From daee69f19e917f9cfbc39466a159c51a08ecdd41 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Sat, 3 Oct 2026 09:36:49 +0200 Subject: [PATCH 1/2] fix(chat): merging a fork back shows a pending-context notice --- .../threads/ComposerMergeBackNotice.tsx | 27 ++++ .../features/threads/ThreadDetailScreen.tsx | 8 ++ .../features/threads/ThreadRouteScreen.tsx | 27 +++- apps/web/src/components/ChatView.tsx | 28 ++++ .../chat/ComposerMergeBackNotice.tsx | 22 +++ .../src/state/threadRelationships.test.ts | 131 +++++++++++++++++- .../src/state/threadRelationships.ts | 36 +++++ 7 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx create mode 100644 apps/web/src/components/chat/ComposerMergeBackNotice.tsx diff --git a/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx b/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx new file mode 100644 index 000000000000..60f772cf1c2d --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx @@ -0,0 +1,27 @@ +import { pendingMergeBackNotice } from "@t3tools/client-runtime/state/thread-relationships"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; + +/** + * A merged-back fork changes what the next send carries. The notice leaves on + * its own once the next run consumes the transfer and the feed shows the + * context handoff divider. + */ +export function ComposerMergeBackNotice(props: { readonly sourceThreadTitle: string | null }) { + const notice = pendingMergeBackNotice(props.sourceThreadTitle); + return ( + + + + {notice.title} + {`. ${notice.description}`} + + + ); +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 623ae734053d..c04addb1d16a 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -129,6 +129,7 @@ import { import { ThreadFeed, type ThreadFeedHistoryControls } from "./ThreadFeed"; import { useThreadTurnSubagents } from "./ThreadAgentsSheet"; import { ComposerQueuedEditBanner } from "./ComposerQueuedEdit"; +import { ComposerMergeBackNotice } from "./ComposerMergeBackNotice"; import { useThreadQueuedCount } from "./ThreadQueueControl"; import type { ThreadContentPresentation } from "./threadContentPresentation"; import { resolveThreadFeedSubmissionAnchor } from "./thread-feed-live-follow"; @@ -143,6 +144,8 @@ export interface ThreadDetailScreenProps { readonly environmentLabel: string | null; readonly feedbackSubmissions: ReadonlyArray; readonly onDismissFeedback: (id: MessageId) => void; + /** Set while a merged-back fork waits to be included in the next send. */ + readonly pendingMergeBack: { readonly sourceThreadTitle: string | null } | null; readonly selectedThreadFeed: ReadonlyArray; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; @@ -1172,6 +1175,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread /> ) : null} + {props.pendingMergeBack !== null ? ( + + ) : null} resolvePendingMergeBackTransfer(selectedThreadDetail), + [selectedThreadDetail], + ); + const selectedEnvironmentId = selectedThread?.environmentId ?? null; + const pendingMergeBackSourceRef = useMemo( + () => + pendingMergeBack === null || selectedEnvironmentId === null + ? null + : scopeThreadRef(selectedEnvironmentId, pendingMergeBack.sourceThreadId), + [pendingMergeBack, selectedEnvironmentId], + ); + const pendingMergeBackSourceTitle = useThreadShell(pendingMergeBackSourceRef)?.title ?? null; + const hasPendingMergeBack = pendingMergeBack !== null; + // Stable identity keeps the memoized detail screen from re-rendering. + const pendingMergeBackNotice = useMemo( + () => (hasPendingMergeBack ? { sourceThreadTitle: pendingMergeBackSourceTitle } : null), + [hasPendingMergeBack, pendingMergeBackSourceTitle], + ); const handleMergeBack = useCallback(async () => { if ( mergeBackBusyRef.current || @@ -996,6 +1020,7 @@ function ThreadRouteContent( environmentLabel={selectedEnvironmentConnection?.environmentLabel ?? null} feedbackSubmissions={composer.feedbackSubmissions} onDismissFeedback={composer.dismissFeedback} + pendingMergeBack={pendingMergeBackNotice} selectedThreadFeed={composer.selectedThreadFeed} activityRun={composer.selectedThreadActivityRun} activeWorkStartedAt={ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8e105a49f37f..16a70cf84208 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,6 +34,7 @@ import { isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { feedbackBannerItem } from "./chat/ComposerFeedback"; +import { mergeBackBannerItem } from "./chat/ComposerMergeBackNotice"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as Schema from "effect/Schema"; @@ -97,6 +98,7 @@ import { presentPendingBackgroundWork, } from "@t3tools/client-runtime/state/thread-execution"; import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; +import { resolvePendingMergeBackTransfer } from "@t3tools/client-runtime/state/thread-relationships"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -6972,6 +6974,28 @@ export default function ChatView(props: ChatViewProps) { onDismiss: acknowledgeActiveThreadWoke, }; }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); + const pendingMergeBack = useMemo( + () => resolvePendingMergeBackTransfer(serverProjection), + [serverProjection], + ); + const mergeBackSourceRef = useMemo( + () => + pendingMergeBack === null + ? null + : scopeThreadRef(environmentId, pendingMergeBack.sourceThreadId), + [environmentId, pendingMergeBack], + ); + const mergeBackSourceTitle = useThreadShell(mergeBackSourceRef)?.title ?? null; + const mergeBackBanner = useMemo( + () => + pendingMergeBack === null + ? null + : mergeBackBannerItem({ + transferId: pendingMergeBack.id, + sourceThreadTitle: mergeBackSourceTitle, + }), + [mergeBackSourceTitle, pendingMergeBack], + ); const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { return null; @@ -7166,12 +7190,14 @@ export default function ChatView(props: ChatViewProps) { const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; + const mergeBackItems = mergeBackBanner === null ? [] : [mergeBackBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, + ...mergeBackItems, ...projectCloneItems, ...systemComposerBannerItems, ...backgroundWorkItems, @@ -7184,6 +7210,7 @@ export default function ChatView(props: ChatViewProps) { ...feedbackBannerItems, ...limitRecoveryItems, ...usageLimitsItems, + ...mergeBackItems, ...projectCloneItems, ...systemComposerBannerItems, ...backgroundWorkItems, @@ -7239,6 +7266,7 @@ export default function ChatView(props: ChatViewProps) { isRestoringThreadBranch, backgroundWorkBannerItem, localCheckoutBranchMismatch, + mergeBackBanner, parkedThreadBannerItem, projectCloneBannerItem, resumeCompactionBannerItem, diff --git a/apps/web/src/components/chat/ComposerMergeBackNotice.tsx b/apps/web/src/components/chat/ComposerMergeBackNotice.tsx new file mode 100644 index 000000000000..8f102340d8da --- /dev/null +++ b/apps/web/src/components/chat/ComposerMergeBackNotice.tsx @@ -0,0 +1,22 @@ +import { pendingMergeBackNotice } from "@t3tools/client-runtime/state/thread-relationships"; +import type { ContextTransferId } from "@t3tools/contracts"; + +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +/** + * A merged-back fork changes what the next send carries, so it gets a composer + * notice. It has no dismiss: the next run consumes the transfer and the + * timeline's context handoff divider takes over. + */ +export function mergeBackBannerItem(input: { + readonly transferId: ContextTransferId; + readonly sourceThreadTitle: string | null; +}): ComposerBannerStackItem { + return { + id: `merge-back:${input.transferId}`, + variant: "info", + icon: , + ...pendingMergeBackNotice(input.sourceThreadTitle), + }; +} diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 7455d9148c09..8cc107e147b6 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { + ContextTransferId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2ContextTransfer, + type OrchestrationV2ThreadProjection, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { @@ -8,6 +15,7 @@ import { orderWebThreadLineageRows, relatedThreadIds, resolveMergeBackTargetThreadId, + resolvePendingMergeBackTransfer, walkThreadRelationships, threadRelationshipRowStatus, } from "./threadRelationships.ts"; @@ -472,3 +480,124 @@ describe("web thread lineage ordering", () => { ).toEqual([first, second, third]); }); }); + +describe("pending merge-back", () => { + const target = ThreadId.make("thread-target"); + const fork = ThreadId.make("thread-fork"); + const now = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); + const thread = { + id: target, + projectId: ProjectId.make("project"), + title: "Source thread", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "sonnet" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { rootThreadId: target, parentThreadId: null, relationshipToParent: null }, + forkedFrom: null, + createdBy: "user", + creationSource: "web", + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + } satisfies OrchestrationV2ThreadProjection["thread"]; + + function transfer(id: string, overrides: Partial = {}) { + return { + id: ContextTransferId.make(id), + type: "merge_back", + status: "pending", + sourceThreadId: fork, + targetThreadId: target, + sourcePoint: { threadId: fork }, + basePoint: null, + sourceProviderInstanceId: null, + targetProviderInstanceId: null, + targetRunId: null, + resolution: null, + createdBy: "user", + error: null, + createdAt: now, + updatedAt: now, + consumedAt: null, + ...overrides, + } satisfies OrchestrationV2ContextTransfer; + } + + function resolve(contextTransfers: ReadonlyArray) { + return resolvePendingMergeBackTransfer({ thread, contextTransfers }); + } + + it("returns null for a missing projection or no transfers", () => { + expect(resolvePendingMergeBackTransfer(null)).toBeNull(); + expect(resolve([])).toBeNull(); + }); + + it("selects by updatedAt rather than creation or array order without copying or sorting", () => { + const newer = Object.freeze( + transfer("newer", { + updatedAt: DateTime.makeUnsafe("2026-06-20T00:02:00.000Z"), + }), + ); + const older = Object.freeze( + transfer("older", { + createdAt: DateTime.makeUnsafe("2026-06-20T00:03:00.000Z"), + updatedAt: DateTime.makeUnsafe("2026-06-20T00:01:00.000Z"), + }), + ); + expect(resolve(Object.freeze([newer, older]))).toBe(newer); + expect(resolve(Object.freeze([older, newer]))).toBe(newer); + }); + + it("matches the server's last-entry-wins tie-break for equal updatedAt", () => { + const first = transfer("first"); + const second = transfer("second"); + expect(resolve([first, second])).toBe(second); + expect(resolve([second, first])).toBe(first); + }); + + it.each(["resolved_native", "resolved_portable", "failed", "consumed", "superseded"] as const)( + "ignores a newer %s transfer", + (status) => { + const pending = transfer("pending"); + const settled = transfer("settled", { + status, + updatedAt: DateTime.makeUnsafe("2026-06-20T00:05:00.000Z"), + }); + expect(resolve([pending, settled])).toBe(pending); + expect(resolve([settled])).toBeNull(); + }, + ); + + it.each(["fork", "provider_handoff", "subagent_spawn", "subagent_result"] as const)( + "ignores a pending %s transfer", + (type) => { + expect(resolve([transfer("other", { type })])).toBeNull(); + }, + ); + + it("ignores outgoing transfers and transfers for a different target", () => { + expect( + resolve([ + transfer("outgoing", { sourceThreadId: target, targetThreadId: fork }), + transfer("elsewhere", { targetThreadId: ThreadId.make("elsewhere") }), + ]), + ).toBeNull(); + }); + + it("replaces a superseded merge-back and clears when the replacement is consumed", () => { + const first = transfer("first"); + expect(resolve([first])).toBe(first); + const superseded = { ...first, status: "superseded" as const }; + const replacement = transfer("replacement"); + expect(resolve([superseded, replacement])).toBe(replacement); + expect(resolve([superseded, { ...replacement, status: "consumed" }])).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 93ed8e9973f3..52aa8229bc7c 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -1,4 +1,5 @@ import type { + OrchestrationV2ContextTransfer, OrchestrationV2ThreadProjection, OrchestrationV2ThreadShell, ThreadId, @@ -41,6 +42,41 @@ export function resolveMergeBackTargetThreadId( : projection.thread.lineage.parentThreadId; } +/** + * The merge-back the next send on this thread will carry, or null. Mirrors the + * server, which consumes only the newest pending merge-back targeting the thread. + */ +export function resolvePendingMergeBackTransfer( + projection: Pick | null, +): OrchestrationV2ContextTransfer | null { + if (projection === null) return null; + let latest: OrchestrationV2ContextTransfer | null = null; + for (const transfer of projection.contextTransfers) { + if ( + transfer.type !== "merge_back" || + transfer.status !== "pending" || + transfer.targetThreadId !== projection.thread.id + ) { + continue; + } + if ( + latest === null || + DateTime.toEpochMillis(transfer.updatedAt) >= DateTime.toEpochMillis(latest.updatedAt) + ) { + latest = transfer; + } + } + return latest; +} + +/** Copy for the composer notice shown while a merge-back waits for the next send. */ +export function pendingMergeBackNotice(sourceThreadTitle: string | null) { + return { + title: `Merged back from ${sourceThreadTitle ?? "a fork"}`, + description: "Its context will be included in your next message", + }; +} + function edgeKey(edge: ThreadRelationshipEdge): string { return `${edge.sourceThreadId}\u001f${edge.targetThreadId}\u001f${edge.kind}`; } From c4e79e99fc873e161b667a8c6cd6706e404dd9fb Mon Sep 17 00:00:00 2001 From: Mnigos Date: Sat, 3 Oct 2026 09:46:15 +0200 Subject: [PATCH 2/2] fix(chat): merge-back notice says when the next send cannot carry it The notice promised the merged context would ride on the next message even when the server would not use it: a send while a run is active is rejected or steered without the transfer, and pending merge-backs from more than one fork reject every send. The notice now says to send once the thread is idle, and warns when more than one fork is pending. --- .../threads/ComposerMergeBackNotice.tsx | 10 +++- .../features/threads/ThreadDetailScreen.tsx | 10 ++-- .../features/threads/ThreadRouteScreen.tsx | 32 ++++++++--- apps/web/src/components/ChatView.tsx | 23 +++++--- .../chat/ComposerMergeBackNotice.tsx | 7 ++- .../src/state/threadRelationships.test.ts | 55 +++++++++++++++++++ .../src/state/threadRelationships.ts | 55 +++++++++++++++++-- 7 files changed, 162 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx b/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx index 60f772cf1c2d..666591e60688 100644 --- a/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx +++ b/apps/mobile/src/features/threads/ComposerMergeBackNotice.tsx @@ -9,12 +9,16 @@ import { SymbolView } from "../../components/AppSymbol"; * its own once the next run consumes the transfer and the feed shows the * context handoff divider. */ -export function ComposerMergeBackNotice(props: { readonly sourceThreadTitle: string | null }) { - const notice = pendingMergeBackNotice(props.sourceThreadTitle); +export function ComposerMergeBackNotice(props: { + readonly sourceThreadTitle: string | null; + readonly forkCount: number; + readonly waitsForIdle: boolean; +}) { + const notice = pendingMergeBackNotice(props); return ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index c04addb1d16a..bf25bcbe5c26 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -145,7 +145,11 @@ export interface ThreadDetailScreenProps { readonly feedbackSubmissions: ReadonlyArray; readonly onDismissFeedback: (id: MessageId) => void; /** Set while a merged-back fork waits to be included in the next send. */ - readonly pendingMergeBack: { readonly sourceThreadTitle: string | null } | null; + readonly pendingMergeBack: { + readonly sourceThreadTitle: string | null; + readonly forkCount: number; + readonly waitsForIdle: boolean; + } | null; readonly selectedThreadFeed: ReadonlyArray; readonly activityRun: ThreadFeedLatestRun | null; readonly activeWorkStartedAt: string | null; @@ -1176,9 +1180,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ) : null} {props.pendingMergeBack !== null ? ( - + ) : null} resolvePendingMergeBackTransfer(selectedThreadDetail), + () => resolvePendingMergeBack(selectedThreadDetail), [selectedThreadDetail], ); + // Primitives keep the memoized detail screen stable while the projection streams. + const mergeBackSourceThreadId = pendingMergeBack?.transfer.sourceThreadId ?? null; + const mergeBackForkCount = pendingMergeBack?.forkCount ?? 0; + const mergeBackWaitsForIdle = pendingMergeBack?.waitsForIdle ?? false; const selectedEnvironmentId = selectedThread?.environmentId ?? null; const pendingMergeBackSourceRef = useMemo( () => - pendingMergeBack === null || selectedEnvironmentId === null + mergeBackSourceThreadId === null || selectedEnvironmentId === null ? null - : scopeThreadRef(selectedEnvironmentId, pendingMergeBack.sourceThreadId), - [pendingMergeBack, selectedEnvironmentId], + : scopeThreadRef(selectedEnvironmentId, mergeBackSourceThreadId), + [mergeBackSourceThreadId, selectedEnvironmentId], ); const pendingMergeBackSourceTitle = useThreadShell(pendingMergeBackSourceRef)?.title ?? null; - const hasPendingMergeBack = pendingMergeBack !== null; - // Stable identity keeps the memoized detail screen from re-rendering. const pendingMergeBackNotice = useMemo( - () => (hasPendingMergeBack ? { sourceThreadTitle: pendingMergeBackSourceTitle } : null), - [hasPendingMergeBack, pendingMergeBackSourceTitle], + () => + mergeBackSourceThreadId === null + ? null + : { + sourceThreadTitle: pendingMergeBackSourceTitle, + forkCount: mergeBackForkCount, + waitsForIdle: mergeBackWaitsForIdle, + }, + [ + mergeBackForkCount, + mergeBackSourceThreadId, + mergeBackWaitsForIdle, + pendingMergeBackSourceTitle, + ], ); const handleMergeBack = useCallback(async () => { if ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 16a70cf84208..6ae66c800d19 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -98,7 +98,7 @@ import { presentPendingBackgroundWork, } from "@t3tools/client-runtime/state/thread-execution"; import { threadSupportsProviderHandoff } from "@t3tools/client-runtime/state/thread-workflows"; -import { resolvePendingMergeBackTransfer } from "@t3tools/client-runtime/state/thread-relationships"; +import { resolvePendingMergeBack } from "@t3tools/client-runtime/state/thread-relationships"; import { codexFeedbackMessage, parseCodexFeedbackCommand, @@ -6975,26 +6975,33 @@ export default function ChatView(props: ChatViewProps) { }; }, [acknowledgeActiveThreadWoke, activeThread?.id, activeThreadWokeVisible]); const pendingMergeBack = useMemo( - () => resolvePendingMergeBackTransfer(serverProjection), + () => resolvePendingMergeBack(serverProjection), [serverProjection], ); + // Primitives keep the banner stable while the projection streams. + const mergeBackTransferId = pendingMergeBack?.transfer.id ?? null; + const mergeBackSourceThreadId = pendingMergeBack?.transfer.sourceThreadId ?? null; + const mergeBackForkCount = pendingMergeBack?.forkCount ?? 0; + const mergeBackWaitsForIdle = pendingMergeBack?.waitsForIdle ?? false; const mergeBackSourceRef = useMemo( () => - pendingMergeBack === null + mergeBackSourceThreadId === null ? null - : scopeThreadRef(environmentId, pendingMergeBack.sourceThreadId), - [environmentId, pendingMergeBack], + : scopeThreadRef(environmentId, mergeBackSourceThreadId), + [environmentId, mergeBackSourceThreadId], ); const mergeBackSourceTitle = useThreadShell(mergeBackSourceRef)?.title ?? null; const mergeBackBanner = useMemo( () => - pendingMergeBack === null + mergeBackTransferId === null ? null : mergeBackBannerItem({ - transferId: pendingMergeBack.id, + transferId: mergeBackTransferId, sourceThreadTitle: mergeBackSourceTitle, + forkCount: mergeBackForkCount, + waitsForIdle: mergeBackWaitsForIdle, }), - [mergeBackSourceTitle, pendingMergeBack], + [mergeBackForkCount, mergeBackSourceTitle, mergeBackTransferId, mergeBackWaitsForIdle], ); const parkedThreadBannerItem = useMemo(() => { if (!activeThreadSnoozed && !activeThreadSettled) { diff --git a/apps/web/src/components/chat/ComposerMergeBackNotice.tsx b/apps/web/src/components/chat/ComposerMergeBackNotice.tsx index 8f102340d8da..5ae3f7917f2c 100644 --- a/apps/web/src/components/chat/ComposerMergeBackNotice.tsx +++ b/apps/web/src/components/chat/ComposerMergeBackNotice.tsx @@ -12,11 +12,14 @@ import type { ComposerBannerStackItem } from "./ComposerBannerStack"; export function mergeBackBannerItem(input: { readonly transferId: ContextTransferId; readonly sourceThreadTitle: string | null; + readonly forkCount: number; + readonly waitsForIdle: boolean; }): ComposerBannerStackItem { + const { blocked, ...notice } = pendingMergeBackNotice(input); return { id: `merge-back:${input.transferId}`, - variant: "info", + variant: blocked ? "warning" : "info", icon: , - ...pendingMergeBackNotice(input.sourceThreadTitle), + ...notice, }; } diff --git a/packages/client-runtime/src/state/threadRelationships.test.ts b/packages/client-runtime/src/state/threadRelationships.test.ts index 8cc107e147b6..970ace39fd07 100644 --- a/packages/client-runtime/src/state/threadRelationships.test.ts +++ b/packages/client-runtime/src/state/threadRelationships.test.ts @@ -15,6 +15,8 @@ import { orderWebThreadLineageRows, relatedThreadIds, resolveMergeBackTargetThreadId, + pendingMergeBackNotice, + resolvePendingMergeBack, resolvePendingMergeBackTransfer, walkThreadRelationships, threadRelationshipRowStatus, @@ -600,4 +602,57 @@ describe("pending merge-back", () => { expect(resolve([superseded, replacement])).toBe(replacement); expect(resolve([superseded, { ...replacement, status: "consumed" }])).toBeNull(); }); + + function resolveState( + contextTransfers: ReadonlyArray, + runStatuses: ReadonlyArray = [], + ) { + return resolvePendingMergeBack({ + thread, + contextTransfers, + runs: runStatuses.map((status) => ({ status })) as never, + }); + } + + it("expects the next send to carry the transfer on an idle thread", () => { + const pending = transfer("pending"); + expect(resolveState([pending], ["completed", "interrupted"])).toEqual({ + transfer: pending, + forkCount: 1, + waitsForIdle: false, + }); + expect(resolveState([])).toBeNull(); + expect(resolvePendingMergeBack(null)).toBeNull(); + }); + + it.each(["preparing", "starting", "running", "waiting"])( + "waits for idle while a run is %s, as the server rejects or steers that send", + (status) => { + expect(resolveState([transfer("pending")], ["completed", status])?.waitsForIdle).toBe(true); + }, + ); + + it("counts distinct pending forks, which the server rejects together", () => { + const otherFork = ThreadId.make("thread-other-fork"); + const later = DateTime.makeUnsafe("2026-06-20T00:01:00.000Z"); + const state = resolveState([ + transfer("fork-older", { status: "superseded" }), + transfer("fork-newer"), + transfer("other-fork", { sourceThreadId: otherFork, updatedAt: later }), + transfer("third-fork-done", { sourceThreadId: ThreadId.make("done"), status: "consumed" }), + ]); + expect(state?.forkCount).toBe(2); + expect(state?.transfer.id).toBe("other-fork"); + expect(resolveState([transfer("a"), transfer("b", { updatedAt: later })])?.forkCount).toBe(1); + }); + + it("only marks the notice as blocking when more than one fork is pending", () => { + const notice = (forkCount: number, waitsForIdle: boolean) => + pendingMergeBackNotice({ sourceThreadTitle: "Fork", forkCount, waitsForIdle }); + expect(notice(1, false).blocked).toBe(false); + expect(notice(1, true).blocked).toBe(false); + expect(notice(1, true).description).not.toBe(notice(1, false).description); + expect(notice(2, false).blocked).toBe(true); + expect(notice(2, true).blocked).toBe(true); + }); }); diff --git a/packages/client-runtime/src/state/threadRelationships.ts b/packages/client-runtime/src/state/threadRelationships.ts index 52aa8229bc7c..5fd61914f8d5 100644 --- a/packages/client-runtime/src/state/threadRelationships.ts +++ b/packages/client-runtime/src/state/threadRelationships.ts @@ -6,6 +6,8 @@ import type { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import { resolveActiveThreadRun } from "./threadWorkflows.ts"; + export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "transfer"; export interface ThreadRelationshipNode { @@ -69,11 +71,56 @@ export function resolvePendingMergeBackTransfer( return latest; } -/** Copy for the composer notice shown while a merge-back waits for the next send. */ -export function pendingMergeBackNotice(sourceThreadTitle: string | null) { +export interface PendingMergeBack { + readonly transfer: OrchestrationV2ContextTransfer; + /** Distinct forks with a pending merge-back here. The server rejects sends while this exceeds 1. */ + readonly forkCount: number; + /** A run is active. The server only consumes the transfer on a send made once the thread is idle. */ + readonly waitsForIdle: boolean; +} + +/** The pending merge-back on this thread, with what keeps the next send from carrying it. */ +export function resolvePendingMergeBack( + projection: Pick | null, +): PendingMergeBack | null { + const transfer = resolvePendingMergeBackTransfer(projection); + if (projection === null || transfer === null) return null; + const forks = new Set(); + for (const candidate of projection.contextTransfers) { + if ( + candidate.type === "merge_back" && + candidate.status === "pending" && + candidate.targetThreadId === projection.thread.id + ) { + forks.add(candidate.sourceThreadId); + } + } + return { + transfer, + forkCount: forks.size, + waitsForIdle: resolveActiveThreadRun(projection) !== null, + }; +} + +/** Copy for the composer notice shown while a merge-back waits for a send. */ +export function pendingMergeBackNotice(input: { + readonly sourceThreadTitle: string | null; + readonly forkCount: number; + readonly waitsForIdle: boolean; +}) { + if (input.forkCount > 1) { + return { + blocked: true, + title: `Merged back from ${input.forkCount} forks`, + description: "Sending will fail while more than one merged fork is pending", + }; + } return { - title: `Merged back from ${sourceThreadTitle ?? "a fork"}`, - description: "Its context will be included in your next message", + blocked: false, + title: `Merged back from ${input.sourceThreadTitle ?? "a fork"}`, + description: input.waitsForIdle + ? "Its context will be included in the next message you send once this thread is idle" + : "Its context will be included in your next message", }; }