diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index ad6ff10291cc..f0aa2ab8b6b1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,16 @@ -import { findRecordedWorktreeSetup, mergeDirectPreviewAnnotation } from "./ChatView.logic"; +import { + boundAttachmentIds, + findRecordedWorktreeSetup, + mergeDirectPreviewAnnotation, + planComposerTurn, + snapshotComposerSend, + withMessageContext, +} from "./ChatView.logic"; import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; +import { ensureInlineContextReferences } from "../lib/composerContextReferences"; +import { terminalContextReference } from "../lib/composerContextRecords"; import { CommandId, EnvironmentId, @@ -1024,3 +1033,128 @@ describe("mergeDirectPreviewAnnotation", () => { expect(result.previewAnnotations[0]?.screenshot).toBeNull(); }); }); + +describe("snapshotComposerSend", () => { + const expired = { + id: "ctx-expired", + threadId, + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "", + createdAt: now, + }; + const live = { ...expired, id: "ctx-live", text: "pnpm test", lineStart: 9, lineEnd: 9 }; + + it("drops expired terminal contexts from the contexts and from the prompt", () => { + const snapshot = snapshotComposerSend({ + prompt: ensureInlineContextReferences("run", [ + terminalContextReference(expired), + terminalContextReference(live), + ]), + images: [], + files: [], + terminalContexts: [expired, live], + sendableTerminalContexts: [live], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + }); + + expect(snapshot.terminalContexts).toEqual([live]); + expect(snapshot.messageText).toBe( + ensureInlineContextReferences("run", [terminalContextReference(live)]).trim(), + ); + }); +}); + +describe("planComposerTurn", () => { + const base = { + messageText: "hello", + sendQueuesBehindSetup: false, + isServerThread: false, + activeMessageCount: 0, + sendEnvMode: "worktree" as const, + worktreePath: null, + activeThreadBranch: "main", + resumeCompactionTokens: null, + keepFullHistory: false, + phase: "ready" as const, + dispatchMode: "auto" as const, + }; + + it("prepares a worktree from the selected branch on a new thread", () => { + expect(planComposerTurn(base)).toMatchObject({ + missingWorktreeBaseBranch: false, + baseBranchForWorktree: "main", + shouldQueueBehindActiveRun: false, + }); + }); + + it("refuses a new worktree without a base branch", () => { + expect(planComposerTurn({ ...base, activeThreadBranch: null })).toMatchObject({ + missingWorktreeBaseBranch: true, + baseBranchForWorktree: null, + }); + }); + + it("queues behind a compaction of a stale session, except for /compact itself", () => { + expect(planComposerTurn({ ...base, resumeCompactionTokens: 120_000 })).toMatchObject({ + compactBeforeSend: true, + turnDispatchMode: "queue", + shouldQueueBehindActiveRun: true, + }); + expect( + planComposerTurn({ ...base, resumeCompactionTokens: 120_000, messageText: "/COMPACT" }), + ).toMatchObject({ compactBeforeSend: false, turnDispatchMode: "auto" }); + }); + + it("queues behind a running turn only when asked to", () => { + const running = { + ...base, + isServerThread: true, + activeMessageCount: 3, + phase: "running" as const, + }; + expect(planComposerTurn({ ...running, dispatchMode: "queue" }).shouldQueueBehindActiveRun).toBe( + true, + ); + expect(planComposerTurn({ ...running, dispatchMode: "steer" })).toMatchObject({ + shouldQueueBehindActiveRun: false, + baseBranchForWorktree: null, + }); + }); +}); + +describe("withMessageContext", () => { + const context = { records: [] } as unknown as NonNullable< + Parameters[0]["context"] + >; + + it("sends the context inline when the server supports it", () => { + expect(withMessageContext({ text: "hi", context, supportsInlineMessageContext: true })).toEqual( + { text: "hi", context }, + ); + }); + + it("folds the context into the text for older servers", () => { + const result = withMessageContext({ text: "hi", context, supportsInlineMessageContext: false }); + expect(result.context).toBeUndefined(); + expect(result.text).toContain("hi"); + }); + + it("sends the text alone without context", () => { + expect( + withMessageContext({ text: "hi", context: undefined, supportsInlineMessageContext: false }), + ).toEqual({ text: "hi" }); + }); +}); + +describe("boundAttachmentIds", () => { + it("prefers the uploaded id and falls back to the local one", () => { + expect( + boundAttachmentIds([{ id: "upload-1" }, {}], [{ id: "local-1" }, { id: "local-2" }]), + ).toEqual(["upload-1", "local-2"]); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index a64dbfb3e520..64abb91c5c3d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -16,6 +16,7 @@ import { ProjectId, type MessageId, type ModelSelection, + type OrchestrationMessageContext, type OrchestrationV2ProjectedTurnItem, type PreviewAnnotationPayload, type ProviderInteractionMode, @@ -53,6 +54,7 @@ import { type TurnDiffSummary, } from "../types"; import { + type ComposerFileAttachment, type ComposerImageAttachment, type DraftId, type DraftThreadState, @@ -61,8 +63,22 @@ import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells, environmentThreadDetails } from "../state/threads"; import { waitForAtomValue } from "../state/waitForAtomValue"; -import { filterTerminalContextsWithText, type TerminalContextDraft } from "../lib/terminalContext"; -import { stripInlineContextReferences } from "~/lib/composerContextReferences"; +import { + filterTerminalContextsWithText, + formatTerminalContextLabel, + type TerminalContextDraft, +} from "../lib/terminalContext"; +import { + removeInlineContextReference, + stripInlineContextReferences, +} from "~/lib/composerContextReferences"; +import { + previewAnnotationContextLabel, + reviewCommentContextLabel, + terminalContextReference, +} from "../lib/composerContextRecords"; +import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; +import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftThreadEnvMode } from "../composerDraftStore"; import { collapseExpandedComposerCursor, type ComposerSubmissionIntent } from "../composer-logic"; import type { ReviewCommentContext } from "../reviewCommentContext"; @@ -1442,3 +1458,155 @@ export function makeModelDisabledReason( return reason ? `${reason.description} Start a new thread to use this model.` : null; }; } + +/** What the composer held when the user pressed send, after a direct annotation merged in. */ +export interface ComposerSendContents { + readonly prompt: string; + readonly images: ReadonlyArray; + readonly files: ReadonlyArray; + readonly terminalContexts: ReadonlyArray; + readonly sendableTerminalContexts: ReadonlyArray; + readonly previewAnnotations: ReadonlyArray; + readonly reviewComments: ReadonlyArray; + readonly threadContexts: ReadonlyArray; +} + +/** + * Copies the composer contents a send dispatches, so a failed send can put the + * same draft back. Terminal contexts that lost their text are dropped from both + * the contexts and the prompt's inline references. + */ +export function snapshotComposerSend(contents: ComposerSendContents) { + const images = [...contents.images]; + const files = [...contents.files]; + const terminalContexts = [...contents.sendableTerminalContexts]; + return { + images, + files, + attachments: [...images, ...files], + terminalContexts, + previewAnnotations: [...contents.previewAnnotations], + reviewComments: [...contents.reviewComments], + threadContexts: [...contents.threadContexts], + messageText: contents.terminalContexts + .filter((context) => !terminalContexts.includes(context)) + .reduce( + (text, context) => + removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, + contents.prompt, + ) + .trim(), + }; +} + +export type ComposerSendSnapshot = ReturnType; + +/** How a composer send dispatches: where it runs and whether it waits behind other work. */ +export function planComposerTurn(input: { + readonly messageText: string; + readonly sendQueuesBehindSetup: boolean; + readonly isServerThread: boolean; + readonly activeMessageCount: number; + readonly sendEnvMode: DraftThreadEnvMode; + readonly worktreePath: string | null; + readonly activeThreadBranch: string | null; + readonly resumeCompactionTokens: number | null; + readonly keepFullHistory: boolean; + readonly phase: SessionPhase; + readonly dispatchMode: ComposerDispatchMode; +}) { + const isFirstMessage = + !input.sendQueuesBehindSetup && (!input.isServerThread || input.activeMessageCount === 0); + const shouldCreateWorktree = + isFirstMessage && input.sendEnvMode === "worktree" && !input.worktreePath; + // A stale Claude session compacts before this message so the turn does not re-read the + // old history. The message queues behind the /compact run, since steering into it is + // rejected. + const compactBeforeSend = + input.resumeCompactionTokens !== null && + !input.keepFullHistory && + input.messageText.toLowerCase() !== "/compact"; + return { + // In worktree mode, require an explicit base branch so we don't silently + // fall back to local execution when branch selection is missing. + missingWorktreeBaseBranch: shouldCreateWorktree && !input.activeThreadBranch, + baseBranchForWorktree: shouldCreateWorktree ? input.activeThreadBranch : null, + compactBeforeSend, + turnDispatchMode: + compactBeforeSend || input.sendQueuesBehindSetup ? ("queue" as const) : input.dispatchMode, + shouldQueueBehindActiveRun: + compactBeforeSend || + input.sendQueuesBehindSetup || + (input.phase === "running" && input.dispatchMode === "queue"), + }; +} + +/** + * The attachment ids a message's context records bind to: the upload id the + * server returned, or the local id on the data-URL path. + */ +export function boundAttachmentIds( + turnAttachments: ReadonlyArray<{ readonly id?: string | undefined }>, + attachments: ReadonlyArray<{ readonly id: string }>, +): string[] { + return turnAttachments.map((attachment, index) => + "id" in attachment && attachment.id !== undefined ? attachment.id : attachments[index]!.id, + ); +} + +/** + * The message text and context a turn sends. Servers from before inline + * context drop the records and forward the links as literal text, so their + * turns carry the payload the legacy way. + */ +export function withMessageContext(input: { + readonly text: string; + readonly context: OrchestrationMessageContext | undefined; + readonly supportsInlineMessageContext: boolean; +}): { readonly text: string; readonly context?: OrchestrationMessageContext } { + const { text, context } = input; + if (context === undefined) return { text }; + if (!input.supportsInlineMessageContext) { + return { text: serializeLegacyContextMessage({ text, records: context.records }) }; + } + return { text, context }; +} + +/** The attachments the optimistic user row shows before the server echoes the message. */ +export function optimisticMessageAttachments( + attachments: ReadonlyArray, +) { + return attachments.map((attachment) => + attachment.type === "image" + ? { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.previewUrl, + ...(attachment.source ? { source: attachment.source } : {}), + } + : { + type: "file" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), + }, + ); +} + +/** Fallback title sources for a message with no text of its own. */ +export function sendTitleFallbackLabels(snapshot: ComposerSendSnapshot) { + const terminalContext = snapshot.terminalContexts[0]; + const reviewComment = snapshot.reviewComments[0]; + const previewAnnotation = snapshot.previewAnnotations[0]; + return [ + terminalContext ? formatTerminalContextLabel(terminalContext) : undefined, + reviewComment ? `Review: ${reviewCommentContextLabel(reviewComment)}` : undefined, + previewAnnotation ? previewAnnotationContextLabel(previewAnnotation) : undefined, + ]; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0f7ee3fb813c..da367a88d0f6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -256,10 +256,6 @@ import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; -import { - getComposerPromptInjectionState, - getComposerProviderState, -} from "./chat/composerProviderState"; import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; import { isPreviewFocused } from "../lib/previewFocus"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; @@ -287,25 +283,15 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; -import { - formatTerminalContextLabel, - type TerminalContextDraft, - type TerminalContextSelection, -} from "../lib/terminalContext"; +import { type TerminalContextDraft, type TerminalContextSelection } from "../lib/terminalContext"; import { collectInlineContextIds, ensureInlineContextReferences, - removeInlineContextReference, } from "../lib/composerContextReferences"; -import { serializeLegacyContextMessage } from "@t3tools/shared/composerContextLegacySend"; import { buildMessageContext, - previewAnnotationContextLabel, previewAnnotationContextReference, - reviewCommentContextLabel, - terminalContextReference, } from "../lib/composerContextRecords"; -import { type ReviewCommentContext } from "../reviewCommentContext"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { useEnvironmentQuery } from "../state/query"; @@ -437,13 +423,11 @@ import { getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, - cloneComposerImageForRetry, deriveLockedProvider, readFileAsDataUrl, resolveFileAttachmentUrl, resolveComposerInteractionMode, resolveComposerProviderSelection, - getAntigravitySendBlockReason, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -499,12 +483,21 @@ import { useLocalDispatchState } from "./chat/useLocalDispatchState"; import { draftFanoutSelectionsAtom, draftSendState, + type MultipleModelTarget, + resolveMultipleModelTargets, + restoreComposerDraft, rewindToMessage, startFanoutThread, } from "./chat/chatViewSend"; import { + boundAttachmentIds, chatActionErrorMessage, formatOutgoingPrompt, + optimisticMessageAttachments, + planComposerTurn, + sendTitleFallbackLabels, + snapshotComposerSend, + withMessageContext, isCompactCommandMessage, type LocalThreadErrorEntry, makeModelDisabledReason, @@ -806,8 +799,6 @@ export default function ChatView(props: ChatViewProps) { return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); - const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); - const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); const setComposerDraftTerminalContexts = useComposerDraftStore( (store) => store.setTerminalContexts, ); @@ -5003,36 +4994,48 @@ export default function ChatView(props: ChatViewProps) { return; } const threadIdForSend = activeThread.id; - const isFirstMessage = !sendQueuesBehindSetup && (!isServerThread || activeMessageCount === 0); - const baseBranchForWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath - ? activeThreadBranch - : null; - - // In worktree mode, require an explicit base branch so we don't silently - // fall back to local execution when branch selection is missing. - const shouldCreateWorktree = - isFirstMessage && sendEnvMode === "worktree" && !activeThread.worktreePath; - if (shouldCreateWorktree && !activeThreadBranch) { + const sendSnapshot = snapshotComposerSend({ + prompt: promptForSend, + images: composerImages, + files: composerFiles, + terminalContexts: composerTerminalContexts, + sendableTerminalContexts: sendableComposerTerminalContexts, + previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, + threadContexts: composerThreadContexts, + }); + const { + files: composerFilesSnapshot, + attachments: composerAttachmentsSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + threadContexts: composerThreadContextsSnapshot, + messageText: messageTextForSend, + } = sendSnapshot; + const { + missingWorktreeBaseBranch, + baseBranchForWorktree, + compactBeforeSend, + turnDispatchMode, + shouldQueueBehindActiveRun, + } = planComposerTurn({ + messageText: messageTextForSend, + sendQueuesBehindSetup, + isServerThread, + activeMessageCount, + sendEnvMode, + worktreePath: activeThread.worktreePath, + activeThreadBranch, + resumeCompactionTokens, + keepFullHistory, + phase, + dispatchMode, + }); + if (missingWorktreeBaseBranch) { setThreadError(threadIdForSend, "Select a base branch before sending in New worktree mode."); return; } - - const composerImagesSnapshot = [...composerImages]; - const composerFilesSnapshot = [...composerFiles]; - const composerAttachmentsSnapshot = [...composerImagesSnapshot, ...composerFilesSnapshot]; - const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; - const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; - const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; - const composerThreadContextsSnapshot = [...composerThreadContexts]; - const messageTextForSend = composerTerminalContexts - .filter((context) => !composerTerminalContextsSnapshot.includes(context)) - .reduce( - (text, context) => - removeInlineContextReference(text, terminalContextReference(context).contextId).prompt, - promptForSend, - ) - .trim(); // Records bind attachments by the id each side knows: the local id for the optimistic // row, the upload id (or local id on the data-URL path) on the wire; the server // rebinds them to the persisted id. @@ -5052,18 +5055,6 @@ export default function ChatView(props: ChatViewProps) { ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); - // A stale Claude session compacts before this message so the turn does not re-read the - // old history. The message queues behind the /compact run, since steering into it is - // rejected. - const compactBeforeSend = - resumeCompactionTokens !== null && - !keepFullHistory && - messageTextForSend.toLowerCase() !== "/compact"; - const turnDispatchMode = compactBeforeSend || sendQueuesBehindSetup ? "queue" : dispatchMode; - const shouldQueueBehindActiveRun = - compactBeforeSend || - sendQueuesBehindSetup || - (phase === "running" && dispatchMode === "queue"); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, model: ctxSelectedModel, @@ -5091,53 +5082,20 @@ export default function ChatView(props: ChatViewProps) { }; }; - const multipleTargets: Array<{ - readonly selection: ModelSelection; - readonly text: string; - readonly interactionMode: ProviderInteractionMode; - }> = []; - for (const selection of multipleModelSelections ?? []) { - const provider = providerInstanceEntries.find( - (entry) => entry.instanceId === selection.instanceId, - ); - if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") { - setThreadError(threadIdForSend, `Provider for ${selection.model} is unavailable.`); - return; - } - const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model); - if (providerBlockReason) { - setThreadError(threadIdForSend, providerBlockReason); + const multipleTargets: MultipleModelTarget[] = []; + for (const resolved of resolveMultipleModelTargets({ + selections: multipleModelSelections ?? [], + providerInstanceEntries, + messageText: messageTextForSend, + planModeEnabled: settings.planModeEnabled, + interactionMode: sendInteractionMode, + })) { + if ("error" in resolved) { + setThreadError(threadIdForSend, resolved.error); return; } - const providerState = getComposerProviderState({ - provider: provider.driverKind, - model: selection.model, - models: provider.models, - modelOptions: selection.options, - promptInjectionState: getComposerPromptInjectionState(messageTextForSend), - planModeEnabled: settings.planModeEnabled, - }); - const text = formatOutgoingPrompt({ - provider: provider.driverKind, - model: selection.model, - models: provider.models, - effort: providerState.promptEffort, - text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, - }); - if (composerRef.current?.validateProviderInput(text) === false) return; - multipleTargets.push({ - selection: createModelSelection( - selection.instanceId, - selection.model, - providerState.modelOptionsForDispatch, - ), - text, - interactionMode: resolveComposerInteractionMode({ - planModeEnabled: settings.planModeEnabled, - provider: provider.snapshot, - interactionMode: sendInteractionMode, - }).interactionMode, - }); + if (composerRef.current?.validateProviderInput(resolved.text) === false) return; + multipleTargets.push(resolved); } sendInFlightRef.current = true; @@ -5361,11 +5319,7 @@ export default function ChatView(props: ChatViewProps) { const fileBlockReason = readLiveAttachmentCapabilities().fileBlockReason; if (fileBlockReason !== null) throw new Error(fileBlockReason); const context = buildOutgoingMessageContext( - attachments.map((attachment, index) => - "id" in attachment && attachment.id !== undefined - ? attachment.id - : composerAttachmentsSnapshot[index]!.id, - ), + boundAttachmentIds(attachments, composerAttachmentsSnapshot), ); const title = deriveThreadTitleSeed({ text: messageTextForSend, @@ -5407,15 +5361,12 @@ export default function ChatView(props: ChatViewProps) { message: { messageId: newMessageId(), role: "user", - text: - context && !supportsInlineMessageContext - ? serializeLegacyContextMessage({ - text: target.text, - records: context.records, - }) - : target.text, attachments, - ...(context && supportsInlineMessageContext ? { context } : {}), + ...withMessageContext({ + text: target.text, + context, + supportsInlineMessageContext, + }), }, modelSelection: target.selection, titleSeed: title, @@ -5481,19 +5432,7 @@ export default function ChatView(props: ChatViewProps) { const restoreFailedDraft = () => { setMultipleModelSelections(failedSelections); if (clearedDraft) { - setComposerDraftPrompt(composerDraftTarget, messageTextForSend); - addComposerDraftImages( - composerDraftTarget, - composerImagesSnapshot.map(cloneComposerImageForRetry), - ); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftPreviewAnnotations( - composerDraftTarget, - composerPreviewAnnotationsSnapshot, - ); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); + restoreComposerDraft(composerDraftTarget, sendSnapshot); if (composerRef.current && currentRouteThreadKeyRef.current === routeThreadKey) { promptRef.current = messageTextForSend; composerRef.current.resetCursorState({ @@ -5547,27 +5486,7 @@ export default function ChatView(props: ChatViewProps) { } return; } - const optimisticAttachments = composerAttachmentsSnapshot.map((attachment) => - attachment.type === "image" - ? { - type: "image" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: attachment.previewUrl, - ...(attachment.source ? { source: attachment.source } : {}), - } - : { - type: "file" as const, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - downloadable: false, - ...(attachment.source ? { source: attachment.source } : {}), - }, - ); + const optimisticAttachments = optimisticMessageAttachments(composerAttachmentsSnapshot); if (!shouldQueueBehindActiveRun) { anchorNewTurn( scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), @@ -5619,17 +5538,7 @@ export default function ChatView(props: ChatViewProps) { const title = deriveThreadTitleSeed({ text: messageTextForSend, attachments: composerAttachmentsSnapshot, - fallbackLabels: [ - composerTerminalContextsSnapshot[0] - ? formatTerminalContextLabel(composerTerminalContextsSnapshot[0]) - : undefined, - composerReviewCommentsSnapshot[0] - ? `Review: ${reviewCommentContextLabel(composerReviewCommentsSnapshot[0])}` - : undefined, - composerPreviewAnnotationsSnapshot[0] - ? previewAnnotationContextLabel(composerPreviewAnnotationsSnapshot[0]) - : undefined, - ], + fallbackLabels: sendTitleFallbackLabels(sendSnapshot), }); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, @@ -5726,34 +5635,18 @@ export default function ChatView(props: ChatViewProps) { message: { messageId: messageIdForSend, role: "user", - text: outgoingMessageText, attachments: turnAttachmentsResult.value, - ...(() => { - const context = buildOutgoingMessageContext( - turnAttachmentsResult.value.map((attachment, index) => - "id" in attachment && attachment.id !== undefined - ? attachment.id - : composerAttachmentsSnapshot[index]!.id, - ), - ); - if (context === undefined) return {}; - // Read the capability at dispatch time: the upload and persistence - // awaits above can span a server reconnect that changes it. Servers - // from before inline context drop the records and forward the links - // as literal text, so their turns carry the payload the legacy way. - const supportsInlineMessageContext = + ...withMessageContext({ + text: outgoingMessageText, + context: buildOutgoingMessageContext( + boundAttachmentIds(turnAttachmentsResult.value, composerAttachmentsSnapshot), + ), + // Read at dispatch time: the upload and persistence awaits above can + // span a server reconnect that changes it. + supportsInlineMessageContext: appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment - .capabilities.inlineMessageContext === true; - if (!supportsInlineMessageContext) { - return { - text: serializeLegacyContextMessage({ - text: outgoingMessageText, - records: context.records, - }), - }; - } - return { context }; - })(), + .capabilities.inlineMessageContext === true, + }), }, modelSelection: ctxSelectedModelSelection, titleSeed: title, @@ -5858,14 +5751,7 @@ export default function ChatView(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - setComposerDraftPrompt(composerDraftTarget, messageTextForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - addComposerDraftFiles(composerDraftTarget, composerFilesSnapshot); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - setComposerDraftThreadContexts(composerDraftTarget, composerThreadContextsSnapshot); + const retryComposerImages = restoreComposerDraft(composerDraftTarget, sendSnapshot); if (currentRouteThreadKeyRef.current === routeThreadKey) { promptRef.current = messageTextForSend; composerImagesRef.current = retryComposerImages; diff --git a/apps/web/src/components/chat/chatViewSend.ts b/apps/web/src/components/chat/chatViewSend.ts index b5404bc47c2c..106131998368 100644 --- a/apps/web/src/components/chat/chatViewSend.ts +++ b/apps/web/src/components/chat/chatViewSend.ts @@ -2,6 +2,7 @@ import { CommandId, type EnvironmentId, type ModelSelection, + type ProviderInteractionMode, type ScopedThreadRef, type ThreadId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, @@ -27,7 +28,19 @@ import { useComposerDraftStore, DraftId, } from "../../composerDraftStore"; -import { prepareRevertedMessageAttachments, waitForRevertedMessage } from "../ChatView.logic"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; +import { createModelSelection } from "@t3tools/shared/model"; +import type { ProviderInstanceEntry } from "../../providerInstances"; +import { getComposerPromptInjectionState, getComposerProviderState } from "./composerProviderState"; +import { + formatOutgoingPrompt, + getAntigravitySendBlockReason, + resolveComposerInteractionMode, + cloneComposerImageForRetry, + type ComposerSendSnapshot, + prepareRevertedMessageAttachments, + waitForRevertedMessage, +} from "../ChatView.logic"; import { readPreparedConnection } from "../../state/session"; import { recallableComposerPrompt } from "./composerPromptHistory"; @@ -235,3 +248,81 @@ export async function rewindToMessage(input: { return { error: error instanceof Error ? error.message : "Failed to revert thread state." }; } } + +/** + * Writes a failed send's composer contents back into its draft. Returns the + * restored images, which are fresh copies so their preview URLs outlive the + * optimistic row that held the originals. + */ +export function restoreComposerDraft( + draftTarget: ScopedThreadRef | DraftId, + snapshot: ComposerSendSnapshot, +): ComposerImageAttachment[] { + const store = useComposerDraftStore.getState(); + const images = snapshot.images.map(cloneComposerImageForRetry); + store.setPrompt(draftTarget, snapshot.messageText); + store.addImages(draftTarget, images); + store.addFiles(draftTarget, snapshot.files); + store.setTerminalContexts(draftTarget, snapshot.terminalContexts); + store.setPreviewAnnotations(draftTarget, snapshot.previewAnnotations); + store.setReviewComments(draftTarget, snapshot.reviewComments); + store.setThreadContexts(draftTarget, snapshot.threadContexts); + return images; +} + +export interface MultipleModelTarget { + readonly selection: ModelSelection; + readonly text: string; + readonly interactionMode: ProviderInteractionMode; +} + +/** + * The thread each selected model starts for a multiple-model send, in + * selection order, or why that model cannot take the message. + */ +export function resolveMultipleModelTargets(input: { + readonly selections: ReadonlyArray; + readonly providerInstanceEntries: ReadonlyArray; + readonly messageText: string; + readonly planModeEnabled: boolean; + readonly interactionMode: ProviderInteractionMode; +}): Array { + const { messageText, planModeEnabled } = input; + return input.selections.map((selection) => { + const provider = input.providerInstanceEntries.find( + (entry) => entry.instanceId === selection.instanceId, + ); + if (!provider?.enabled || !provider.isAvailable || provider.status !== "ready") { + return { error: `Provider for ${selection.model} is unavailable.` }; + } + const providerBlockReason = getAntigravitySendBlockReason(provider.snapshot, selection.model); + if (providerBlockReason) return { error: providerBlockReason }; + const providerState = getComposerProviderState({ + provider: provider.driverKind, + model: selection.model, + models: provider.models, + modelOptions: selection.options, + promptInjectionState: getComposerPromptInjectionState(messageText), + planModeEnabled, + }); + return { + selection: createModelSelection( + selection.instanceId, + selection.model, + providerState.modelOptionsForDispatch, + ), + text: formatOutgoingPrompt({ + provider: provider.driverKind, + model: selection.model, + models: provider.models, + effort: providerState.promptEffort, + text: messageText || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + }), + interactionMode: resolveComposerInteractionMode({ + planModeEnabled, + provider: provider.snapshot, + interactionMode: input.interactionMode, + }).interactionMode, + }; + }); +}