feat(chat): turn /btw into an isolated composer (#3398)

* feat(chat): turn /btw into an isolated composer

* fix(chat): preserve direct BTW sends and isolate pending preparation

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
ChangeHow
2026-09-07 23:13:07 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7b206b1014
commit 02581d08c5
38 changed files with 1227 additions and 311 deletions
+357 -143
View File
@@ -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<ChatInputProps> = ({
return snapshot.text;
});
const confirmedMentionsRef = React.useRef<Set<string>>(initialDraftSnapshotRef.current.confirmedMentions);
const [inputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
const [storedInputMode, setInputMode] = React.useState<'normal' | 'shell'>('normal');
const inputModeParentRef = React.useRef<string | null>(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<ChatInputProps> = ({
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<ChatInputProps> = ({
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
const btwSessionId = btwPanel.btwSessionId;
const btwDirectory = btwPanel.btwDirectory;
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
() => (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<ChatInputProps> = ({
() => 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<ChatInputProps> = ({
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<ChatInputProps> = ({
? 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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<ToolPopupContent>({
open: false,
@@ -553,6 +610,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
});
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<ChatInputProps> = ({
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<ChatInputProps> = ({
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<LinkedLinearIssueRef | null>(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<ChatInputProps> = ({
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<InlineCommentDraftTarget | null>(
() => inlineDraftSessionKey && inlineDraftDirectory
@@ -907,9 +965,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
() => 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<ChatInputProps> = ({
));
// 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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
}, 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<ChatInputProps> = ({
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<ChatInputProps> = ({
}, []);
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<ChatInputProps> = ({
};
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<ChatInputProps> = ({
}
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<ChatInputProps> = ({
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<ChatInputProps> = ({
? 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<ChatInputProps> = ({
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<ChatInputProps> = ({
...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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
...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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
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<ChatInputProps> = ({
}
}
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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
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<ChatInputProps> = ({
? 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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<ChatInputProps> = ({
) => {
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<ChatInputProps> = ({
// 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<ChatInputProps> = ({
}, [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<ChatInputProps> = ({
const behavior: LargeTextPasteBehavior = largeTextPasteBehavior;
const shouldOfferLargePaste = sessionReady
&& inputMode === 'normal'
&& !isBtwActive
&& behavior !== 'inline'
&& isLargePlainTextPaste(pastedText);
@@ -2392,7 +2565,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
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<ChatInputProps> = ({
};
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<ChatInputProps> = ({
};
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<ChatInputProps> = ({
const fileInputRef = React.useRef<HTMLInputElement>(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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<HTMLInputElement>) => {
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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<ChatInputProps> = ({
newSessionDraftOpen,
permissionAutoAcceptEnabled,
permissionScopeSessionId,
isBtwActive,
btwSessionId,
currentSessionId,
pendingBtwAutoAccept,
setDraftPermissionAutoAcceptEnabled,
setSessionAutoAccept,
t,
@@ -3010,6 +3207,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<>
<form
ref={composerFormRef}
data-btw-composer={isBtwActive ? 'true' : undefined}
onKeyDownCapture={(event) => {
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<ChatInputProps> = ({
</div>
) : null}
<div className={cn('chat-input-column relative overflow-visible', isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
<QueuedMessageChips
{!isBtwActive ? <AttachedFilesList onShowPopup={handleShowAttachmentPreview} /> : null}
{!isBtwActive ? <QueuedMessageChips
onEditMessage={handleQueuedMessageEdit}
onSendMessage={handleQueuedMessageSend}
/>
/> : null}
<AutoReviewBanner />
{hasDrafts ? (
<ComposerContextChips
@@ -3132,7 +3338,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
isMobileExpanded && 'flex min-h-0 flex-1 flex-col',
)}
>
{isMobile && !mobileComposerExpanded ? (
{isMobile && !mobileComposerExpanded && !isBtwActive ? (
<MobilePillComposer
message={message}
sessionId={currentSessionId}
@@ -3162,18 +3368,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
/>
) : (
<>
<SessionGoalRow
{!isBtwActive ? <SessionGoalRow
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
className="mb-1.5"
/>
<SessionSuggestionChip
/> : null}
{!isBtwActive ? <SessionSuggestionChip
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
hidden={hasContent || newSessionDraftOpen}
onApply={applyAssistSuggestion}
className="mb-1.5"
/>
/> : null}
<div
className={cn(
"flex flex-col relative overflow-visible",
@@ -3240,17 +3446,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<div className={cn("overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
{isMobile ? (
<div className="scrollbar-none relative z-10 flex items-center gap-x-2 overflow-x-auto px-3 pb-0.5 pt-1.5">
<MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="flex-shrink-0" />
<MemoMobileAgentButton
{isBtwActive ? <ModelControls
className="flex-1 min-w-0"
sessionId={btwComposerSessionId}
selection={effectiveBtwSelection}
/> : null}
{!isBtwActive ? <MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="flex-shrink-0" /> : null}
{!isBtwActive ? <MemoMobileAgentButton
onOpenAgentPanel={handleOpenAgentPanel}
onCycleAgent={handleCycleAgent}
className="flex-shrink-0"
/>
/> : null}
</div>
) : null}
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
<ActiveEditorFileSuggestion />
{!isBtwActive ? <AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} /> : null}
{!isBtwActive ? <ActiveEditorFileSuggestion /> : null}
</div>
<div
className={cn("relative overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}
@@ -3349,6 +3560,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
onDictationInsert={handleDictationInsert}
onDictationInsertAndSend={handleDictationInsertAndSend}
onDictationContentHeightChange={handleDictationContentHeightChange}
isBtw={isBtwActive}
modelSessionId={btwComposerSessionId}
btwSelection={effectiveBtwSelection}
/>
</div>
@@ -3359,7 +3573,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
the pill ↔ composer swap so a recording started from the pill
survives the morph. Its absolute overlay covers whichever
shape the wrapper currently has. */}
{isMobile ? (
{isMobile && !isBtwActive ? (
<MemoComposerDictation
radius={chatInputRadius}
isMobile={isMobile}
@@ -3378,7 +3592,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
{/* Hidden host for the model/agent/variant bottom sheets. Kept
outside the pill conditional so an open panel survives (and
stays visible over) the collapsed composer. */}
{isMobile ? (
{isMobile && !isBtwActive ? (
<MemoModelControls
className="hidden"
mobilePanel={mobileControlsPanel}
@@ -3392,7 +3606,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
className={cn('chat-input-column mt-4', draftPresentationClassName)}
/>
) : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} onExit={handleExitBtw} /> : null}
</form>
{/* Issue Picker Dialog */}
@@ -8,14 +8,15 @@ import { useI18n } from '@/lib/i18n';
interface MobileModelButtonProps {
onOpenModel: () => void;
className?: string;
model?: { providerId: string; modelId: string } | null;
}
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className }) => {
export const MobileModelButton: React.FC<MobileModelButtonProps> = ({ onOpenModel, className, model }) => {
const { t } = useI18n();
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
const currentProvider = getCurrentProvider();
const currentModelId = useConfigStore((state) => model === undefined ? state.currentModelId : model?.modelId);
const currentProviderId = useConfigStore((state) => model === undefined ? state.currentProviderId : model?.providerId);
const providers = useConfigStore((state) => state.providers);
const currentProvider = providers.find((provider) => provider.id === currentProviderId);
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
return (
@@ -1,5 +1,6 @@
import React from 'react';
import { focusChatInput } from './composer/editor/dom';
import { MobileModelButton } from './MobileModelButton';
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
import type { ModelMetadata } from '@/types';
import {
@@ -17,7 +18,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
import { ModelPickerList, type ModelPickerEntry } from '@/components/model-picker/ModelPickerList';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { isDesktopShell } from '@/lib/desktop';
import { getAgentColor } from '@/lib/agentColors';
@@ -46,6 +47,7 @@ import {
shouldPreserveManualModelOverride,
} from '@/lib/messages/userModelChoice';
import { getSyncParts } from '@/sync/sync-refs';
import type { BtwSelection } from '@/stores/useBtwStore';
type IconComponent = IconName;
@@ -307,33 +309,36 @@ const formatDate = (value?: string) => {
return formatReleaseDate(parsedDate);
};
interface ModelControlsProps {
type ModelControlsProps = {
className?: string;
mobilePanel?: MobileControlsPanel;
onMobilePanelChange?: (panel: MobileControlsPanel) => void;
}
} & ({ selection?: never; sessionId?: never } | { selection: BtwSelection; sessionId: string | null });
export const ModelControls: React.FC<ModelControlsProps> = ({
className,
mobilePanel,
onMobilePanelChange,
selection,
sessionId: controlledSessionId,
}) => {
const { t } = useI18n();
const { isReady, isUnavailable } = useOpenCodeReadiness();
const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading');
const providers = useConfigStore((state) => state.providers);
const currentProviderId = useConfigStore((state) => state.currentProviderId);
const currentModelId = useConfigStore((state) => state.currentModelId);
const currentProviderId = useConfigStore((state) => selection ? selection.model?.providerId ?? '' : state.currentProviderId);
const currentModelId = useConfigStore((state) => selection ? selection.model?.modelId ?? '' : state.currentModelId);
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
// What the picker shows is what the next send carries: an explicit choice
// when there is one, "Default" when "Default" was picked, and otherwise the
// inherited effort — showing "Default" while an inherited effort is in
// force is how a switch away from it looks like it did not stick.
const currentVariant = currentVariantSelection.override === null
let currentVariant = currentVariantSelection.override === null
? undefined
: currentVariantSelection.override ?? effectiveCurrentVariant;
const currentAgentName = useConfigStore((state) => state.currentAgentName);
if (selection) currentVariant = selection.variant ?? undefined;
const currentAgentName = useConfigStore((state) => selection ? selection.agent : state.currentAgentName);
const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
const setProvider = useConfigStore((state) => state.setProvider);
@@ -354,7 +359,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const tracedReadyRef = React.useRef(false);
React.useEffect(() => {
if (tracedReadyRef.current || !isReady) return;
if (selection || tracedReadyRef.current || !isReady) return;
tracedReadyRef.current = true;
markStartupTrace('ModelControls:ready', {
providers: providers.length,
@@ -363,9 +368,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
currentModelId,
currentAgentName,
});
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]);
}, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length, selection]);
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
// Controlled selections never restore from the main session or its history.
const currentSessionId = useSessionUIStore((s) => selection ? null : s.currentSessionId);
const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
const sync = useSync();
@@ -409,7 +415,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const addRecentModel = useUIStore((state) => state.addRecentModel);
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen);
const globalModelSelectorOpen = useUIStore((state) => !selection && state.isModelSelectorOpen);
const [localModelSelectorOpen, setLocalModelSelectorOpen] = React.useState(false);
const isModelSelectorOpen = selection ? localModelSelectorOpen : globalModelSelectorOpen;
const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
@@ -457,7 +465,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
});
// Use global state for model selector (allows Ctrl+M shortcut)
const agentMenuOpen = isModelSelectorOpen;
const setAgentMenuOpen = setModelSelectorOpen;
const setAgentMenuOpen = selection ? setLocalModelSelectorOpen : setModelSelectorOpen;
const openAddProviderSettings = React.useCallback(() => {
setSelectedProvider(ADD_PROVIDER_ID);
setSettingsPage('providers');
@@ -524,13 +532,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Handle agent selector close behavior
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
React.useEffect(() => {
if (!isAgentSelectorOpen) {
if (!selection && !isAgentSelectorOpen) {
setAgentSearchQuery('');
if (!isCompact) {
requestAnimationFrame(focusChatInput);
}
}
}, [isAgentSelectorOpen, isCompact]);
}, [isAgentSelectorOpen, isCompact, selection]);
const selectableDesktopAgents = React.useMemo(() => {
return agents.filter((agent) => isPrimaryMode(agent.mode));
@@ -564,7 +572,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3';
const currentProvider = getCurrentProvider();
const currentProvider = selection ? providers.find((provider) => provider.id === currentProviderId) : getCurrentProvider();
const models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
const visibleProviders = React.useMemo(() => {
@@ -623,7 +631,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Compute from current model each render to avoid stale variants
// in draft/session transitions.
const availableVariants = getCurrentModelVariants();
const availableVariants = selection
? Object.keys(currentProvider?.models.find((model) => model.id === currentModelId)?.variants ?? {})
: getCurrentModelVariants();
const hasVariants = availableVariants.length > 0;
const costRows = [
@@ -650,14 +660,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
// Skip synthetic subagent-completion nudges — restoring from them resets a
// manual model override back to the agent default (issue #2404).
const latestLoadedUserChoice = React.useMemo(() => {
if (selection) return null;
return findLatestUserModelChoice(
currentSessionMessagesFromSync,
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
);
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
}, [currentSessionDirectory, currentSessionMessagesFromSync, selection]);
const tryApplyModelSelection = React.useCallback(
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
if (selection) {
if (controlledSessionId) {
saveSessionModelSelection(controlledSessionId, providerId, modelId);
if (selection.agent) saveAgentModelForSession(controlledSessionId, selection.agent, providerId, modelId);
}
return 'applied';
}
if (!providerId || !modelId) {
return 'model-missing';
}
@@ -691,7 +709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return 'applied';
},
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection],
[providers, currentProviderId, currentModelId, setProvider, setModel, currentSessionId, saveAgentModelForSession, saveSessionModelSelection, controlledSessionId, selection],
);
const getModelVariantOptions = React.useCallback((providerId: string, modelId: string) => {
@@ -739,8 +757,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
const effectiveAgentName = uiAgentName || currentAgentName;
if (currentSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
const selectionSessionId = selection ? controlledSessionId : currentSessionId;
if (selectionSessionId && effectiveAgentName) {
const savedVariant = getAgentModelVariantForSession(selectionSessionId, effectiveAgentName, providerId, modelId);
// An explicit "Default" is a choice: it stops the fallbacks below.
if (savedVariant === null) {
return null;
@@ -764,9 +783,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelVariantForSession,
getModelVariantOptions,
uiAgentName,
controlledSessionId,
selection,
]);
const resolveLiveAgentName = React.useCallback(() => {
if (selection) return selection.agent;
const liveConfigAgentName = useConfigStore.getState().currentAgentName;
if (currentSessionId) {
return useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
@@ -775,7 +797,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|| currentAgentName;
}
return liveConfigAgentName || currentAgentName;
}, [currentAgentName, currentSessionId]);
}, [currentAgentName, currentSessionId, selection]);
/**
* Records `variant` as this session's effort for the model, in the same
@@ -787,6 +809,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
* user having chosen "Default".
*/
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
if (selection) {
if (controlledSessionId && selection.agent) {
saveAgentModelVariantForSession(controlledSessionId, selection.agent, providerId, modelId, variant);
}
return;
}
const variantOptions = getModelVariantOptions(providerId, modelId);
if (variantOptions.length === 0) {
manualVariantSelectionRef.current = false;
@@ -814,6 +842,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveAgentModelVariantForSession,
setCurrentVariant,
setCurrentVariantOverride,
controlledSessionId,
selection,
]);
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
@@ -823,10 +853,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}
addRecentModel(providerId, modelId);
if (!selection) addRecentModel(providerId, modelId);
commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName);
return 'applied';
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection]);
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection, selection]);
React.useEffect(() => {
if (!currentSessionId) {
@@ -1074,7 +1104,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
]);
React.useEffect(() => {
if (!contextHydrated) {
if (selection || !contextHydrated) {
return;
}
const abortController = new AbortController();
@@ -1127,9 +1157,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
getAgentModelForSession,
tryApplyModelSelection,
contextHydrated,
selection,
]);
React.useEffect(() => {
if (selection) return;
if (!contextHydrated || !currentAgentName) {
manualVariantSelectionRef.current = false;
setCurrentVariant(undefined);
@@ -1199,6 +1231,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
setCurrentVariant,
setCurrentVariantOverride,
settingsDefaultVariant,
selection,
]);
React.useEffect(() => {
@@ -1214,6 +1247,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
if (selection) return;
try {
setAgent(agentName);
addRecentAgent(agentName);
@@ -1238,6 +1272,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
saveSessionAgentSelection,
setAgent,
setAgentMenuOpen,
selection,
]);
const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => {
@@ -1249,6 +1284,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}, [agents, currentAgentName, handleAgentChange]);
const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => {
if (selection) return null;
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
? normalizeCombo(`shift+${cycleAgentShortcut}`)
: '';
@@ -1262,7 +1298,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return null;
}, [cycleAgentShortcut]);
}, [cycleAgentShortcut, selection]);
const handleProviderAndModelChange = (
providerId: string,
@@ -1284,7 +1320,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
}
return;
}
if (!options?.applyVariant) {
if (!selection && !options?.applyVariant) {
// Add to recent models on successful selection.
addRecentModel(providerId, modelId);
}
@@ -2184,6 +2220,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
const renderModelSelector = () => {
if (isCompact && selection) {
return <MobileModelButton
model={selection.model}
onOpenModel={() => setActiveMobilePanel('model')}
className="model-controls__model-trigger flex-shrink-0"
/>;
}
const handleThinkingVariantKey = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry) => {
keyboardOwnsModelSelectionRef.current = true;
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
@@ -2364,7 +2407,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
collisionAvoidance={{ side: 'none', align: 'shift' }}
onKeyDownCapture={handleModelShortcutKeyDownCapture}
>
<div className="p-1 border-b border-border/40">
{!selection && <div className="p-1 border-b border-border/40">
<button
type="button"
onClick={openAddProviderSettings}
@@ -2375,9 +2418,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</span>
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
</button>
</div>
</div>}
<ModelPickerList
providers={providers as ModelPickerProvider[]}
providers={providers}
favoriteModels={favoriteModelsList}
recentModels={recentModelsList}
modelsMetadata={useConfigStore.getState().modelsMetadata}
@@ -2413,7 +2456,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return (
<div className="flex items-center gap-x-2 whitespace-nowrap overflow-hidden">
<span>{t('chat.modelControls.keyboardHintNavigate')}</span>
<span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>
{!selection && <span>{t('chat.modelControls.keyboardHintSwitchAgent', { shortcut: 'Tab' })}</span>}
{activeHasThinkingVariants ? <span>{t('chat.modelControls.keyboardHintThinking')}</span> : null}
</div>
);
@@ -2616,6 +2659,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<button
type="button"
onClick={() => setActiveMobilePanel('variant')}
onMouseDown={(event) => event.preventDefault()}
onPointerDownCapture={(event) => {
if (event.pointerType === 'touch') event.preventDefault();
}}
className={cn(
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
buttonHeight,
@@ -2690,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">
<p className="typography-meta">Thinking: {displayVariant}</p>
<p className="typography-meta">{t('chat.modelControls.thinking')}: {displayVariant}</p>
</TooltipContent>
</Tooltip>
);
@@ -2875,6 +2922,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
);
};
const inlineMobileSelection = isMobile && Boolean(selection);
const inlineClassName = cn(
'@container/model-controls flex items-center min-w-0',
// Only force full-width + truncation behaviors on true mobile layouts.
@@ -2888,22 +2936,24 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
<div className={inlineClassName}>
<div
className={cn(
'flex items-center min-w-0 flex-1 justify-end',
'flex items-center min-w-0 flex-1',
inlineMobileSelection ? 'justify-start' : 'justify-end',
inlineGapClass,
isMobile && 'overflow-hidden'
)}
>
{renderVariantSelector()}
{!inlineMobileSelection && renderVariantSelector()}
{renderModelSelector()}
{renderAgentSelector()}
{inlineMobileSelection && renderVariantSelector()}
{!selection && renderAgentSelector()}
</div>
</div>
{renderMobileModelPanel()}
{renderMobileVariantPanel()}
{renderMobileAgentPanel()}
{!selection && renderMobileAgentPanel()}
{renderMobileModelTooltip()}
{renderMobileAgentTooltip()}
{!selection && renderMobileAgentTooltip()}
</>
);
@@ -1,5 +1,5 @@
import React, { act } from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
import { createRoot } from 'react-dom/client';
import { Window } from 'happy-dom';
import { create } from 'zustand';
@@ -122,9 +122,9 @@ type SelectionState = {
getSessionAgentSelection: () => string | null;
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
getAgentModelVariantForSession: () => VariantChoice;
saveSessionModelSelection: () => void;
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
saveSessionAgentSelection: () => void;
saveAgentModelForSession: () => void;
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
saveAgentModelVariantForSession: (
sessionId: string,
agentName: string,
@@ -206,9 +206,11 @@ mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) }));
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
mock.module('@/components/ui/dropdown-menu', () => ({
DropdownMenu: passthrough,
DropdownMenu: ({ children, open }: React.PropsWithChildren<{ open?: boolean }>) => <div data-menu-open={open}>{children}</div>,
DropdownMenuContent: passthrough,
DropdownMenuItem: passthrough,
DropdownMenuItem: ({ children, onSelect }: React.PropsWithChildren<{ onSelect?: () => void }>) => (
<button onClick={onSelect}>{children}</button>
),
DropdownMenuLabel: passthrough,
DropdownMenuSeparator: () => null,
DropdownMenuTrigger: passthrough,
@@ -216,7 +218,9 @@ mock.module('@/components/ui/dropdown-menu', () => ({
mock.module('@/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
}));
mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough }));
mock.module('@/components/ui/MobileOverlayPanel', () => ({
MobileOverlayPanel: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => open ? <div>{children}</div> : null,
}));
mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null }));
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
mock.module('@/components/ui/tooltip', () => ({
@@ -225,9 +229,13 @@ mock.module('@/components/ui/tooltip', () => ({
TooltipTrigger: passthrough,
}));
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null }));
mock.module('@/components/model-picker/ModelPickerList', () => ({
ModelPickerList: ({ onSelect }: React.ComponentProps<typeof import('@/components/model-picker/ModelPickerList').ModelPickerList>) => (
<button onClick={() => onSelect({ providerID: PROVIDER_ID, modelID: MODEL_ID, model })}>{MODEL_ID}</button>
),
}));
mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) }));
mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModelsList: [], recentModelsList: [] }) }));
mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false }));
mock.module('@/hooks/useOpenCodeReadiness', () => ({
useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }),
@@ -304,12 +312,12 @@ const installDom = () => {
};
};
const renderModelControls = async () => {
const renderModelControls = async (props: React.ComponentProps<typeof ModelControls> = {}) => {
const dom = installDom();
const root = createRoot(dom.container);
await act(async () => root.render(
<I18nProvider>
<ModelControls />
<ModelControls {...props} />
</I18nProvider>,
));
return {
@@ -327,6 +335,7 @@ describe('ModelControls effort restore', () => {
overrideWrites.length = 0;
latestUserChoice = null;
forcePreserveManualOverride = null;
useUIStore.setState({ isMobile: false, isModelSelectorOpen: false });
useSelectionStore.setState({ savedVariant: undefined });
useConfigStore.setState({
currentProviderId: PROVIDER_ID,
@@ -341,9 +350,11 @@ describe('ModelControls effort restore', () => {
test('restores the concrete effort the session history carries', async () => {
latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' };
useUIStore.setState({ isModelSelectorOpen: true });
const { cleanup } = await renderModelControls();
const { dom, cleanup } = await renderModelControls();
try {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('true');
expect(variantWrites).toContain('low');
expect(variantWrites).not.toContain(null);
expect(useSelectionStore.getState().savedVariant).toBe('low');
@@ -403,4 +414,55 @@ describe('ModelControls effort restore', () => {
await cleanup();
}
});
for (const [isMobile, variant] of [
[false, 'low'], [true, null],
] as const) {
test(`controlled BTW selection stays independent (mobile: ${isMobile}, effort: ${variant})`, async () => {
const btwSessionId = 'btw-pending:ses_restore';
latestUserChoice = { id: 'msg-main', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' };
useUIStore.setState({ isMobile, isModelSelectorOpen: true });
useConfigStore.setState({
currentProviderId: 'main-provider', currentModelId: 'main-model',
currentVariant: 'low', currentVariantSelection: { override: undefined, inherited: 'low' },
});
const selections = useSelectionStore.getState();
const saveModel = spyOn(selections, 'saveSessionModelSelection');
const saveAgentModel = spyOn(selections, 'saveAgentModelForSession');
const saveVariant = spyOn(selections, 'saveAgentModelVariantForSession');
const { dom, cleanup } = await renderModelControls({
sessionId: btwSessionId,
selection: { model: { providerId: PROVIDER_ID, modelId: MODEL_ID }, agent: 'plan', variant },
});
try {
expect(overrideWrites).toEqual([]);
expect(variantWrites).toEqual([]);
expect(saveModel.mock.calls).toEqual([]);
expect(dom.container.querySelector('.model-controls__agent-label')).toBeNull();
expect(dom.container.querySelector('.model-controls__variant-label')?.textContent?.trim()).toBe(variant ?? 'Default');
if (!isMobile) {
expect(dom.container.querySelector('.model-controls__model-trigger')?.closest('[data-menu-open]')?.getAttribute('data-menu-open')).toBe('false');
}
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__model-trigger')?.click());
const modelButton = Array.from(dom.container.querySelectorAll<HTMLButtonElement>('button:not(.model-controls__model-trigger)')).find((button) => button.textContent?.trim() === MODEL_ID);
await act(async () => modelButton?.click());
expect(saveModel.mock.calls.at(-1)).toEqual([btwSessionId, PROVIDER_ID, MODEL_ID]);
expect(saveAgentModel.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID]);
await act(async () => dom.container.querySelector<HTMLButtonElement>('.model-controls__variant-trigger')?.click());
const defaultButton = Array.from(dom.container.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Default');
await act(async () => defaultButton?.click());
expect(saveVariant.mock.calls.at(-1)).toEqual([btwSessionId, 'plan', PROVIDER_ID, MODEL_ID, null]);
const config = useConfigStore.getState();
expect([config.currentProviderId, config.currentModelId, config.currentAgentName, config.currentVariant])
.toEqual(['main-provider', 'main-model', AGENT, 'low']);
expect(overrideWrites).toEqual([]);
} finally {
await cleanup();
for (const write of [saveModel, saveAgentModel, saveVariant]) write.mockRestore();
useSelectionStore.setState(selections);
}
});
}
});
@@ -1,6 +1,7 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { isIMECompositionEvent } from '@/lib/ime';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
@@ -40,11 +41,13 @@ const IDLE_SESSION_STATUS = { type: 'idle' as const };
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState; onExit: () => void }> = ({
parentSessionId,
panel,
onExit,
}) => {
const { t } = useI18n();
useEscapeToExit(onExit, !panel.collapsed && Boolean(panel.pending || panel.creating || panel.btwSessionId));
if (panel.btwSessionId && panel.btwDirectory) {
return (
@@ -54,7 +57,6 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
@@ -63,7 +65,7 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<BtwFrame>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
@@ -72,6 +74,27 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
);
}
if (panel.pending) {
return (
<BtwFrame
draftHint={t('chat.btw.draftHint')}
collapsed={panel.collapsed}
actions={(
<Button
type="button"
variant="ghost"
size="icon"
onClick={onExit}
aria-label={t('chat.btw.cancelAria')}
title={t('chat.btw.cancelAria')}
>
<Icon name="close" className="size-4" />
</Button>
)}
/>
);
}
return null;
};
@@ -161,21 +184,18 @@ const useBtwSessionData = (
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
/** Composer and popup handlers get first refusal; the owner decides cancel versus collapse. */
const useEscapeToExit = (onExit: () => void, enabled: boolean): void => {
React.useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
if (event.key !== 'Escape' || event.defaultPrevented || isIMECompositionEvent(event)) return;
event.preventDefault();
onExit();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
}, [enabled, onExit]);
};
/**
@@ -217,14 +237,14 @@ const useAutoScroll = (
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
draftHint?: string;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
}> = ({ actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, draftHint, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
@@ -245,17 +265,12 @@ const BtwFrame: React.FC<{
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
{draftHint ? <span className="typography-ui-label truncate">{draftHint}</span> : null}
</span>
)}
<div className="min-w-0 flex-1" />
@@ -273,23 +288,20 @@ const BtwFrame: React.FC<{
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
}> = ({ sessionRef, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
@@ -324,7 +336,6 @@ const BtwSheet: React.FC<{
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
@@ -335,7 +346,6 @@ const BtwSheet: React.FC<{
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
@@ -351,16 +361,14 @@ const BtwSheet: React.FC<{
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
}> = ({ sessionRef, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
@@ -372,12 +380,11 @@ const BtwCollapsedStrip: React.FC<{
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
}> = ({ sessionRef, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
@@ -395,7 +402,7 @@ const BtwExpandedSheet: React.FC<{
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<BtwFrame actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
@@ -16,6 +16,7 @@ export type BtwPanelState = {
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
pending: boolean;
};
/**
@@ -53,5 +54,6 @@ export function useBtwPanelState(
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
pending: Boolean(uiState?.pending),
};
}
@@ -171,7 +171,7 @@ and the send path reading the same grammar.
attached context is consumed. Commands that act on session or UI state
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
their command text and leave comments, files, and linked context attached;
commands that produce a prompt (`/btw` and the magic prompts) send that
magic prompt commands send that
context with the prompt they produce. Session actions are planned only when
a session exists, so typing one into a new-session draft stays on the normal
send path. A local command is never queued as text: queueing runs it
@@ -232,6 +232,34 @@ session bucket, which adds attachments and keeps prompts a revert hid from the
timeline. A prompt present in both collapses to the persisted entry. Global
scope reads the persisted runtime bucket only.
## BTW composer
An empty `/btw` opens an unsent draft. `/btw <question>` opens BTW and sends
that question immediately after its own draft and model selection are active.
**By the way…** opens an unsent draft with Quote-formatted selection text.
The first send creates the fork; Enter follows the user's preference. Pending text and references then
move to the fork's draft identity. Normal and BTW drafts remain independent,
including in memory when persistence is disabled.
Both modes reuse `ComposerEditor` and `ModelControls`; BTW transitions put the
caret at the end. BTW copies the main model/effort once, including explicit
Default, and uses `plan` or the first selectable agent. Its controlled model
path only writes BTW selections. Attachments, goals, expansion, shell, and
agent selection and file/agent mention autocomplete are unavailable. Auto-accept is applied before the first send.
On mobile, model and effort controls sit in the input's upper-left row; the
footer only contains auto-accept and send/stop controls.
Escape closes menus first. Otherwise it returns to normal: an unsent BTW is
discarded with its text, references, selections and panel; a creating or real
fork is only collapsed. Neither exit sends, aborts, or deletes a server session,
nor consumes the main draft's files, queue, or linked context. Pending snippet
expansion belongs to the unsent panel. Discarding that panel invalidates the
send, and a runtime change prevents fork creation and stale UI recovery.
The unsent panel shows "Ask your question" until fork creation starts.
Existing panels hide titles. Promotion retains the existing internal title, without
transcript fetching or Small Model generation.
## Mobile
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
@@ -125,3 +125,12 @@ describe('precedence and disabling', () => {
expect(at('|')).toBeNull();
});
});
test('BTW leaves file and agent references as text while retaining other pickers', () => {
const btw: TriggerContext = { inputMode: 'normal', mentionsEnabled: false };
expect(at('@src/file|', btw)).toBeNull();
expect(at('@plan|', btw)).toBeNull();
expect(at('#snippet|', btw)).toEqual({ kind: 'snippet', query: 'snippet' });
expect(at('@plan|')?.kind).toBe('mention');
});
@@ -30,6 +30,7 @@ export interface AutocompleteTrigger {
export interface TriggerContext {
/** Shell mode (`!cmd`) disables every picker. */
inputMode: 'normal' | 'shell';
mentionsEnabled?: boolean;
/** Whether the change that moved the caret came from a paste. */
inputSource?: FileMentionAutocompleteInputSource;
/** The text that change inserted, when known. */
@@ -106,6 +107,7 @@ function matchMention(
cursorPosition: number,
context: TriggerContext,
): AutocompleteTrigger | null {
if (context.mentionsEnabled === false) return null;
const query = getFileMentionAutocompleteQuery({
value,
cursorPosition,
@@ -95,7 +95,7 @@ describe('fork composer restoration', () => {
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
composer.render(source);
expect(composer.result.text).toBe(persistEnabled ? 'source draft @source.ts' : '');
expect(composer.result.text).toBe('source draft @source.ts');
expect(readChatDraft(fork).text).toBe(persistEnabled ? 'replay prompt' : '');
} finally {
composer.teardown();
@@ -15,11 +15,13 @@ import React from 'react';
import { useInputStore } from '@/sync/input-store';
import {
clearChatDraft,
getChatDraftIdentityKey,
readChatDraft,
subscribeChatDraftDeletion,
writeChatDraft,
type ChatDraftIdentity,
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
const PERSIST_DEBOUNCE_MS = 500;
@@ -47,7 +49,7 @@ export interface ComposerDraftOptions {
confirmedMentionsRef: React.RefObject<Set<string>>;
/** The draft this composer currently belongs to. */
identity: ChatDraftIdentity | null;
/** User setting: when off, drafts are discarded rather than stored. */
/** User setting: when off, drafts stay in memory without durable writes. */
persistEnabled: boolean;
/** The draft restored on mount, if any. */
initialDraft: { text: string; identity: ChatDraftIdentity | null };
@@ -63,6 +65,12 @@ export interface ComposerDraftControls {
* cleared composer must be stored before the send resolves.
*/
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
/** Consume a command in the current draft while opening another draft. */
handoffDraft: (identity: ChatDraftIdentity | null, draft: string | null) => void;
/** Restore a draft after a failed send without using persistence as state. */
restoreDraft: (identity: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => void;
/** Move an in-memory draft to an identity materialized during an async flow. */
migrateDraft: (from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => void;
}
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
@@ -82,6 +90,15 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
const skipNextPersistRef = React.useRef(false);
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
const draftMemoryRef = React.useRef(new Map<string, ChatDraftSnapshot>());
const skipOutgoingDraftRef = React.useRef(false);
const initialKey = initialDraft.identity ? getChatDraftIdentityKey(initialDraft.identity) : null;
if (persistEnabled && initialKey && !draftMemoryRef.current.has(initialKey) && initialDraft.text) {
draftMemoryRef.current.set(initialKey, {
text: initialDraft.text,
confirmedMentions: new Set(confirmedMentionsRef.current),
});
}
const pendingComposerRestore = useInputStore((state) => state.pendingComposerRestore);
// Callbacks reach the effects through a ref so a caller passing inline
@@ -93,8 +110,13 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
currentIdentityRef.current = identity;
}, [identity]);
React.useEffect(() => {
// Persistence off keeps in-memory drafts, but must not retain old disk copies.
if (!persistEnabled && identity) clearChatDraft(identity);
}, [identity, persistEnabled]);
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
if (!target) return;
if (!persistEnabled || !target) return;
const key = getChatDraftIdentityKey(target);
// Only keep confirmed mentions the draft still contains: a mention the
@@ -110,7 +132,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
writeChatDraft(target, draft, activeMentions);
lastPersistedRef.current.set(key, signature);
}, [confirmedMentionsRef]);
}, [confirmedMentionsRef, persistEnabled]);
const clearPending = React.useCallback(() => {
if (!persistTimerRef.current) return;
@@ -127,8 +149,9 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
if (!initialDraft.text) return;
if (!persistEnabled) {
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
writeChatDraft(initialDraft.identity, '', []);
return;
}
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
@@ -151,16 +174,18 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// debounced effect must not immediately write it back out.
skipNextPersistRef.current = true;
if (!persistEnabled) {
setMessage('');
confirmedMentionsRef.current = new Set();
return;
if (!skipOutgoingDraftRef.current && previousKey) {
const outgoing = { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) };
draftMemoryRef.current.set(previousKey, outgoing);
if (persistEnabled) persistNow(previous, outgoing.text);
}
skipOutgoingDraftRef.current = false;
persistNow(previous, messageRef.current);
const restored = readChatDraft(identity);
const restored = (currentKey && draftMemoryRef.current.get(currentKey))
|| (persistEnabled ? readChatDraft(identity) : { text: '', confirmedMentions: new Set<string>() });
messageRef.current = restored.text;
setMessage(restored.text);
confirmedMentionsRef.current = restored.confirmedMentions;
confirmedMentionsRef.current = new Set(restored.confirmedMentions);
if (restored.text) {
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
}
@@ -197,6 +222,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
const deletedKey = getChatDraftIdentityKey(deleted);
// Record the empty signature so a queued write does not resurrect it.
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
draftMemoryRef.current.set(deletedKey, { text: '', confirmedMentions: new Set() });
const current = currentIdentityRef.current;
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
@@ -210,11 +236,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
// Debounced write while typing.
React.useEffect(() => {
if (!persistEnabled) {
clearPending();
persistNow(identity, '');
return;
}
if (!persistEnabled) return;
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
@@ -253,5 +275,62 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
};
}, [clearPending, messageRef, persistEnabled, persistNow]);
return { persistNow };
const restoreDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string, confirmedMentions: Set<string>) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
const current = currentIdentityRef.current;
const isCurrent = target && current && getChatDraftIdentityKey(target) === getChatDraftIdentityKey(current);
const existing = isCurrent
? { text: messageRef.current, confirmedMentions: confirmedMentionsRef.current }
: (targetKey && draftMemoryRef.current.get(targetKey)) || (persistEnabled ? readChatDraft(target) : null);
const text = existing?.text && existing.text !== draft ? `${existing.text}\n\n${draft}` : draft;
const mentions = new Set([...(existing?.confirmedMentions ?? []), ...confirmedMentions]);
if (targetKey) draftMemoryRef.current.set(targetKey, { text, confirmedMentions: mentions });
if (isCurrent) {
messageRef.current = text;
confirmedMentionsRef.current = new Set(mentions);
setMessage(text);
}
if (persistEnabled && target) {
writeChatDraft(target, text, mentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(target), draftSignature(text, mentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled, setMessage]);
const handoffDraft = React.useCallback((target: ChatDraftIdentity | null, draft: string | null) => {
const targetKey = target ? getChatDraftIdentityKey(target) : null;
if (targetKey && draft !== null) draftMemoryRef.current.set(targetKey, { text: draft, confirmedMentions: new Set() });
const currentKey = currentIdentityRef.current ? getChatDraftIdentityKey(currentIdentityRef.current) : null;
if (targetKey === currentKey) {
if (draft !== null) {
messageRef.current = draft;
confirmedMentionsRef.current = new Set();
setMessage(draft);
persistNow(target, draft);
}
return;
}
if (currentKey) draftMemoryRef.current.set(currentKey, { text: '', confirmedMentions: new Set() });
persistNow(currentIdentityRef.current, '');
skipOutgoingDraftRef.current = true;
messageRef.current = '';
confirmedMentionsRef.current = new Set();
setMessage('');
}, [confirmedMentionsRef, messageRef, persistNow, setMessage]);
const migrateDraft = React.useCallback((from: ChatDraftIdentity | null, to: ChatDraftIdentity | null) => {
if (!to) return;
const current = currentIdentityRef.current;
const draft = from && current && getChatDraftIdentityKey(from) === getChatDraftIdentityKey(current)
? { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) }
: (from && draftMemoryRef.current.get(getChatDraftIdentityKey(from)))
|| (persistEnabled ? readChatDraft(from) : null);
if (!draft) return;
draftMemoryRef.current.set(getChatDraftIdentityKey(to), { text: draft.text, confirmedMentions: new Set(draft.confirmedMentions) });
if (persistEnabled) {
writeChatDraft(to, draft.text, draft.confirmedMentions);
lastPersistedRef.current.set(getChatDraftIdentityKey(to), draftSignature(draft.text, draft.confirmedMentions));
}
}, [confirmedMentionsRef, messageRef, persistEnabled]);
return { persistNow, handoffDraft, restoreDraft, migrateDraft };
}
@@ -23,6 +23,7 @@ import { ComposerActionButtons } from './ComposerActionButtons';
import { ComposerAttachmentControls } from './ComposerAttachmentControls';
import { FocusModeButton } from './FocusModeButton';
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
import type { BtwSelection } from '@/stores/useBtwStore';
const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
@@ -67,6 +68,9 @@ export interface ComposerFooterProps {
onDictationInsert: (text: string) => void;
onDictationInsertAndSend: (text: string) => void;
onDictationContentHeightChange: (height: number | null) => void;
isBtw?: boolean;
modelSessionId?: string | null;
btwSelection: BtwSelection;
}
export function ComposerFooter(props: ComposerFooterProps) {
@@ -108,6 +112,9 @@ export function ComposerFooter(props: ComposerFooterProps) {
onDictationInsert,
onDictationInsertAndSend,
onDictationContentHeightChange,
isBtw = false,
modelSessionId,
btwSelection,
} = props;
return (
@@ -127,7 +134,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
<>
<div className="flex w-full items-center justify-between gap-x-1.5">
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -138,7 +145,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
onOpenMobileSheet={onOpenAttachSheet}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -146,18 +153,18 @@ export function ComposerFooter(props: ComposerFooterProps) {
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className="flex items-center min-w-0 gap-x-1 justify-end">
<div className="flex items-center gap-x-1 flex-shrink-0">
<button
{!isBtw ? <button
type="button"
className={footerIconButtonClass}
// Keep the soft keyboard open (same guard as
@@ -176,7 +183,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
aria-label={t('chat.dictation.start')}
>
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
</button>
</button> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -198,7 +205,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
) : (
<>
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
<ComposerAttachmentControls
{!isBtw ? <ComposerAttachmentControls
isVSCode={isVSCode}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -208,13 +215,13 @@ export function ComposerFooter(props: ComposerFooterProps) {
showLinearPicker={showLinearPicker}
openLinearPicker={onOpenLinearPicker}
onOpenSettings={onOpenSettings}
/>
<FocusModeButton
/> : null}
{!isBtw ? <FocusModeButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
isExpandedInput={isExpandedInput}
onToggle={onToggleExpandedInput}
/>
/> : null}
<PermissionAutoAcceptButton
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
@@ -223,19 +230,19 @@ export function ComposerFooter(props: ComposerFooterProps) {
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
withTooltip
/>
<SessionGoalButton
{!isBtw ? <SessionGoalButton
sessionId={currentSessionId}
directory={directory}
draftOpen={newSessionDraftOpen}
footerIconButtonClass={footerIconButtonClass}
iconSizeClass={iconSizeClass}
withTooltip
/>
<SessionGoalObjectiveCounter length={messageLength} />
/> : null}
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
</div>
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
<MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />
<MemoComposerDictation
{isBtw ? <ModelControls className="flex-1 min-w-0 justify-end" sessionId={modelSessionId ?? null} selection={btwSelection} /> : <MemoModelControls className={cn('flex-1 min-w-0 justify-end')} />}
{!isBtw ? <MemoComposerDictation
radius={chatInputRadius}
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -245,7 +252,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
onInsert={onDictationInsert}
onInsertAndSend={onDictationInsertAndSend}
onContentHeightChange={onDictationContentHeightChange}
/>
/> : null}
<ComposerActionButtons
isMobile={isMobile}
footerIconButtonClass={footerIconButtonClass}
@@ -118,6 +118,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const requestBtwComposer = useInputStore((state) => state.requestBtwComposer);
const isMobile = useUIStore((state) => state.isMobile);
const projects = useProjectsStore((state) => state.projects);
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
@@ -469,6 +470,19 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
addMarkdownToChat(selectedTextMarkdown);
}, [addMarkdownToChat, selectedTextMarkdown]);
const handleAskOpenChamber = React.useCallback(() => {
if (!currentSessionId || !selectedTextMarkdown) return;
requestBtwComposer({
parentSessionId: currentSessionId,
text: wrapMarkdownSelectionForChat(selectedTextMarkdown),
});
hideMenu();
window.getSelection()?.removeAllRanges();
queueMicrotask(() => {
focusChatInput();
});
}, [currentSessionId, hideMenu, requestBtwComposer, selectedTextMarkdown]);
const handleOpenComment = React.useCallback(() => {
if (!selectedTextMarkdown) return;
setCommentMode(true);
@@ -705,6 +719,24 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
</button>
{currentSessionId ? (
<button
onClick={handleAskOpenChamber}
className={cn(
'flex min-w-0 items-center gap-2 rounded-xl px-3 py-2.5 text-left',
'text-sm font-medium leading-tight',
'bg-[var(--surface-muted)] text-[var(--surface-foreground)]',
'active:opacity-80',
'transition-opacity duration-150'
)}
title={t('chat.textSelection.title.askOpenChamber')}
type="button"
>
<Icon name="chat-ai-3" className="h-5 w-5 flex-shrink-0" />
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.askOpenChamber')}</span>
</button>
) : null}
{!isVSCodeRuntime() ? (
<button
onClick={handleAddToNotes}
@@ -766,6 +798,26 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
{t('chat.textSelection.actions.comment')}
</button>
{currentSessionId ? (
<>
<div className="mx-0.5 h-5 w-px shrink-0 bg-[var(--interactive-border)]" />
<button
onClick={handleAskOpenChamber}
className={cn(
'px-3.5 py-1.5 rounded-full',
'text-sm font-medium',
'text-[var(--surface-foreground)]',
'hover:bg-[var(--interactive-hover)]',
'transition-colors duration-150'
)}
title={t('chat.textSelection.title.askOpenChamber')}
type="button"
>
{t('chat.textSelection.actions.askOpenChamber')}
</button>
</>
) : null}
{!isVSCodeRuntime() ? (
<>