diff --git a/apps/server/src/threadTabs/http.ts b/apps/server/src/threadTabs/http.ts index 27d0277bfb1c2..5edb39fd1f348 100644 --- a/apps/server/src/threadTabs/http.ts +++ b/apps/server/src/threadTabs/http.ts @@ -21,7 +21,7 @@ import { } from "../auth/http.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; -import { summarizeSiblingChat } from "./summary.ts"; +import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts"; interface TabRow { readonly threadId: string; @@ -187,32 +187,43 @@ export const threadTabsHttpApiLayer = HttpApiBuilder.group( ); if (!group) return yield* failEnvironmentNotFound("thread_not_found"); const sourceIds = [...new Set(args.payload.sourceThreadIds)]; + const { beforeMessageId } = args.payload; if ( sourceIds.includes(args.params.threadId) || - sourceIds.some((id) => !group.tabs.some((tab) => tab.threadId === id)) + sourceIds.some((id) => !group.tabs.some((tab) => tab.threadId === id)) || + (beforeMessageId !== undefined && sourceIds.length !== 1) ) { return yield* failEnvironmentInvalidRequest("invalid_command"); } + // A fork cuts the history at its message, which may be older than the recent window. + const window = beforeMessageId === undefined ? { turnLimit: 8 } : undefined; const sections = yield* Effect.forEach(sourceIds, (sourceId) => - snapshots.getThreadDetailSnapshot(sourceId, { turnLimit: 8 }).pipe( - Effect.map((detail) => { + snapshots.getThreadDetailSnapshot(sourceId, window).pipe( + Effect.catch((cause) => failEnvironmentInternal("internal_error", cause)), + Effect.flatMap((detail) => { const tab = group.tabs.find((entry) => entry.threadId === sourceId); - if (Option.isNone(detail) || !tab) return ""; + if (Option.isNone(detail) || !tab) return Effect.succeed(""); const { thread, snapshotSequence } = detail.value; - return `Source thread: ${sourceId} (snapshot ${snapshotSequence})\n${summarizeSiblingChat( - { - title: tab.title, - worktreePath: thread.worktreePath, - latestTurnState: thread.latestTurn?.state ?? null, - messages: thread.messages, - activities: thread.activities, - checkpoints: thread.checkpoints, - proposedPlans: thread.proposedPlans, - }, - )}`; + const chat = { + title: tab.title, + worktreePath: thread.worktreePath, + latestTurnState: thread.latestTurn?.state ?? null, + messages: thread.messages, + activities: thread.activities, + checkpoints: thread.checkpoints, + proposedPlans: thread.proposedPlans, + }; + const source = + beforeMessageId === undefined + ? chat + : siblingChatBeforeMessage(chat, beforeMessageId); + if (!source) return failEnvironmentInvalidRequest("invalid_command"); + return Effect.succeed( + `Source thread: ${sourceId} (snapshot ${snapshotSequence})\n${summarizeSiblingChat(source)}`, + ); }), ), - ).pipe(Effect.catch((cause) => failEnvironmentInternal("internal_error", cause))); + ); return { text: sections.filter(Boolean).join("\n\n"), }; diff --git a/apps/server/src/threadTabs/summary.test.ts b/apps/server/src/threadTabs/summary.test.ts index 1414ae89a2a0a..620bca0950b47 100644 --- a/apps/server/src/threadTabs/summary.test.ts +++ b/apps/server/src/threadTabs/summary.test.ts @@ -8,7 +8,7 @@ import { type OrchestrationMessage, type OrchestrationThreadActivity, } from "@t3tools/contracts"; -import { summarizeSiblingChat } from "./summary.ts"; +import { siblingChatBeforeMessage, summarizeSiblingChat } from "./summary.ts"; let clock = 0; // Strictly increasing timestamps order the fixture chronologically. @@ -165,4 +165,43 @@ describe("sibling chat handoff", () => { expect(summary).toContain("Conclusion"); expect(summary.length).toBeLessThan(3_000); }); + + it("forks from a user message with only the history before it", () => { + const messages = [ + message("u1", "user", "Build the search view"), + message("a1", "assistant", "Built it"), + ]; + const earlyTool = activity("tool.completed", "Read search.ts", { status: "completed" }); + const earlyFiles = checkpoint([ + { path: "search.ts", kind: "modified", additions: 1, deletions: 0 }, + ]); + messages.push(message("u2", "user", "Now add filters")); + messages.push(message("a2", "assistant", "Added filters")); + const lateTool = activity("tool.completed", "Read filters.ts", { status: "completed" }); + const lateFiles = checkpoint([ + { path: "filters.ts", kind: "modified", additions: 4, deletions: 0 }, + ]); + const chat = { + title: "Search", + latestTurnState: "completed", + messages, + activities: [earlyTool, lateTool], + checkpoints: [earlyFiles, lateFiles], + }; + + const forked = siblingChatBeforeMessage(chat, "u2"); + expect(forked).not.toBeNull(); + const summary = summarizeSiblingChat(forked!); + expect(summary).toContain("User: Build the search view"); + expect(summary).toContain("Assistant: Built it"); + expect(summary).toContain("Read search.ts"); + expect(summary).toContain("search.ts (+1 −0)"); + expect(summary).not.toContain("Now add filters"); + expect(summary).not.toContain("Added filters"); + expect(summary).not.toContain("filters.ts"); + expect(summary).not.toContain("Latest turn"); + + expect(siblingChatBeforeMessage(chat, "a1")).toBeNull(); + expect(siblingChatBeforeMessage(chat, "missing")).toBeNull(); + }); }); diff --git a/apps/server/src/threadTabs/summary.ts b/apps/server/src/threadTabs/summary.ts index 469704f009a65..2acd049a6d38e 100644 --- a/apps/server/src/threadTabs/summary.ts +++ b/apps/server/src/threadTabs/summary.ts @@ -27,6 +27,29 @@ export interface SiblingChatSource { readonly proposedPlans?: ReadonlyArray; } +/** + * The chat as it stood just before a user message, for forking from that message. Null when the + * message is not one of the chat's user messages. + */ +export function siblingChatBeforeMessage( + source: SiblingChatSource, + messageId: string, +): SiblingChatSource | null { + const index = source.messages.findIndex((message) => message.id === messageId); + const cutoff = source.messages[index]; + if (!cutoff || cutoff.role !== "user") return null; + const before = (at: string) => at < cutoff.createdAt; + return { + ...source, + // The latest turn is at or after the cutoff, so its state says nothing about this history. + latestTurnState: null, + messages: source.messages.slice(0, index), + activities: (source.activities ?? []).filter((activity) => before(activity.createdAt)), + checkpoints: (source.checkpoints ?? []).filter((checkpoint) => before(checkpoint.completedAt)), + proposedPlans: (source.proposedPlans ?? []).filter((plan) => before(plan.createdAt)), + }; +} + type Entry = | { readonly at: string; readonly kind: "message"; readonly message: OrchestrationMessage } | { readonly at: string; readonly kind: "tool"; readonly label: string } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff7e6d71ad80d..d07f92fbccf26 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -378,7 +378,7 @@ import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd, worktreeSetupAgentStarted } from "./chat/MessagesTimeline.logic"; import { resolveComposerTimelineInset, resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; -import { ThreadTabContextPills, useThreadTabGroup } from "./chat/ThreadTabs"; +import { ThreadTabContextPills, forkThreadTab, useThreadTabGroup } from "./chat/ThreadTabs"; import { runtime } from "../lib/runtime"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -1467,6 +1467,33 @@ const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {}; const noopHeldRevert = (_targetTurnCount: number) => {}; + +/** Puts a sent message's downloaded attachments back into a composer draft, in their order. */ +function restoreMessageAttachments( + target: ScopedThreadRef | DraftId, + message: ChatMessage, + files: ReadonlyArray, +) { + const images: ComposerImageAttachment[] = []; + const restoredFiles: ComposerFileAttachment[] = []; + files.forEach((file, index) => { + const attachment = { + id: randomUUID(), + name: file.name, + mimeType: file.type, + sizeBytes: file.size, + file, + }; + if (message.attachments?.[index]?.type === "image") { + images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); + } else { + restoredFiles.push({ ...attachment, type: "file" }); + } + }); + const store = useComposerDraftStore.getState(); + store.addImages(target, images, { allowDuplicates: true }); + store.addFiles(target, restoredFiles, { allowDuplicates: true }); +} const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** @@ -7113,24 +7140,7 @@ export default function ChatView(props: ChatViewProps) { ? `${currentPrompt}\n\n${restoredPrompt}` : restoredPrompt; store.setPrompt(composerDraftTarget, nextPrompt); - const images: ComposerImageAttachment[] = []; - const restoredFiles: ComposerFileAttachment[] = []; - files.forEach((file, index) => { - const attachment = { - id: randomUUID(), - name: file.name, - mimeType: file.type, - sizeBytes: file.size, - file, - }; - if (message.attachments?.[index]?.type === "image") { - images.push({ ...attachment, type: "image", previewUrl: URL.createObjectURL(file) }); - } else { - restoredFiles.push({ ...attachment, type: "file" }); - } - }); - store.addImages(composerDraftTarget, images, { allowDuplicates: true }); - store.addFiles(composerDraftTarget, restoredFiles, { allowDuplicates: true }); + restoreMessageAttachments(composerDraftTarget, message, files); if (currentRouteThreadKeyRef.current === routeThreadKey) { promptRef.current = nextPrompt; composerRef.current?.resetCursorState({ prompt: nextPrompt, cursor: nextPrompt.length }); @@ -7172,6 +7182,52 @@ export default function ChatView(props: ChatViewProps) { ], ); + // Forking opens a new tab whose draft repeats a user message after a summary of what came + // before it, so the message can be retried with another model or provider. + const forkInFlightRef = useRef(false); + const onForkFromMessage = async (messageId: MessageId) => { + if (!activeThread || !isServerThread || forkInFlightRef.current) return; + const index = activeThread.messages.findIndex((message) => message.id === messageId); + const message = activeThread.messages[index]; + if (!message || message.role !== "user") return; + const connection = readPreparedConnection(environmentId); + if (!connection) { + toastManager.add({ type: "error", title: "The environment is not connected." }); + return; + } + forkInFlightRef.current = true; + try { + const files = await prepareRevertedMessageAttachments({ + message, + environmentId, + httpBaseUrl: connection.httpBaseUrl, + createAssetUrl: createAttachmentAssetUrl, + }); + const tabRef = await forkThreadTab(connection, { + environmentId, + sourceThreadId: activeThread.id, + sourceTitle: activeThread.title, + modelSelection: activeThread.modelSelection, + messageId, + prompt: recallableComposerPrompt(message.text), + hasHistory: index > 0, + }); + restoreMessageAttachments(tabRef, message, files); + await navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: tabRef.threadId }, + }); + } catch (cause) { + toastManager.add({ + type: "error", + title: "Could not fork into a new tab", + description: cause instanceof Error ? cause.message : undefined, + }); + } finally { + forkInFlightRef.current = false; + } + }; + const onCompactContext = async () => { if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) { return; @@ -9472,6 +9528,11 @@ export default function ChatView(props: ChatViewProps) { const onRevertTimelineTurn = useCallback((targetTurnCount: number, messageId: MessageId) => { void onRevertToTurnCountRef.current(targetTurnCount, messageId); }, []); + const onForkFromMessageRef = useRef(onForkFromMessage); + onForkFromMessageRef.current = onForkFromMessage; + const onForkTimelineMessage = useCallback((messageId: MessageId) => { + void onForkFromMessageRef.current(messageId); + }, []); // Files dropped on a sidebar row land here once the dropped-on thread is // actually open, then take the exact same path as a workspace drop: @@ -9904,6 +9965,9 @@ export default function ChatView(props: ChatViewProps) { onRevertToTurnCount={ paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurn } + {...(isServerThread && !paintOnlyDisplayedTimeline + ? { onForkFromMessage: onForkTimelineMessage } + : {})} isRevertingCheckpoint={!paintOnlyDisplayedTimeline && isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index df2911196df2d..a55970b2f1164 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -132,6 +132,7 @@ import { SquarePenIcon, TerminalIcon, Undo2Icon, + GitForkIcon, WrenchIcon, XIcon, ZapIcon, @@ -284,6 +285,7 @@ interface TimelineRowSharedState { skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; + onForkFromMessage: ((messageId: MessageId) => void) | null; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onRunShellCommand: ((command: string) => void) | undefined; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -433,6 +435,8 @@ interface MessagesTimelineProps { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; supportsConversationRollback: boolean; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; + /** Opens a new tab seeded from a user message; omitted where the chat cannot have tabs. */ + onForkFromMessage?: (messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; onRunShellCommand?: (command: string) => void; isRevertingCheckpoint: boolean; @@ -503,6 +507,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, supportsConversationRollback, onRevertToTurnCount, + onForkFromMessage, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, onRunShellCommand, isRevertingCheckpoint, @@ -1158,6 +1163,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertToTurnCount, + onForkFromMessage: onForkFromMessage ?? null, onUseArtifactTemplate, onRunShellCommand, onImageExpand, @@ -1194,6 +1200,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertToTurnCount, + onForkFromMessage, onUseArtifactTemplate, onRunShellCommand, onImageExpand, @@ -2242,6 +2249,9 @@ function UserTimelineRow({ row }: { row: Extract )} + {ctx.onForkFromMessage && ( + + )} {resolvedContext.text && ( void; +}) { + return ( + + onFork(messageId)} + aria-label="Fork into new tab" + /> + } + > + + + Fork into new tab + + ); +} + /** * Hover-revealed wall-clock time with a full-date tooltip — the same metadata * presentation as message rows, for work entries and turn folds. The parent diff --git a/apps/web/src/components/chat/ThreadTabs.tsx b/apps/web/src/components/chat/ThreadTabs.tsx index 21f23586b6bbe..bc46f3d91bd11 100644 --- a/apps/web/src/components/chat/ThreadTabs.tsx +++ b/apps/web/src/components/chat/ThreadTabs.tsx @@ -3,10 +3,13 @@ import { listThreadTabs, prepareThreadTabHandoff, } from "@t3tools/client-runtime/thread-tabs"; +import type { PreparedConnection } from "@t3tools/client-runtime/connection"; import { COMPOSER_CONTEXT_THREAD_TAB_SUMMARY_MAX_CHARS, type EnvironmentId, + type MessageId, type ModelSelection, + type ScopedThreadRef, type ThreadId, type ThreadTabGroup, } from "@t3tools/contracts"; @@ -22,8 +25,10 @@ import { PlusIcon, XIcon } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useThreadActions } from "../../hooks/useThreadActions"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { type ComposerContextReference, + formatInlineContextReference, toKindScopedComposerContextId, } from "../../lib/composerContextReferences"; import { runtime } from "../../lib/runtime"; @@ -290,35 +295,99 @@ function ThreadTabContextPill(props: { /** * Captures a sibling tab's transcript summary into the draft of `threadId` and resolves to the - * chip reference for the composer to place. Null until the thread and connection are ready. + * chip reference for the composer to place. With `beforeMessageId`, only the history before + * that user message of the source is summarized. */ +async function captureThreadTabContext( + connection: PreparedConnection, + input: { + threadId: ThreadId; + sourceThreadId: ThreadId; + title: string; + beforeMessageId?: MessageId; + }, +): Promise { + const { threadId, sourceThreadId, title, beforeMessageId } = input; + const handoff = await runtime.runPromise( + prepareThreadTabHandoff(connection, threadId, { + sourceThreadIds: [sourceThreadId], + ...(beforeMessageId ? { beforeMessageId } : {}), + }), + ); + const contextId = toKindScopedComposerContextId("thread-tab", sourceThreadId); + const label = sanitizeComposerContextLabel(title, "thread-tab"); + useThreadTabContextStore.getState().upsert(threadId, { + version: 1, + kind: "thread-tab", + contextId, + label, + threadId: sourceThreadId, + title: title.slice(0, 2_048), + summary: handoff.text.slice(0, COMPOSER_CONTEXT_THREAD_TAB_SUMMARY_MAX_CHARS), + }); + return { kind: "thread-tab", contextId, label } satisfies ComposerContextReference; +} + +/** Hook form of `captureThreadTabContext`, bound to the thread being composed in. */ export function useCaptureThreadTabContext( environmentId: EnvironmentId, threadId: ThreadId | null, ) { const prepared = usePreparedConnection(environmentId); - const upsertRecord = useThreadTabContextStore((state) => state.upsert); return useMemo(() => { if (threadId === null || Option.isNone(prepared)) return null; const connection = prepared.value; - return async (sourceThreadId: ThreadId, title: string): Promise => { - const handoff = await runtime.runPromise( - prepareThreadTabHandoff(connection, threadId, { sourceThreadIds: [sourceThreadId] }), - ); - const contextId = toKindScopedComposerContextId("thread-tab", sourceThreadId); - const label = sanitizeComposerContextLabel(title, "thread-tab"); - upsertRecord(threadId, { - version: 1, - kind: "thread-tab", - contextId, - label, - threadId: sourceThreadId, - title: title.slice(0, 2_048), - summary: handoff.text.slice(0, COMPOSER_CONTEXT_THREAD_TAB_SUMMARY_MAX_CHARS), - }); - return { kind: "thread-tab", contextId, label } satisfies ComposerContextReference; - }; - }, [prepared, threadId, upsertRecord]); + return (sourceThreadId: ThreadId, title: string) => + captureThreadTabContext(connection, { threadId, sourceThreadId, title }); + }, [prepared, threadId]); +} + +/** + * Forks a chat at one of its user messages: opens a new tab in the same thread whose draft holds + * a summary of everything before that message, followed by the message itself, so it can be + * sent again with another model. Resolves to the new tab once the client knows about it. + */ +export async function forkThreadTab( + connection: PreparedConnection, + input: { + environmentId: EnvironmentId; + sourceThreadId: ThreadId; + sourceTitle: string; + modelSelection: ModelSelection; + messageId: MessageId; + /** The message's text as it should be recalled into a composer. */ + prompt: string; + /** False for the chat's first message, which has no history to summarize. */ + hasHistory: boolean; + }, +): Promise { + const threadId = newThreadId(); + await runtime.runPromise( + createThreadTab(connection, input.sourceThreadId, { + threadId, + modelSelection: input.modelSelection, + }), + ); + const reference = input.hasHistory + ? await captureThreadTabContext(connection, { + threadId, + sourceThreadId: input.sourceThreadId, + title: input.sourceTitle, + beforeMessageId: input.messageId, + }) + : null; + const threadRef = scopeThreadRef(input.environmentId, threadId); + useComposerDraftStore + .getState() + .setPrompt( + threadRef, + [reference ? formatInlineContextReference(reference) : "", input.prompt] + .filter((part) => part.length > 0) + .join("\n\n"), + ); + // The thread route redirects away from threads the client store has not heard of yet. + await waitForThreadShell(threadRef); + return threadRef; } /** diff --git a/docs/fork-differences.md b/docs/fork-differences.md index aa5721ae78b6b..dff144dc5cf69 100644 --- a/docs/fork-differences.md +++ b/docs/fork-differences.md @@ -107,9 +107,13 @@ is its own conversation and provider. `packages/shared/src/composerContextReferences.ts`. The summary covers the recent conversation, tools, reasoning, errors, changed files, and the latest plan (`apps/server/src/threadTabs/summary.ts`). It is captured when chosen, so later changes in that chat do not change it. +- **Fork from a message.** On web and desktop, a user message's hover actions include **Fork into + new tab**. It opens a new tab whose draft holds a `thread-tab` chip summarizing the chat before + that message (`beforeMessageId` on the handoff request), then the message's text and attachments, + so it can be resent with another model or provider (`forkThreadTab` in `ThreadTabs.tsx`). - **Mobile.** A switcher menu switches, creates, and closes tabs (`apps/mobile/src/features/threads/ThreadTabs.tsx`). An empty tab can attach sibling context when - sending. Mobile does not have the header crumb, the `@` chip, or the sidebar tab list. + sending. Mobile does not have the header crumb, the `@` chip, forking, or the sidebar tab list. User guide: [thread-sidebar.md](./user/thread-sidebar.md#continue-in-another-tab). diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ebc8c153fe494..dce3aa2f7eb94 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -35,6 +35,11 @@ The summary is captured when you click, so later changes in that chat do not cha the same workspace can edit the same files, so review the current checkout before restoring changes. +To retry a message with another model or provider on web or desktop, hover the message and click +**Fork into new tab**. The new tab's composer holds a summary of the chat up to that message, +followed by the message itself and its attachments. Pick a model, edit if you like, and send. +Files the original chat changed after that message stay changed, since tabs share the workspace. + ### Start in the background In a desktop browser or the desktop app, press `Cmd+Enter` on macOS or `Ctrl+Enter` diff --git a/packages/contracts/src/threadTabs.ts b/packages/contracts/src/threadTabs.ts index 2e281ca1a9a49..2becb14f27032 100644 --- a/packages/contracts/src/threadTabs.ts +++ b/packages/contracts/src/threadTabs.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { MessageId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ModelSelection } from "./orchestration.ts"; export const ThreadTab = Schema.Struct({ @@ -32,6 +32,8 @@ export type CreateThreadTabInput = typeof CreateThreadTabInput.Type; export const ThreadTabHandoffInput = Schema.Struct({ sourceThreadIds: Schema.Array(ThreadId).check(Schema.isMaxLength(8)), + /** Forking: summarize only what came before this user message of the single source. */ + beforeMessageId: Schema.optional(MessageId), }); export type ThreadTabHandoffInput = typeof ThreadTabHandoffInput.Type;