diff --git a/apps/mobile/src/features/threads/useStartSideChat.ts b/apps/mobile/src/features/threads/useStartSideChat.ts index d329cc519039c..ffb1e3bf7a286 100644 --- a/apps/mobile/src/features/threads/useStartSideChat.ts +++ b/apps/mobile/src/features/threads/useStartSideChat.ts @@ -2,18 +2,27 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { isAtomCommandInterrupted, + settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { MessageId, ThreadId, type ModelSelection, + type OrchestrationMessageContext, type RuntimeMode, type ScopedThreadRef, } from "@t3tools/contracts"; import * as Dialog from "../../components/ui/Dialog"; +import { prepareTurnAttachments } from "../../lib/attachmentUpload"; +import { + serializeComposerMessageForServer, + uploadedComposerContext, +} from "../../lib/composerContext"; +import type { DraftComposerAttachment } from "../../lib/composerImages"; import { uuidv4 } from "../../lib/uuid"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; @@ -21,8 +30,14 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { waitForThreadShell } from "./threadForkNavigation"; import { useOpenLinkedThread } from "./useOpenLinkedThread"; +export interface SideChatMessage { + readonly text: string; + readonly attachments: ReadonlyArray; + readonly context: OrchestrationMessageContext | undefined; +} + /** - * Forks `source` at its latest stable point into a side chat, sends `prompt` there, and opens + * Forks `source` at its latest stable point into a side chat, sends `message` there, and opens * it like any linked thread. Resolves false when the side chat did not start. `settings` are * what the parent's composer would send, which its thread gets only on its next send. */ @@ -44,9 +59,23 @@ export function useStartSideChat() { return async ( source: ScopedThreadRef, - prompt: string, + message: SideChatMessage, settings: { readonly runtimeMode: RuntimeMode; readonly modelSelection: ModelSelection }, ): Promise => { + // Upload first, so an attachment that cannot go out never leaves an empty side chat. + const prepared = await settlePromise(() => + prepareTurnAttachments({ + environmentId: source.environmentId, + attachments: message.attachments, + supportsImageUploads: true, + }), + ); + if (prepared._tag === "Failure") { + alertFailure(prepared, "Could not upload the attachments."); + return false; + } + if (prepared.value.status !== "ready") return false; + const attachments = prepared.value.attachments; const targetThreadId = ThreadId.make(uuidv4()); const fork = await forkSideChat({ environmentId: source.environmentId, @@ -104,11 +133,14 @@ export function useStartSideChat() { message: { messageId: MessageId.make(uuidv4()), role: "user", - text: prompt, - attachments: [], + ...serializeComposerMessageForServer( + restoreAttachmentOnlyBootstrapPrompt(message.text, attachments.length > 0), + uploadedComposerContext(message.context, message.attachments, attachments), + ), + attachments, }, modelSelection: settings.modelSelection, - titleSeed: deriveThreadTitleSeed({ text: prompt, attachments: [] }), + titleSeed: deriveThreadTitleSeed(message), runtimeMode: settings.runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), diff --git a/apps/mobile/src/state/thread-task-permissions.test.ts b/apps/mobile/src/state/thread-task-permissions.test.ts index 5a31c1955b928..e56e70091b270 100644 --- a/apps/mobile/src/state/thread-task-permissions.test.ts +++ b/apps/mobile/src/state/thread-task-permissions.test.ts @@ -9,6 +9,7 @@ import { RuntimeRequestId, TurnItemId, type ModelSelection, + type OrchestrationMessageContext, type OrchestrationV2ThreadProjection, type RuntimeMode, type ServerConfig, @@ -31,6 +32,7 @@ const state = vi.hoisted(() => ({ text: "Keep my draft", attachments: [] as DraftComposerImageAttachment[], modelSelection: undefined as ModelSelection | undefined, + context: undefined as OrchestrationMessageContext | undefined, runtimeMode: undefined as RuntimeMode | undefined, }, serverConfig: null as ServerConfig | null, @@ -200,6 +202,7 @@ beforeEach(() => { text: "Keep my draft", attachments: [], modelSelection: undefined, + context: undefined, runtimeMode: undefined, }; state.freshPending = false; @@ -349,6 +352,22 @@ describe("mobile task permissions", () => { ...state.serverConfig, environment: { capabilities: { sideChats: true } }, } as ServerConfig; + const screenshot = { id: "screenshot" } as DraftComposerImageAttachment; + const screenshotContext = { + version: 1, + records: [ + { + version: 1, + kind: "image", + contextId: "ctx-screenshot", + label: "screenshot.png", + attachmentId: "screenshot", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 10, + }, + ], + } as unknown as OrchestrationMessageContext; // The thread gets the composer's mode and model only on its next send. const selection: ModelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -358,6 +377,8 @@ describe("mobile task permissions", () => { state.draft = { ...state.draft, text: "/side why does this fail?", + attachments: [screenshot], + context: screenshotContext, modelSelection: selection, runtimeMode: "approval-required", }; @@ -367,12 +388,25 @@ describe("mobile task permissions", () => { expect(await composer.onSendMessage()).toBeNull(); expect(startSideChat).toHaveBeenCalledExactlyOnceWith( { environmentId: "secondary", threadId: "thread" }, - "why does this fail?", + { text: "why does this fail?", attachments: [screenshot], context: screenshotContext }, { runtimeMode: "approval-required", modelSelection: selection }, ); expect(state.enqueue).not.toHaveBeenCalled(); expect(state.clearDraft).toHaveBeenCalled(); + // Context other than attachments stays out of side chats. + vi.clearAllMocks(); + state.draft = { + ...state.draft, + context: { + version: 1, + records: [{ kind: "terminal" }], + } as unknown as OrchestrationMessageContext, + }; + expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull(); + expect(startSideChat).not.toHaveBeenCalled(); + expect(state.clearDraft).not.toHaveBeenCalled(); + // An attachment added while the side chat starts stays in the draft. vi.clearAllMocks(); startSideChat.mockImplementationOnce(async () => { @@ -382,7 +416,7 @@ describe("mobile task permissions", () => { }; return true; }); - state.draft = { ...state.draft, text: "/side again?", attachments: [] }; + state.draft = { ...state.draft, text: "/side again?", attachments: [], context: undefined }; expect(await useThreadComposerState({ startSideChat }).onSendMessage()).toBeNull(); expect(startSideChat).toHaveBeenCalledOnce(); expect(state.clearDraft).not.toHaveBeenCalled(); diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 61a72f1af65dc..9d6a89354ddf9 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -59,6 +59,7 @@ import { appendPendingThreadMessages, selectPendingAcknowledgedMessages, } from "../features/threads/pending-thread-feed"; +import type { SideChatMessage } from "../features/threads/useStartSideChat"; import { threadAllowsProviderSwitch } from "./thread-provider-switching"; import { appAtomRegistry } from "./atom-registry"; import { pendingThreadCreationMessage } from "./pending-thread-creation"; @@ -178,10 +179,10 @@ export function useThreadDraftForThread(input: { } export function useThreadComposerState(input: { - /** Forks the thread into a side chat that gets `prompt`; resolves false when it did not start. */ + /** Forks the thread into a side chat that gets `message`; resolves false when it did not start. */ readonly startSideChat: ( source: ScopedThreadRef, - prompt: string, + message: SideChatMessage, settings: { readonly runtimeMode: RuntimeMode; readonly modelSelection: ModelSelection }, ) => Promise; }) { @@ -558,16 +559,26 @@ export function useThreadComposerState(input: { }; // A redirect's text answers its approval, so `/side` there is that text, not the command. const sideChatCommand = beforeDispatch === undefined ? parseSideChatCommand(text) : null; + const contextBlockReason = composerContextSendBlockReason(draft.context); if (sideChatCommand) { if (sideChatUnavailableReason !== null) { Dialog.alert("Side chat not started", sideChatUnavailableReason); - } else if (attachments.length > 0 || (draft.context?.records.length ?? 0) > 0) { + } else if ( + draft.context?.records.some((record) => record.kind !== "image" && record.kind !== "file") + ) { Dialog.alert( - "Side chats take text only", - "Remove the attachments and context, then send it again.", + "Side chats take text and attachments only", + "Remove the added context, then send it again.", ); - } else if (sideChatCommand.prompt.length === 0) { + } else if (sideChatCommand.prompt.length === 0 && attachments.length === 0) { Dialog.alert("Ask a side question", "Type the question after /side, then send it."); + } else if (attachments.length > PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { + Dialog.alert( + "Too many attachments", + `Remove attachments until there are at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS}.`, + ); + } else if (contextBlockReason) { + Dialog.alert("Too much context", contextBlockReason); } else if (modelUnavailable) { alertModelUnavailable(); } else if (!startingSideChatRef.current) { @@ -575,7 +586,7 @@ export function useThreadComposerState(input: { const started = await input .startSideChat( { environmentId: thread.environmentId, threadId: thread.id }, - sideChatCommand.prompt, + { text: sideChatCommand.prompt, attachments, context: draft.context }, { runtimeMode: draft.runtimeMode ?? thread.runtimeMode, modelSelection, @@ -589,8 +600,11 @@ export function useThreadComposerState(input: { if ( started && current.text === draft.text && - current.attachments.length === 0 && - (current.context?.records.length ?? 0) === 0 + current.attachments.length === attachments.length && + current.attachments.every( + (attachment, index) => attachment.id === attachments[index]?.id, + ) && + JSON.stringify(current.context) === JSON.stringify(draft.context) ) { clearComposerDraftContent(threadKey); } @@ -619,7 +633,6 @@ export function useThreadComposerState(input: { return null; } - const contextBlockReason = composerContextSendBlockReason(draft.context); if (contextBlockReason) { Dialog.alert("Too much context", contextBlockReason); return null; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7de02cc3941ad..b438d0bab2408 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { buildExpiredTerminalContextToastCopy, mergeDirectPreviewAnnotation, planComposerTurn, + sideChatFirstMessage, snapshotComposerSend, withMessageContext, deriveAvailableRevertTurnCounts, @@ -12,6 +13,7 @@ import { ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import { deriveProviderInstanceEntries } from "../providerInstances"; import { ensureInlineContextReferences } from "../lib/composerContextReferences"; import { terminalContextReference } from "../lib/composerContextRecords"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { CommandId, EnvironmentId, @@ -1164,6 +1166,76 @@ describe("boundAttachmentIds", () => { }); }); +describe("sideChatFirstMessage", () => { + const image = { + type: "image" as const, + id: "local-image", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 4, + previewUrl: "blob:screenshot", + file: new File(["png!"], "screenshot.png", { type: "image/png" }), + }; + const file = { + type: "file" as const, + id: "local-file", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 5, + file: null, + }; + const uploaded = [ + { + type: "image" as const, + id: "upload-image", + name: image.name, + mimeType: "image/png", + sizeBytes: 4, + }, + { + type: "file" as const, + id: "upload-file", + name: file.name, + mimeType: "text/plain", + sizeBytes: 5, + }, + ]; + + it("sends the attachments with context bound to their upload ids", () => { + const message = sideChatFirstMessage({ + prompt: " what is this? ", + attachments: [image, file], + turnAttachments: uploaded, + }); + expect(message?.text).toBe("what is this?"); + expect(message?.attachments).toEqual(uploaded); + expect(message?.titleSeed).toBe("what is this?"); + expect(message?.context?.records).toMatchObject([ + { kind: "image", attachmentId: "upload-image" }, + { kind: "file", attachmentId: "upload-file" }, + ]); + }); + + it("sends attachments without text as an attachment-only message", () => { + const message = sideChatFirstMessage({ + prompt: "", + attachments: [image], + turnAttachments: uploaded.slice(0, 1), + }); + expect(message?.text).toBe(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT); + expect(message?.titleSeed).toBe("Attached image"); + }); + + it("sends plain text alone, and nothing for a bare /side", () => { + expect(sideChatFirstMessage({ prompt: "why?", attachments: [], turnAttachments: [] })).toEqual({ + text: "why?", + attachments: [], + titleSeed: "why?", + }); + expect(sideChatFirstMessage({ prompt: " ", attachments: [], turnAttachments: [] })).toBeNull(); + }); +}); + describe("deriveAvailableRevertTurnCounts", () => { const providerThreadId = ProviderThreadId.make("provider-thread"); const otherProviderThreadId = ProviderThreadId.make("other-provider-thread"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 09a25ee55910d..cacedef5bd0c9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -21,6 +21,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, type AssetCreateUrlInput, type AssetCreateUrlResult, + type ChatAttachment, type ChatFileAttachment, type CommandId, type EnvironmentId, @@ -40,6 +41,7 @@ import { type ThreadId, type ThreadLinkedPullRequest, type RunId, + type UploadChatAttachment, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; @@ -63,6 +65,8 @@ import { type Thread, type TurnDiffSummary, } from "../types"; +import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/composerContextReferences"; import { type ComposerFileAttachment, type ComposerImageAttachment, @@ -82,6 +86,7 @@ import { stripInlineContextReferences, } from "~/lib/composerContextReferences"; import { + buildMessageContext, previewAnnotationContextLabel, reviewCommentContextLabel, terminalContextReference, @@ -1361,6 +1366,38 @@ export function withMessageContext(input: { return { text, context }; } +/** + * The first message `/side` sends to its side chat, or null when it only opens one. + * `turnAttachments` are `attachments` as the turn sends them, in the same order. + */ +export function sideChatFirstMessage(input: { + readonly prompt: string; + readonly attachments: ReadonlyArray; + readonly turnAttachments: ReadonlyArray; +}) { + const text = input.prompt.trim(); + if (text.length === 0 && input.turnAttachments.length === 0) return null; + const attachmentIds = boundAttachmentIds(input.turnAttachments, input.attachments); + return { + ...withMessageContext({ + text: text || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + context: buildMessageContext({ + terminalContexts: [], + reviewComments: [], + previewAnnotations: [], + attachments: input.attachments.map((attachment, index) => ({ + attachment, + attachmentId: attachmentIds[index] ?? attachment.id, + })), + }), + }), + attachments: input.turnAttachments, + titleSeed: deriveThreadTitleSeed({ text, attachments: input.turnAttachments }), + }; +} + +export type SideChatFirstMessage = NonNullable>; + /** The attachments the optimistic user row shows before the server echoes the message. */ export function optimisticMessageAttachments( attachments: ReadonlyArray, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6daf189f7d6df..776cdc448b11d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11,6 +11,7 @@ import { deriveAvailableRevertTurnCounts, deriveThreadPendingBackgroundTasks, presentThreadShellFromProjection, + type SideChatFirstMessage, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; import { useComposerSelection } from "./chat/useComposerSelection"; @@ -2793,7 +2794,9 @@ export default function ChatView(props: ChatViewProps) { ? "Available once the thread's worktree is set up." : null; const openSideChatStable = useStableCallback(() => void sideChat.open()); - const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); + const sendSideChatStable = useStableCallback((message: SideChatFirstMessage | null) => + sideChat.open(message), + ); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ environmentId, @@ -2864,7 +2867,7 @@ export default function ChatView(props: ChatViewProps) { scrollToEnd, setThreadError, }); - const { onSend } = useComposerSend({ + const { onSend, sideChatStarting } = useComposerSend({ environmentId, routeThreadKey, currentRouteThreadKeyRef, @@ -2916,6 +2919,7 @@ export default function ChatView(props: ChatViewProps) { respondingRequestIds, activePendingProgress, onAdvanceActivePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, onRespondToApproval: onRespondToApprovalStable, beginLocalDispatch, resetLocalDispatch, @@ -3587,6 +3591,7 @@ export default function ChatView(props: ChatViewProps) { canInterrupt={canInterruptRunningThread} isSendBusy={ (isSendBusy && !sendQueuesBehindSetup) || + sideChatStarting || (setupQueueSendThreadKey !== null && setupQueueSendThreadKey === activeThreadKey) || queuedRunEdit.isSaving || diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 2b1b8d73c756b..119220de5f7b4 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -5,6 +5,8 @@ import { resolveBackgroundDraftWorkspaceOptions, shouldDockDraftHeroForSubmission, buildExpiredTerminalContextToastCopy, + sideChatFirstMessage, + type SideChatFirstMessage, deriveComposerSendState, boundAttachmentIds, formatOutgoingPrompt, @@ -43,7 +45,7 @@ import { scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { createModelSelection } from "@t3tools/shared/model"; -import { type SetStateAction, useLayoutEffect, useRef } from "react"; +import { type SetStateAction, useLayoutEffect, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { @@ -213,6 +215,7 @@ interface ComposerSendInput { readonly respondingRequestIds: PendingThreadRequests["respondingRequestIds"]; readonly activePendingProgress: PendingThreadRequests["activePendingProgress"]; readonly onAdvanceActivePendingUserInput: PendingThreadRequests["onAdvanceActivePendingUserInput"]; + readonly onChangeActivePendingUserInputCustomAnswer: PendingThreadRequests["onChangeActivePendingUserInputCustomAnswer"]; readonly onRespondToApproval: PendingThreadRequests["onRespondToApproval"]; readonly beginLocalDispatch: LocalDispatchState["beginLocalDispatch"]; readonly resetLocalDispatch: LocalDispatchState["resetLocalDispatch"]; @@ -233,8 +236,8 @@ interface ComposerSendInput { readonly setKeepFullHistory: (threadKey: string, keep: boolean) => void; readonly anchorNewTurn: TimelineFollow["anchorNewTurn"]; readonly acknowledgeActiveThreadWoke: () => void; - /** Resolves true once the side chat started and got the prompt. */ - readonly onSideChat?: ((prompt: string) => Promise) | undefined; + /** Resolves true once the side chat started and got the message. */ + readonly onSideChat?: ((message: SideChatFirstMessage | null) => Promise) | undefined; /** Refuses `/side` with this reason and keeps the draft. */ readonly sideChatDisabledReason: string | null; readonly handleNewThreadInActiveProject: () => void; @@ -306,6 +309,7 @@ export function useComposerSend(input: ComposerSendInput) { respondingRequestIds, activePendingProgress, onAdvanceActivePendingUserInput, + onChangeActivePendingUserInputCustomAnswer, onRespondToApproval: onRespondToApprovalStable, beginLocalDispatch, resetLocalDispatch, @@ -337,6 +341,9 @@ export function useComposerSend(input: ComposerSendInput) { }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); + const clearComposerPromptAndImages = useComposerDraftStore( + (store) => store.clearComposerPromptAndImages, + ); const sendToMultipleModels = useMultipleModelSend({ environmentId, routeThreadKey, @@ -351,6 +358,8 @@ export function useComposerSend(input: ComposerSendInput) { setThreadError, }); const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); + // Keeps the composer busy until a `/side` start settles, uploads included. + const [sideChatStartingThreadKey, setSideChatStartingThreadKey] = useState(null); const composerSendGenerationRef = useRef(0); const environmentUnavailableSendToastSlotRef = useRef(0); // A send awaits uploads before it declines, so it reads the approvals pending by then. @@ -362,6 +371,45 @@ export function useComposerSend(input: ComposerSendInput) { respondingRequestIdsRef.current = respondingRequestIds; }, [pendingApprovals, respondingRequestIds]); + // Side chats need the server's config, so their attachments always upload. + const uploadSideChatAttachments = async ( + threadId: ThreadId, + attachments: ReadonlyArray, + ) => { + const fileBlockReason = () => { + const config = appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId) ?? null; + return fileAttachmentCapabilityBlockReason({ + files: attachments.filter((attachment) => attachment.type === "file"), + attachmentUploadsCapabilityKnown: config !== null, + supportsAttachmentUploads: config !== null, + maxFileAttachmentBytes: + config?.environment.capabilities.fileAttachments?.maxUploadBytes ?? null, + }); + }; + const blockedBeforeUpload = fileBlockReason(); + if (blockedBeforeUpload !== null) { + setThreadError(threadId, blockedBeforeUpload); + return null; + } + for (const attachment of attachments) { + const upload = + readAttachmentUpload(attachment.id)?.status === "failed" + ? retryAttachmentUpload + : startAttachmentUpload; + upload({ environmentId, image: attachment, draftTarget: composerDraftTarget }); + } + await awaitAttachmentUploads(attachments.map((attachment) => attachment.id)); + const blockedAfterUpload = fileBlockReason(); + if (blockedAfterUpload !== null) { + setThreadError(threadId, blockedAfterUpload); + return null; + } + const uploaded = getUploadedAttachments({ environmentId, images: attachments }); + if (uploaded === null) + setThreadError(threadId, "Retry or remove failed uploads before sending."); + return uploaded; + }; + const onSend = async ( e?: { preventDefault: () => void }, dispatchMode: ComposerDispatchMode = "auto", @@ -463,8 +511,6 @@ export function useComposerSend(input: ComposerSendInput) { } if (onSideChat && sendCtx && sideChatCommand && sideChatDisabledReason === null) { if ( - sendCtx.images.length > 0 || - sendCtx.files.length > 0 || filterTerminalContextsWithText(sendCtx.terminalContexts).length > 0 || sendCtx.previewAnnotations.length > 0 || sendCtx.reviewComments.length > 0 || @@ -474,22 +520,45 @@ export function useComposerSend(input: ComposerSendInput) { toastManager.add( stackedThreadToast({ type: "warning", - title: "Side chats take text only", - description: "Remove the attachments and context, then send it again.", + title: "Side chats take text and attachments only", + description: "Remove the added context, then send it again.", }), ); return; } - const started = await onSideChat(sideChatCommand.prompt); + // A pending question's attachments belong to its answer, so the side chat gets none. + const attachments = answeringQuestion ? [] : [...sendCtx.images, ...sendCtx.files]; + const startWithAttachments = async () => { + const turnAttachments = await uploadSideChatAttachments(activeThread.id, attachments); + if (turnAttachments === null || currentRouteThreadKeyRef.current !== routeThreadKey) { + return false; + } + return onSideChat( + sideChatFirstMessage({ prompt: sideChatCommand.prompt, attachments, turnAttachments }), + ); + }; + sendInFlightRef.current = true; + setSideChatStartingThreadKey(routeThreadKey); + const started = await startWithAttachments().finally(() => { + sendInFlightRef.current = false; + setSideChatStartingThreadKey((current) => (current === routeThreadKey ? null : current)); + }); + const current = composerRef.current?.getSendContext(); // Clear only what was sent; the user may have edited the draft or left meanwhile. if ( started && currentRouteThreadKeyRef.current === routeThreadKey && - promptRef.current === promptAtSend + promptRef.current === promptAtSend && + (answeringQuestion || + (composerRef.current?.hasPendingAttachments() !== true && + sameAttachmentIds(current ? [...current.images, ...current.files] : [], attachments))) ) { promptRef.current = ""; - setComposerDraftPrompt(composerDraftTarget, ""); + const questionId = activePendingProgress?.activeQuestion?.id; + if (!answeringQuestion) clearComposerPromptAndImages(composerDraftTarget); + else if (questionId) onChangeActivePendingUserInputCustomAnswer(questionId, ""); composerRef.current?.resetCursorState(); + releaseDraftAttachments(attachments); } return; } @@ -1400,5 +1469,12 @@ export function useComposerSend(input: ComposerSendInput) { } }; - return { onSend }; + return { onSend, sideChatStarting: sideChatStartingThreadKey === routeThreadKey }; +} + +function sameAttachmentIds( + left: ReadonlyArray<{ readonly id: string }>, + right: ReadonlyArray<{ readonly id: string }>, +) { + return left.length === right.length && left.every((item, index) => item.id === right[index]?.id); } diff --git a/apps/web/src/components/chat/useSideChat.ts b/apps/web/src/components/chat/useSideChat.ts index 1083ca819c6dc..4d129deebcad6 100644 --- a/apps/web/src/components/chat/useSideChat.ts +++ b/apps/web/src/components/chat/useSideChat.ts @@ -1,6 +1,5 @@ import type { ModelSelection, RuntimeMode, ScopedThreadRef } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { deriveThreadTitleSeed } from "@t3tools/client-runtime/operations"; import { type AtomCommandResult, isAtomCommandInterrupted, @@ -11,6 +10,7 @@ import { useRef } from "react"; import { newMessageId, newThreadId } from "~/lib/utils"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; +import type { SideChatFirstMessage } from "../ChatView.logic"; import { useRightPanelStore } from "../../rightPanelStore"; import { readThreadShell, waitForThreadShell } from "../../state/entities"; import { threadEnvironment } from "../../state/threads"; @@ -19,7 +19,7 @@ import { useOrchestrationCommand } from "../../state/use-orchestration-command"; /** * Starts side chats from `threadRef`, or offers none when it is null. `open` forks the - * thread's latest stable point, sends `prompt` there with `readSettings()`, the parent + * thread's latest stable point, sends `message` there with `readSettings()`, the parent * composer's settings, and shows the tab. It resolves false when the side chat did not * start, or another one is still starting. */ @@ -48,7 +48,7 @@ export function useSideChat(input: { const error = squashAtomCommandFailure(failure); input.onError(error instanceof Error ? error.message : fallback); }; - const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => { + const start = async (threadRef: ScopedThreadRef, message: SideChatFirstMessage | null) => { const { runtimeMode, modelSelection } = input.readSettings(); const targetThreadId = newThreadId(); const fork = await forkSideChat({ @@ -88,16 +88,16 @@ export function useSideChat(input: { return false; } } - const text = prompt?.trim(); // Send before showing the tab: closing a tab whose side chat has no run deletes it. - if (text) { + if (message) { + const { titleSeed, ...content } = message; const turn = await startThreadTurn({ environmentId: threadRef.environmentId, input: { threadId: targetThreadId, - message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + message: { messageId: newMessageId(), role: "user", ...content }, ...(modelSelection === undefined ? {} : { modelSelection }), - titleSeed: deriveThreadTitleSeed({ text, attachments: [] }), + titleSeed, runtimeMode, interactionMode: "default", createdAt: new Date().toISOString(), @@ -114,11 +114,11 @@ export function useSideChat(input: { useRightPanelStore.getState().openSideChat(threadRef, targetThreadId); return true; }; - const open = async (prompt?: string) => { + const open = async (message: SideChatFirstMessage | null = null) => { const { threadRef } = input; if (!threadRef || pendingRef.current) return false; pendingRef.current = true; - return start(threadRef, prompt).finally(() => { + return start(threadRef, message).finally(() => { pendingRef.current = false; }); }; diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..ce469a58d30f5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -301,7 +301,8 @@ A side chat lets you ask a question about a thread without adding to it. It star with what the thread knows, and the thread keeps working while you ask. On web and desktop, start a message with `/side` or `/btw`, then your question. -You can also choose **Side chat** from the **+** menu in the right panel. The side +Images and files attached to that message go with it. Other added context, such as +terminal output or review comments, cannot start a side chat. You can also choose **Side chat** from the **+** menu in the right panel. The side chat opens as a tab there, with its own composer. Side chats do not show in the sidebar. Find one again under **Side chats** in thread details.