diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index dfa31029..c8f9d157 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -26,16 +26,18 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { createChatDraftIdentity, + getChatDraftIdentityKey, + clearChatDraft, readChatDraft, - writeChatDraft, type ChatDraftIdentity, type ChatDraftSnapshot, } from '@/lib/chatDraftPersistence'; import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog'; import { BtwPanel } from './btw/BtwPanel'; import { useBtwPanelState } from './btw/useBtwPanelState'; +import { resolveBtwSelection, useBtwStore } from '@/stores/useBtwStore'; import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata'; -import { buildBtwSyntheticTexts, destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw'; +import { buildBtwSyntheticTexts, preparePendingBtwSend, startBtwSession } from '@/lib/btw'; import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { ToolPopupContent } from './message/types'; @@ -47,6 +49,7 @@ import type { SkillAutocompleteHandle } from './SkillAutocomplete'; import type { SnippetAutocompleteHandle } from './SnippetAutocomplete'; import { cn } from "@/lib/utils"; import { ModelControls } from './ModelControls'; +import { focusChatInput } from './composer/editor/dom'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts'; import { ComposerStatusBar } from './ComposerStatusBar'; @@ -83,6 +86,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { useKeybind } from '@/hooks/useKeybind'; +import { hasOpenDropdown } from '@/hooks/keyboard-shortcut-dom'; import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; @@ -201,6 +205,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16; */ const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const EMPTY_QUEUE: QueuedMessage[] = []; +const EMPTY_ATTACHMENTS: AttachedFile[] = []; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { @@ -364,7 +369,8 @@ const ChatInputComponent: React.FC = ({ return snapshot.text; }); const confirmedMentionsRef = React.useRef>(initialDraftSnapshotRef.current.confirmedMentions); - const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); + const [storedInputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal'); + const inputModeParentRef = React.useRef(null); const [isDragging, setIsDragging] = React.useState(false); const [isInternalDrag, setIsInternalDrag] = React.useState(false); // At most one picker is open at a time; the prompt language decides which. @@ -419,6 +425,12 @@ const ChatInputComponent: React.FC = ({ const liveSessionId = useSessionUIStore((s) => s.currentSessionId); const chatColumnSession = useChatColumnSession(); const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId; + React.useEffect(() => { + if (inputModeParentRef.current !== null && inputModeParentRef.current !== currentSessionId) { + setInputMode('normal'); + } + inputModeParentRef.current = currentSessionId; + }, [currentSessionId]); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); const liveEffectiveDirectory = useEffectiveDirectory(); const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null) @@ -434,13 +446,13 @@ const ChatInputComponent: React.FC = ({ const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined); const btwSessionId = btwPanel.btwSessionId; const btwDirectory = btwPanel.btwDirectory; - const btwSessionRef = React.useMemo( - () => (currentSessionId && btwSessionId && btwDirectory - ? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory } - : null), - [btwDirectory, btwSessionId, currentSessionId], - ); - const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed; + const btwComposerSessionId = btwPanel.pending && currentSessionId + ? `btw-pending:${currentSessionId}` + : btwSessionId; + const isBtwActive = Boolean(btwComposerSessionId) && !btwPanel.collapsed; + const immediateBtwSubmitRef = React.useRef<{ identity: ChatDraftIdentity; text: string } | null>(null); + const draftCaretModeRef = React.useRef({ btw: isBtwActive, atEnd: isBtwActive }); + const inputMode = isBtwActive ? 'normal' : storedInputMode; // A session promoted out of `/btw` keeps the boundary instructions in its // transcript — there is no way to delete a message part — so it has to say // they no longer apply. @@ -450,9 +462,9 @@ const ChatInputComponent: React.FC = ({ () => createChatDraftIdentity( activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory, - currentSessionId, + isBtwActive ? btwComposerSessionId : currentSessionId, ), - [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + [activeRuntimeKey, btwComposerSessionId, currentDirectory, currentSessionDirectoryForSync, currentSessionId, isBtwActive], ); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const newSessionDraftOpen = Boolean(newSessionDraft?.open); @@ -466,11 +478,21 @@ const ChatInputComponent: React.FC = ({ const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory); const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); - const attachedFiles = useInputStore((s) => s.attachedFiles); + const attachedFiles = useInputStore((s) => isBtwActive ? EMPTY_ATTACHMENTS : s.attachedFiles); const addAttachedFile = useInputStore((s) => s.addAttachedFile); const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles); const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection); + const btwModelSelection = useSelectionStore(React.useCallback( + (s) => btwComposerSessionId ? s.sessionModelSelections.get(btwComposerSessionId) ?? null : null, + [btwComposerSessionId], + )); + const btwAgentSelection = useSelectionStore(React.useCallback( + (s) => btwComposerSessionId ? s.sessionAgentSelections.get(btwComposerSessionId) ?? null : null, + [btwComposerSessionId], + )); const consumePendingInputText = useInputStore((s) => s.consumePendingInputText); + const consumePendingBtwComposerRequest = useInputStore((s) => s.consumePendingBtwComposerRequest); + const pendingBtwComposerRequest = useInputStore((s) => s.pendingBtwComposerRequest); const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const pendingInputText = useInputStore((s) => s.pendingInputText); @@ -499,10 +521,40 @@ const ChatInputComponent: React.FC = ({ ? getModelMetadata(currentProviderId, currentModelId) : undefined; const currentVariant = useConfigStore((state) => state.currentVariant); + const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection); const currentAgentName = useConfigStore((state) => state.currentAgentName); const setAgent = useConfigStore((state) => state.setAgent); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); + const btwSavedVariant = useSelectionStore(React.useCallback( + (state) => btwComposerSessionId && btwAgentSelection && btwModelSelection + ? state.getAgentModelVariantForSession( + btwComposerSessionId, + btwAgentSelection, + btwModelSelection.providerId, + btwModelSelection.modelId, + ) + : undefined, + [btwAgentSelection, btwComposerSessionId, btwModelSelection], + )); + const effectiveBtwSelection = resolveBtwSelection({ + agents, + savedAgent: btwAgentSelection, + savedModel: btwModelSelection, + savedVariant: btwSavedVariant, + composerModel: currentProviderId && currentModelId ? { providerId: currentProviderId, modelId: currentModelId } : null, + composerVariant: currentVariantSelection.override === null ? null : currentVariantSelection.override ?? currentVariant, + }); + React.useEffect(() => { + const { model, agent, variant } = effectiveBtwSelection; + if (!isBtwActive || !btwComposerSessionId || !model || !agent) return; + const selections = useSelectionStore.getState(); + if (selections.getSessionModelSelection(btwComposerSessionId)) return; + selections.saveSessionAgentSelection(btwComposerSessionId, agent); + selections.saveSessionModelSelection(btwComposerSessionId, model.providerId, model.modelId); + selections.saveAgentModelForSession(btwComposerSessionId, agent, model.providerId, model.modelId); + selections.saveAgentModelVariantForSession(btwComposerSessionId, agent, model.providerId, model.modelId, variant); + }, [btwComposerSessionId, effectiveBtwSelection, isBtwActive]); const isMobile = useUIStore((state) => state.isMobile); const hasHardwareKeyboard = useHardwareKeyboard(); const enterToSend = useUIStore((state) => state.enterToSend); @@ -513,7 +565,8 @@ const ChatInputComponent: React.FC = ({ const persistChatDraft = useUIStore((state) => state.persistChatDraft); const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior); - const isExpandedInput = useUIStore((state) => state.isExpandedInput); + const persistedExpandedInput = useUIStore((state) => state.isExpandedInput); + const isExpandedInput = !isBtwActive && persistedExpandedInput; const setExpandedInput = useUIStore((state) => state.setExpandedInput); const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen); const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs(); @@ -531,6 +584,10 @@ const ChatInputComponent: React.FC = ({ const fetchGitStatus = useGitStore((state) => state.fetchStatus); const clearGitDiffCache = useGitStore((state) => state.clearDiffCache); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); + const pendingBtwAutoAccept = useBtwStore(React.useCallback( + (state) => currentSessionId ? state.byParent[currentSessionId]?.pendingAutoAccept === true : false, + [currentSessionId], + )); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ open: false, @@ -553,6 +610,7 @@ const ChatInputComponent: React.FC = ({ }); React.useEffect(() => { + if (isBtwActive) return; const modelKey = `${currentProviderId ?? ''}/${currentModelId ?? ''}`; const inputModalities = currentModelMetadata?.modalities?.input; const modalitySignature = inputModalities?.slice().sort().join(',') ?? null; @@ -592,7 +650,7 @@ const ChatInputComponent: React.FC = ({ modalities: unsupportedModalities.map((modality) => modalityLabels[modality]).join(', '), files: fileSummary, }), { id: `attachment-modalities:${modelKey}` }); - }, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, t]); + }, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, isBtwActive, t]); const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => { if (!content.image) return; @@ -864,7 +922,7 @@ const ChatInputComponent: React.FC = ({ const [linkedLinearIssue, setLinkedLinearIssue] = React.useState(null); // Message queue - const messageQueueTarget = currentSessionId + const messageQueueTarget = !isBtwActive && currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory) : null; const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null; @@ -882,7 +940,7 @@ const ChatInputComponent: React.FC = ({ const takeForSend = useMessageQueueStore((state) => state.takeForSend); // Inline comment drafts - const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); + const inlineDraftSessionKey = isBtwActive ? btwComposerSessionId ?? '' : currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory; const inlineDraftTarget = React.useMemo( () => inlineDraftSessionKey && inlineDraftDirectory @@ -907,9 +965,9 @@ const ChatInputComponent: React.FC = ({ () => createInputHistoryIdentity( activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory ?? '', - currentSessionId ?? 'draft', + inlineDraftSessionKey || 'draft', ), - [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId], + [activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, inlineDraftSessionKey], ); const inputHistoryEntries = useInputHistoryStore(React.useCallback( (state) => selectInputHistoryEntries(state, inputHistoryIdentity), @@ -917,7 +975,7 @@ const ChatInputComponent: React.FC = ({ )); // Session scope also reads the visible transcript, so sessions older than // the persisted history still recall their prompts. - const transcriptPrompts = useUserMessageHistory(currentSessionId ?? ''); + const transcriptPrompts = useUserMessageHistory((isBtwActive ? btwSessionId : currentSessionId) ?? ''); const historyValues = React.useMemo( () => (inputHistoryScope === 'session' ? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries) @@ -941,7 +999,12 @@ const ChatInputComponent: React.FC = ({ // Draft persistence: identity switching, debounced writes and the // flush-on-hide edges live in the hook. - const { persistNow: persistDraftImmediately } = useComposerDraft({ + const { + persistNow: persistDraftImmediately, + handoffDraft, + restoreDraft, + migrateDraft, + } = useComposerDraft({ message, messageRef, setMessage, @@ -952,13 +1015,60 @@ const ChatInputComponent: React.FC = ({ text: initialDraftRef.current ?? '', identity: initialDraftIdentityRef.current, }, - onIdentityChange: () => setInputMode('normal'), + onIdentityChange: () => { + setInputMode('normal'); + draftCaretModeRef.current.atEnd = isBtwActive || draftCaretModeRef.current.btw; + draftCaretModeRef.current.btw = isBtwActive; + }, onDraftRestored: (source) => { - if (source === 'fork') composerRef.current?.focus(); - composerRef.current?.selectAll(); + const editor = composerRef.current; + if (!editor) return; + if (source === 'fork') editor.focus(); + if (source !== 'fork' && draftCaretModeRef.current.atEnd) { + editor.setSelection(editor.getValue().length); + } else { + editor.selectAll(); + } }, }); + const handleExitBtw = React.useCallback(() => { + if (!currentSessionId) return; + immediateBtwSubmitRef.current = null; + const panels = useBtwStore.getState(); + const pending = panels.byParent[currentSessionId]; + if (pending?.pending && !pending.creating && !btwSessionId) { + const pendingSessionId = `btw-pending:${currentSessionId}`; + const identity = createChatDraftIdentity(activeRuntimeKey, currentSessionDirectoryForSync ?? currentDirectory, pendingSessionId); + if (identity) { + clearChatDraft(identity, true); + useInlineCommentDraftStore.getState().clearDrafts({ directory: identity.directory, sessionKey: pendingSessionId }); + } + useSelectionStore.getState().clearSessionSelections(pendingSessionId); + useInputStore.getState().consumePendingBtwComposerRequest(currentSessionId); + panels.clearPanelState(currentSessionId); + return; + } + panels.setPanelState(currentSessionId, { collapsed: true }); + }, [activeRuntimeKey, btwSessionId, currentDirectory, currentSessionDirectoryForSync, currentSessionId]); + + React.useEffect(() => { + const request = pendingBtwComposerRequest; + if (!request || request.parentSessionId !== currentSessionId) return; + if (!isBtwActive) { + useBtwStore.getState().setPanelState( + request.parentSessionId, + btwSessionId ? { collapsed: false } : { pending: true, collapsed: false }, + ); + return; + } + if (!chatDraftIdentity) return; + const consumed = consumePendingBtwComposerRequest(currentSessionId); + if (!consumed) return; + restoreDraft(chatDraftIdentity, consumed.text, new Set()); + queueMicrotask(() => focusChatInput()); + }, [btwSessionId, chatDraftIdentity, consumePendingBtwComposerRequest, currentSessionId, isBtwActive, pendingBtwComposerRequest, restoreDraft]); + // Focus textarea when new session draft is opened const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen); React.useEffect(() => { @@ -1003,7 +1113,7 @@ const ChatInputComponent: React.FC = ({ // Consume pending input text (e.g., from revert action) React.useEffect(() => { - if (pendingInputText !== null) { + if (!isBtwActive && pendingInputText !== null) { const pending = consumePendingInputText(); if (pending?.text) { if (pending.mode === 'append') { @@ -1023,11 +1133,12 @@ const ChatInputComponent: React.FC = ({ }, 0); } } - }, [pendingInputText, consumePendingInputText]); + }, [isBtwActive, pendingInputText, consumePendingInputText]); const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts; - const hasQueuedMessages = queuedMessages.length > 0; - const canSend = hasContent || hasQueuedMessages; + const hasQueuedMessages = !isBtwActive && queuedMessages.length > 0; + const preparingBtwSend = useBtwStore((state) => Boolean(currentSessionId && state.byParent[currentSessionId]?.pendingSend)); + const canSend = (hasContent || hasQueuedMessages) && !(isBtwActive && (btwPanel.creating || preparingBtwSend)); const canAbort = sessionPhase !== 'idle'; @@ -1170,7 +1281,7 @@ const ChatInputComponent: React.FC = ({ return; } recordLinkedReferences(queueSessionId, queueTarget.directory, linked); - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inputMode, hasDrafts, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inputMode, hasDrafts, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]); /** Put the context a queued message was captured with back on the composer chips. */ const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => { @@ -1237,8 +1348,9 @@ const ChatInputComponent: React.FC = ({ }, []); const handleToggleExpandedInput = React.useCallback(() => { + if (isBtwActive) return; setExpandedInput(!isExpandedInput); - }, [isExpandedInput, setExpandedInput]); + }, [isBtwActive, isExpandedInput, setExpandedInput]); const openIssuePicker = React.useCallback(() => { setIssuePickerOpen(true); @@ -1260,19 +1372,12 @@ const ChatInputComponent: React.FC = ({ }; const handleSubmit = async (options?: SubmitOptions) => { + if (isBtwActive && currentSessionId && (btwPanel.creating || useBtwStore.getState().byParent[currentSessionId]?.pendingSend)) return; const submitRuntimeKey = getRuntimeKey(); const queuedOnly = options?.queuedOnly ?? false; const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const capturedTarget = messageQueueTarget; - // An expired session cannot deliver anything: keep the prompt in the - // composer and point at the login banner instead of burning the send - // on a guaranteed 401. - if (useAuthSessionStore.getState().state !== 'ok') { - toast.error(t('sessionAuth.expired.sendBlocked')); - return; - } - // Snapshot the draft and current-session identity before the first // async gap so a later sidebar selection cannot reroute the send. const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null; @@ -1312,6 +1417,32 @@ const ChatInputComponent: React.FC = ({ } if (commandPlan?.command.name === 'handoff-review' && (isMobile || isVSCodeRuntime())) commandPlan = null; + // Enter BTW before sending so the question uses its isolated selections. + // A bare command waits for input; an argument requests one immediate send. + if (commandPlan?.kind === 'prompt' && commandPlan.command.name === 'btw' && currentSessionId) { + const targetComposerId = btwSessionId ?? `btw-pending:${currentSessionId}`; + const targetIdentity = createChatDraftIdentity( + activeRuntimeKey, + btwDirectory ?? currentSessionDirectoryForSync ?? currentDirectory, + targetComposerId, + ); + const argument = commandPlan.command.argument.trim(); + handoffDraft(targetIdentity, isBtwActive ? argument : argument || null); + if (argument && targetIdentity) immediateBtwSubmitRef.current = { identity: targetIdentity, text: argument }; + if (btwSessionId) { + useBtwStore.getState().setPanelState(currentSessionId, { collapsed: false }); + return; + } + useBtwStore.getState().setPanelState(currentSessionId, { pending: true, creating: false, collapsed: false }); + return; + } + + // Opening BTW is local and still works while authentication is expired. + if (useAuthSessionStore.getState().state !== 'ok') { + toast.error(t('sessionAuth.expired.sendBlocked')); + return; + } + // A failed send returns the typed prompt no matter WHY it failed — // auth, network, server, anything. Losing a long prompt to a toast is // the one outcome this handler must never produce. The mentions are @@ -1319,22 +1450,7 @@ const ChatInputComponent: React.FC = ({ const confirmedMentionsSnapshot = new Set(confirmedMentionsRef.current); const restoreComposerText = () => { if (queuedOnly || !inputSnapshot.message) return; - for (const mention of confirmedMentionsSnapshot) confirmedMentionsRef.current.add(mention); - if (currentChatDraftIdentityRef.current !== chatDraftIdentity) { - // The user switched sessions mid-send: restore into that - // session's persisted draft, not the visible composer. - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); - return; - } - const currentInput = composerRef.current?.getValue() ?? messageRef.current; - if (!currentInput || currentInput === inputSnapshot.message) { - setMessage(inputSnapshot.message); - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); - } else { - // New typing already lives in the composer; the failed prompt - // joins it instead of clobbering either text. - useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append'); - } + restoreDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsSnapshot); }; // The projection knows the captured send configuration; the full @@ -1344,10 +1460,10 @@ const ChatInputComponent: React.FC = ({ ? queuedMessages.filter((message) => message.id === queuedMessageId) : queuedMessages; const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined; - const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId; - const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId; - const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName; - const variantToSend = capturedSendConfig?.variant ?? currentVariant; + const providerIdToSend = capturedSendConfig?.providerID ?? (isBtwActive ? effectiveBtwSelection.model?.providerId : currentProviderId); + const modelIdToSend = capturedSendConfig?.modelID ?? (isBtwActive ? effectiveBtwSelection.model?.modelId : currentModelId); + const agentNameToSend = capturedSendConfig?.agent ?? (isBtwActive ? effectiveBtwSelection.agent : currentAgentName); + const variantToSend = capturedSendConfig?.variant ?? (isBtwActive ? effectiveBtwSelection.variant : currentVariant); if (!providerIdToSend || !modelIdToSend) { console.warn('Cannot send message: provider or model not selected'); @@ -1401,7 +1517,7 @@ const ChatInputComponent: React.FC = ({ confirmedMentionsRef.current.clear(); persistDraftImmediately(chatDraftIdentity, ''); messageHistory.reset(); - setExpandedInput(false); + if (!isBtwActive) setExpandedInput(false); if (isMobile) composerRef.current?.blur(); try { if (actionName === 'undo') { @@ -1454,7 +1570,7 @@ const ChatInputComponent: React.FC = ({ ...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []), ]); const documentMentions = await prepareDocumentMentions( - !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [], + !isBtwActive && !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [], reservedFilenames, submitRuntimeKey, ); @@ -1497,7 +1613,7 @@ const ChatInputComponent: React.FC = ({ // Inline review comments and synthetic context are consumed before // assembly so a failed send can restore exactly what it took. What is // here belongs to this send: queueing took its own context with it. - const syntheticParts = consumePendingSyntheticParts(); + const syntheticParts = isBtwActive ? [] : consumePendingSyntheticParts(); const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget ? consumeDrafts(consumedDraftTarget) @@ -1537,21 +1653,23 @@ const ChatInputComponent: React.FC = ({ ...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }), ...(syntheticParts?.map((part) => part.text) ?? []), ], - linkedIssue: linkedIssue + linkedIssue: !isBtwActive && linkedIssue ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } : null, - linkedPr: linkedPr + linkedPr: !isBtwActive && linkedPr ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, - linkedLinearIssue: linkedLinearIssue + linkedLinearIssue: !isBtwActive && linkedLinearIssue ? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText } : null, }, { parseAgentMention: (text) => { + if (isBtwActive) return { text }; const { sanitizedText, mention } = parseAgentMentions(text, agents); return { text: sanitizedText, agentName: mention?.name }; }, extractFileMentions: (text) => { + if (isBtwActive) return { text, attachments: [] }; const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions); return { text: sanitizedText, attachments }; }, @@ -1568,6 +1686,7 @@ const ChatInputComponent: React.FC = ({ // Clear input (the queue was taken above) if (!queuedOnly) { setMessage(''); + messageRef.current = ''; confirmedMentionsRef.current.clear(); // Clear per-session draft on submit persistDraftImmediately(chatDraftIdentity, ''); @@ -1576,58 +1695,19 @@ const ChatInputComponent: React.FC = ({ clearAttachedFiles(); } // Close expanded input overlay when submitting - setExpandedInput(false); + if (!isBtwActive) setExpandedInput(false); } if (isMobile) { composerRef.current?.blur(); } - // Prompt commands render a visible prompt (or fork a btw question) and - // send it with everything the composer had attached. + // Prompt commands render a visible prompt and send it with everything + // the composer had attached. `/btw` was handled above as a composer + // transition and never reaches this sending path. if (commandPlan?.kind === 'prompt') { const { name: commandName, argument } = commandPlan.command; - if (commandName === 'btw' && currentSessionId) { - const question = argument.trim(); - if (!question) { - restoreConsumedInput(); - toast.error(t('chat.btw.toast.emptyArgument')); - return; - } - const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) - || currentDirectory - || null; - if (!targetDirectory) { - restoreConsumedInput(); - toast.error(t('chat.btw.toast.createFailed')); - return; - } - try { - // A new btw replaces this session's current one: destroy - // the previous fork first so forks never accumulate. - if (btwSessionRef) { - await destroyBtwSession(btwSessionRef); - } - await startBtwSession({ - parentSessionId: currentSessionId, - question, - directory: targetDirectory, - providerID: providerIdToSend, - modelID: modelIdToSend, - agent: agentNameToSend, - variant: variantToSend, - attachments: primaryAttachments, - additionalParts, - }); - scrollToBottom?.(); - } catch (error) { - restoreConsumedInput(); - toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed'))); - } - return; - } - // The rest render a visible prompt plus synthetic instructions and // send them as one message, the attached context riding along. const command = findMagicPromptCommand(commandName); @@ -1672,15 +1752,29 @@ const ChatInputComponent: React.FC = ({ } } - try { - const expandText = useSnippetsStore.getState().expandText; - primaryText = await expandText(primaryText); - for (const part of additionalParts) { - if (!part.synthetic) part.text = await expandText(part.text); + const expandOutgoingSnippets = async () => { + try { + const expandText = useSnippetsStore.getState().expandText; + primaryText = await expandText(primaryText); + for (const part of additionalParts) { + if (!part.synthetic) part.text = await expandText(part.text); + } + } catch (error) { + console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); } - } catch (error) { - console.warn('[ChatInput] Failed to expand snippets, sending original text:', error); + }; + let pendingBtwSend: symbol | null = null; + if (isBtwActive && btwPanel.pending && currentSessionId) { + pendingBtwSend = await preparePendingBtwSend(currentSessionId, submitRuntimeKey, expandOutgoingSnippets); + if (!pendingBtwSend) { + if (getRuntimeKey() !== submitRuntimeKey) restoreComposerText(); + return; + } + } else { + await expandOutgoingSnippets(); } + const ownsPendingBtwSend = () => Boolean(pendingBtwSend && currentSessionId + && useBtwStore.getState().byParent[currentSessionId]?.pendingSend === pendingBtwSend); // Collect all attachments for error recovery const allAttachments = [ @@ -1693,6 +1787,59 @@ const ChatInputComponent: React.FC = ({ // never claims the new message. scrollToBottom?.(); + if (isBtwActive && btwPanel.pending && currentSessionId && btwComposerSessionId) { + const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) + || currentDirectory + || null; + if (!targetDirectory) { + useBtwStore.getState().setPanelState(currentSessionId, { pendingSend: undefined }); + restoreConsumedInput(); + toast.error(t('chat.btw.toast.createFailed')); + return; + } + try { + const fork = await startBtwSession({ + parentSessionId: currentSessionId, + expectedRuntimeKey: submitRuntimeKey, + question: primaryText, + directory: targetDirectory, + providerID: providerIdToSend, + modelID: modelIdToSend, + agent: agentNameToSend, + variant: variantToSend, + attachments: primaryAttachments, + additionalParts, + permissionAutoAccept: pendingBtwAutoAccept, + }); + if (!ownsPendingBtwSend()) return; + if (getRuntimeKey() !== submitRuntimeKey) { + useBtwStore.getState().clearPanelState(currentSessionId); + return; + } + const forkDirectory = fork.directory ?? targetDirectory; + migrateDraft(chatDraftIdentity, createChatDraftIdentity(activeRuntimeKey, forkDirectory, fork.id)); + if (inlineDraftTarget) { + const drafts = useInlineCommentDraftStore.getState(); + drafts.restoreDrafts({ directory: forkDirectory, sessionKey: fork.id }, drafts.consumeDrafts(inlineDraftTarget)); + } + useBtwStore.getState().setPanelState(currentSessionId, { pending: false, creating: false, pendingSend: undefined }); + scrollToBottom?.(); + } catch (error) { + if (!ownsPendingBtwSend()) return; + if (getRuntimeKey() !== submitRuntimeKey) { + useBtwStore.getState().clearPanelState(currentSessionId); + restoreComposerText(); + return; + } + // Preserve the pending owner before restoring text so a failed + // first send never drops back into the parent draft. + useBtwStore.getState().setPanelState(currentSessionId, { pending: true, creating: false, collapsed: false, pendingSend: undefined }); + restoreConsumedInput(); + toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed'))); + } + return; + } + const sendPromise = sendMessage( primaryText, providerIdToSend, @@ -1706,6 +1853,7 @@ const ChatInputComponent: React.FC = ({ sendMessageOptions, ); void sendPromise.then(() => { + if (isBtwActive) return; // On a draft there is no session yet in this closure: the send path // creates one and makes it current before resolving, so the id is // read from the store. The fallback is used only when the closure @@ -1835,6 +1983,18 @@ const ChatInputComponent: React.FC = ({ void handleSubmitRef.current({ presetText: next }); }, []); + // A command with an argument sends once the isolated composer owns its draft. + React.useEffect(() => { + const pending = immediateBtwSubmitRef.current; + if (!pending || !isBtwActive || !chatDraftIdentity) return; + if (getChatDraftIdentityKey(pending.identity) !== getChatDraftIdentityKey(chatDraftIdentity)) { + immediateBtwSubmitRef.current = null; + return; + } + immediateBtwSubmitRef.current = null; + void handleSubmit({ presetText: pending.text }); + }); + // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. @@ -1852,7 +2012,7 @@ const ChatInputComponent: React.FC = ({ // Enter shell mode before CodeMirror inserts the trigger. Keeping the // document unchanged also keeps the caret at the start for the first // command character. - if (inputMode === 'normal' && e.key === '!') { + if (!isBtwActive && inputMode === 'normal' && e.key === '!') { const selection = composerRef.current?.getSelection(); if (selection?.start === 0 && selection.end === 0) { e.preventDefault(); @@ -1887,6 +2047,13 @@ const ChatInputComponent: React.FC = ({ return; } + if (isBtwActive && currentSessionId && e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleExitBtw(); + return; + } + if (isDesktopExpanded && e.key === 'Escape') { e.preventDefault(); setExpandedInput(false); @@ -1902,7 +2069,7 @@ const ChatInputComponent: React.FC = ({ ? 1 : 0; - if (cycleAgentDirection !== 0 && openAutocomplete === null) { + if (!isBtwActive && cycleAgentDirection !== 0 && openAutocomplete === null) { e.preventDefault(); e.stopPropagation(); handleCycleAgent(cycleAgentDirection); @@ -1945,7 +2112,7 @@ const ChatInputComponent: React.FC = ({ const recalled = messageHistory.older({ text: message, attachments: attachedFiles }); if (recalled !== null) { setMessage(recalled.text); - useInputStore.getState().setAttachedFiles([...recalled.attachments]); + if (!isBtwActive) useInputStore.getState().setAttachedFiles([...recalled.attachments]); // Caret to the start, so the recalled message reads from its // beginning rather than from wherever the draft's caret was. requestAnimationFrame(() => composerRef.current?.setSelection(0, 0)); @@ -1958,7 +2125,7 @@ const ChatInputComponent: React.FC = ({ const recalled = messageHistory.newer({ text: message, attachments: attachedFiles }); if (recalled !== null) { setMessage(recalled.text); - useInputStore.getState().setAttachedFiles([...recalled.attachments]); + if (!isBtwActive) useInputStore.getState().setAttachedFiles([...recalled.attachments]); requestAnimationFrame(() => composerRef.current?.setSelection(recalled.text.length, recalled.text.length)); } return; @@ -2050,12 +2217,13 @@ const ChatInputComponent: React.FC = ({ ) => { const trigger = resolveAutocompleteTrigger(value, cursorPosition, { inputMode, + mentionsEnabled: !isBtwActive, inputSource, insertedText, }); setOpenAutocomplete(trigger?.kind ?? null); setAutocompleteQuery(trigger?.query ?? ''); - }, [inputMode]); + }, [inputMode, isBtwActive]); const insertTextAtSelection = React.useCallback(( text: string, @@ -2153,7 +2321,7 @@ const ChatInputComponent: React.FC = ({ // Mobile keyboards and paste may update the document without a usable // keydown, so consume the trigger in the same editor transaction rather // than moving the caret in a later frame against stale text. - if (inputMode === 'normal' && value.startsWith('!')) { + if (!isBtwActive && inputMode === 'normal' && value.startsWith('!')) { const shellCommand = value.slice(1); const nextCursor = Math.max(0, selection.start - 1); setInputMode('shell'); @@ -2180,6 +2348,10 @@ const ChatInputComponent: React.FC = ({ }, [clearDropTextSuppression, clearFileMentionPasteSuppression]); const handlePaste = React.useCallback(async (event: ClipboardEvent) => { + if (isBtwActive && event.clipboardData?.files.length) { + event.preventDefault(); + return; + } const clipboardData = event.clipboardData; if (!clipboardData) return; // Narrowed alias so the rest of the handler reads as it did when this @@ -2233,6 +2405,7 @@ const ChatInputComponent: React.FC = ({ const behavior: LargeTextPasteBehavior = largeTextPasteBehavior; const shouldOfferLargePaste = sessionReady && inputMode === 'normal' + && !isBtwActive && behavior !== 'inline' && isLargePlainTextPaste(pastedText); @@ -2392,7 +2565,7 @@ const ChatInputComponent: React.FC = ({ pendingPastedAttachmentFilenamesRef.current.delete(filename); } } - }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); + }, [addAttachedFile, attachedFiles, currentSessionId, inputMode, isBtwActive, largeTextPasteBehavior, markFileMentionPasteSuppression, message, newSessionDraftOpen, insertTextAtSelection, setMessage, t, updateAutocompleteState]); const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => { @@ -2527,7 +2700,11 @@ const ChatInputComponent: React.FC = ({ }; const handleCommandSelect = (command: CommandInfo) => { - + if (command.name === 'btw' && currentSessionId) { + closeAutocomplete(); + void handleSubmitRef.current({ presetText: '/btw' }); + return; + } setMessage(`/${command.name} `); closeAutocomplete(); @@ -2654,6 +2831,10 @@ const ChatInputComponent: React.FC = ({ }; const handleDrop = async (e: React.DragEvent) => { + if (isBtwActive) { + e.preventDefault(); + return; + } dragEnterCountRef.current = 0; const draggedFiles = hasDraggedFiles(e.dataTransfer); if (!draggedFiles) { @@ -2739,6 +2920,7 @@ const ChatInputComponent: React.FC = ({ const fileInputRef = React.useRef(null); const attachFiles = React.useCallback(async (files: FileList | File[]) => { + if (isBtwActive) return; const list = Array.isArray(files) ? files : Array.from(files); let attached = false; @@ -2752,9 +2934,10 @@ const ChatInputComponent: React.FC = ({ if (list.length > 0 && !attached) { toast.error(t('chat.chatInput.toast.attachFileFailed')); } - }, [addAttachedFile, t]); + }, [addAttachedFile, isBtwActive, t]); const handleVSCodePickFiles = React.useCallback(async () => { + if (isBtwActive) return; try { const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as { files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>; @@ -2798,22 +2981,27 @@ const ChatInputComponent: React.FC = ({ console.error('VS Code file pick failed', error); toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed')); } - }, [attachFiles, t, vscodeApi]); + }, [attachFiles, isBtwActive, t, vscodeApi]); const handlePickLocalFiles = React.useCallback(() => { + if (isBtwActive) return; if (isVSCodeRuntime()) { void handleVSCodePickFiles(); return; } fileInputRef.current?.click(); - }, [handleVSCodePickFiles]); + }, [handleVSCodePickFiles, isBtwActive]); const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent) => { + if (isBtwActive) { + event.target.value = ''; + return; + } const files = event.target.files; if (!files) return; await attachFiles(files); event.target.value = ''; - }, [attachFiles]); + }, [attachFiles, isBtwActive]); const footerGapClass = 'gap-x-1.5 gap-y-0'; const isVSCode = isVSCodeRuntime(); @@ -2961,8 +3149,9 @@ const ChatInputComponent: React.FC = ({ const iconButtonBaseClass = 'flex cursor-pointer items-center justify-center text-foreground transition-none outline-none focus:outline-none flex-shrink-0 disabled:cursor-not-allowed'; const footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass); - const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId; + const permissionScopeSessionId = isBtwActive ? btwSessionId : currentSessionId ?? currentManagementSessionId; const permissionAutoAcceptEnabled = usePermissionStore((state) => { + if (isBtwActive && !btwSessionId) return pendingBtwAutoAccept; if (!permissionScopeSessionId) { return draftPermissionAutoAcceptEnabled; } @@ -2971,6 +3160,10 @@ const ChatInputComponent: React.FC = ({ const isPermissionAutoAcceptInteractive = Boolean(permissionScopeSessionId || newSessionDraftOpen); const handlePermissionAutoAcceptToggle = React.useCallback(() => { + if (isBtwActive && !btwSessionId && currentSessionId) { + useBtwStore.getState().setPanelState(currentSessionId, { pendingAutoAccept: !pendingBtwAutoAccept }); + return; + } togglePermissionAutoAccept({ permissionScopeSessionId, newSessionDraftOpen, @@ -2986,6 +3179,10 @@ const ChatInputComponent: React.FC = ({ newSessionDraftOpen, permissionAutoAcceptEnabled, permissionScopeSessionId, + isBtwActive, + btwSessionId, + currentSessionId, + pendingBtwAutoAccept, setDraftPermissionAutoAcceptEnabled, setSessionAutoAccept, t, @@ -3010,6 +3207,15 @@ const ChatInputComponent: React.FC = ({ <>
{ + if (!isBtwActive || event.key !== 'Escape' || isIMECompositionEvent(event) || hasOpenDropdown()) return; + if (!(event.target instanceof Element) || !event.target.closest('[data-chat-input-footer]')) return; + // Footer tooltips must not consume the only exit key for a pending BTW. + event.preventDefault(); + event.stopPropagation(); + handleExitBtw(); + }} onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }} className={cn( "relative w-full pt-0 pb-4", @@ -3032,11 +3238,11 @@ const ChatInputComponent: React.FC = ({ ) : null}
- - : null} + {!isBtwActive ? + /> : null} {hasDrafts ? ( = ({ isMobileExpanded && 'flex min-h-0 flex-1 flex-col', )} > - {isMobile && !mobileComposerExpanded ? ( + {isMobile && !mobileComposerExpanded && !isBtwActive ? ( = ({ /> ) : ( <> - - : null} + {!isBtwActive ?