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() ? (
<>
@@ -7,6 +7,10 @@ export function hasOpenDropdown(root: ParentNode = document): boolean {
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
}
export function hasActiveBtwComposer(root: ParentNode = document): boolean {
return Boolean(root.querySelector('[data-btw-composer="true"]'));
}
export function shouldStopDropdownImeEscape(
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
dropdownOpen: boolean,
@@ -42,7 +42,7 @@ import {
invokeActiveSelectionAddToChat,
} from '@/lib/addSelectionToChat';
import { isIMECompositionEvent } from '@/lib/ime';
import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
import { hasActiveBtwComposer, hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
const dropdownTargetSelector = [
'[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]',
@@ -227,6 +227,7 @@ export const useKeyboardShortcuts = () => {
focusChatInput();
},
cycle_agent: (event) => {
if (hasActiveBtwComposer()) return false;
const state = useUIStore.getState();
const hasOverlay = state.isSettingsDialogOpen
|| state.isCommandPaletteOpen
@@ -258,6 +259,7 @@ export const useKeyboardShortcuts = () => {
return toggleTerminalSurfaceExpanded();
},
open_model_selector: () => {
if (hasActiveBtwComposer()) return false;
const state = useUIStore.getState();
const hasOverlay = state.isCommandPaletteOpen
|| state.isHelpDialogOpen
@@ -267,6 +269,7 @@ export const useKeyboardShortcuts = () => {
state.setModelSelectorOpen(!state.isModelSelectorOpen);
},
cycle_thinking_variant: () => {
if (hasActiveBtwComposer()) return false;
const state = useUIStore.getState();
const hasOverlay = state.isCommandPaletteOpen
|| state.isHelpDialogOpen
@@ -291,10 +294,12 @@ export const useKeyboardShortcuts = () => {
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
expand_input: () => {
if (hasActiveBtwComposer()) return false;
if (useUIStore.getState().isMobile) return false;
useUIStore.getState().toggleExpandedInput();
},
toggle_dictation: () => {
if (hasActiveBtwComposer()) return false;
const state = useUIStore.getState();
if (
state.isCommandPaletteOpen
@@ -313,6 +318,7 @@ export const useKeyboardShortcuts = () => {
});
function cycleFavoriteModel(delta: number): boolean | void {
if (hasActiveBtwComposer()) return false;
const state = useUIStore.getState();
const hasOverlay = state.isCommandPaletteOpen
|| state.isHelpDialogOpen
@@ -400,6 +406,7 @@ export const useKeyboardShortcuts = () => {
}
if (
target?.closest('[role="dialog"]')
|| target?.closest('[data-btw-composer="true"]')
|| isTerminalEventTarget(target)
|| dropdownOpen
) {
@@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useKeybinds } from './useKeybind';
import { isEditableEventTarget } from './keyboard-shortcut-dom';
import { hasActiveBtwComposer, isEditableEventTarget } from './keyboard-shortcut-dom';
export const useMiniChatKeyboardShortcuts = () => {
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
@@ -25,6 +25,7 @@ export const useMiniChatKeyboardShortcuts = () => {
const dispatcher = dispatcherRef.current;
const cycleFavoriteModel = (delta: number): boolean | void => {
if (hasActiveBtwComposer()) return false;
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) return false;
@@ -64,10 +65,12 @@ export const useMiniChatKeyboardShortcuts = () => {
focusChatInput();
},
open_model_selector: () => {
if (hasActiveBtwComposer()) return false;
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
},
cycle_thinking_variant: () => {
if (hasActiveBtwComposer()) return false;
const configState = useConfigStore.getState();
if (configState.getCurrentModelVariants().length === 0) return false;
+130 -15
View File
@@ -18,13 +18,16 @@ const childStoreSessions: Session[] = [];
const currentSessionSwitches: string[] = [];
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
const parentSyncMessages: Message[] = [];
const sessionMessageReads: string[] = [];
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
forkSessionImpl(sessionId, messageId, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) =>
getSessionMessagesImpl(id, limit, directory),
getSessionMessages: (id: string, limit?: number, directory?: string | null) => {
sessionMessageReads.push(id);
return getSessionMessagesImpl(id, limit, directory);
},
},
}));
mock.module('@/sync/session-actions', () => ({
@@ -59,9 +62,10 @@ mock.module('@/sync/sync-refs', () => ({
}),
}));
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
const { preparePendingBtwSend, btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
await import('@/lib/btw');
const { useBtwStore } = await import('@/stores/useBtwStore');
const { useSelectionStore } = await import('@/sync/selection-store');
const makeSession = (id: string, directory?: string): Session => ({
id,
@@ -72,8 +76,8 @@ const makeSession = (id: string, directory?: string): Session => ({
version: 1,
}) as unknown as Session;
const record = (id: string): { info: Message; parts: Part[] } => ({
info: { id, role: 'user', time: { created: 1 } } as unknown as Message,
const record = (id: string, created = 1): { info: Message; parts: Part[] } => ({
info: { id, sessionID: 'fork-1', role: 'user', time: { created }, agent: 'plan', model: { providerID: 'provider', modelID: 'model' } },
parts: [],
});
@@ -103,6 +107,7 @@ beforeEach(() => {
currentSessionSwitches.length = 0;
metadataPatches.length = 0;
parentSyncMessages.length = 0;
sessionMessageReads.length = 0;
useBtwStore.setState({ byParent: {} });
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
@@ -123,6 +128,17 @@ describe('btwSessionTitle', () => {
});
describe('filterBtwTailMessages', () => {
test('keeps a newer user message whose ID sorts before the inherited boundary', () => {
const records = [record('msg_f001', 1), record('msg_0001', 2), record('msg_f002', 3)];
expect(filterBtwTailMessages(records, 'msg_f001').map((entry) => entry.info.id))
.toEqual(['msg_0001', 'msg_f002']);
});
test('keeps a loaded tail when its inherited boundary is outside the retained page', () => {
const records = [record('msg_0001', 2), record('msg_f002', 3)];
expect(filterBtwTailMessages(records, 'msg_f001')).toEqual(records);
});
test('keeps only messages after the boundary id', () => {
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
@@ -155,14 +171,16 @@ describe('startBtwSession', () => {
let sentText: unknown = null;
let sentOptions: unknown = null;
sendMessageImpl = (...args) => {
sentText = args[0];
sentOptions = args[9];
return Promise.resolve();
sentText = args[0];
sentOptions = args[9];
expect(args[7]).toBe(undefined);
return Promise.resolve();
};
const session = await startBtwSession(startInput);
const session = await startBtwSession({ ...startInput, variant: null });
expect(session.id).toBe('fork-1');
expect(useSelectionStore.getState().getAgentModelVariantForSession('fork-1', 'build', 'provider', 'model')).toBeNull();
expect(registeredDirectories).toEqual(['fork-1:/project']);
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
expect(sentText).toBe('wtf is kafka');
@@ -172,7 +190,7 @@ describe('startBtwSession', () => {
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
]);
// Transient creating flag is cleared once the flow settles.
expect(useBtwStore.getState().byParent).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
});
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
@@ -265,7 +283,7 @@ describe('startBtwSession', () => {
// marker, link, then unlink rollback
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
expect(metadataPatches[2]?.result).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({});
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
});
test('a failed boundary fetch deletes the fork', async () => {
@@ -278,6 +296,22 @@ describe('startBtwSession', () => {
expect(deleted).toEqual(['fork-1']);
expect(metadataPatches).toEqual([]);
});
test('rejects a second creation for the same parent before it forks', async () => {
let releaseFork: ((session: Session) => void) | undefined;
const forkStarted = new Promise<void>((resolve) => {
forkSessionImpl = () => {
resolve();
return new Promise((release) => { releaseFork = release; });
};
});
const first = startBtwSession(startInput);
await forkStarted;
await expect(startBtwSession(startInput)).rejects.toThrow('btw session creation already in progress');
releaseFork?.(makeSession('fork-1', '/project'));
await first;
});
});
describe('destroyBtwSession', () => {
@@ -310,7 +344,12 @@ describe('destroyBtwSession', () => {
describe('promoteBtwSession', () => {
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
test('unlinks the parent, strips the marker, and navigates to the fork', async () => {
test('unlinks the parent, strips the marker, and navigates to the fork without generating a title', async () => {
const renamedTitles: string[] = [];
updateSessionTitleImpl = (_sessionId, title) => {
renamedTitles.push(title);
return Promise.resolve();
};
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
const base = sessionId === 'fork-1'
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
@@ -323,19 +362,43 @@ describe('promoteBtwSession', () => {
await promoteBtwSession(ref);
expect(metadataPatches).toEqual([
{ sessionId: 'parent-1', result: {} },
// The fork stops being a btw session but stays marked as promoted: its
// transcript still carries the boundary instructions.
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
{ sessionId: 'parent-1', result: {} },
]);
expect(currentSessionSwitches).toEqual(['fork-1']);
expect(sessionMessageReads).toEqual([]);
expect(renamedTitles).toEqual([]);
});
test('a failed unlink aborts the promote without navigating', async () => {
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed');
const originalMetadata = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } };
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
if (sessionId === 'parent-1') return Promise.reject(new Error('unlink failed'));
const result = updater(originalMetadata);
metadataPatches.push({ sessionId, result });
return Promise.resolve(makeSession(sessionId));
};
await expect(promoteBtwSession(ref)).rejects.toThrow('unlink failed');
expect(currentSessionSwitches).toEqual([]);
expect(metadataPatches).toEqual([
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
{ sessionId: 'fork-1', result: originalMetadata },
]);
});
test('a failed marker removal preserves the parent link', async () => {
patchSessionMetadataImpl = (sessionId) => {
if (sessionId === 'fork-1') return Promise.reject(new Error('marker failed'));
throw new Error('the parent must remain linked');
};
await expect(promoteBtwSession(ref)).rejects.toThrow('marker failed');
expect(currentSessionSwitches).toEqual([]);
});
});
describe('buildBtwSyntheticTexts', () => {
@@ -358,3 +421,55 @@ describe('buildBtwSyntheticTexts', () => {
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
});
});
describe('pending BTW preparation', () => {
test('cancelling and reopening during snippet expansion cannot revive the old send', async () => {
const { getRuntimeKey } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
panels.clearPanelState('parent-1');
panels.setPanelState('parent-1', { pending: true });
finish();
expect(await preparing).toBeNull();
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ pending: true });
});
test('preparation belongs to its parent and rejects duplicate sends', async () => {
const { getRuntimeKey } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
panels.setPanelState('parent-2', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
expect(await preparePendingBtwSend('parent-1', getRuntimeKey(), async () => {})).toBeNull();
panels.clearPanelState('parent-2');
finish();
expect(await preparing).toBe(useBtwStore.getState().byParent['parent-1']?.pendingSend);
});
test('a stale composer cannot fork on the newly selected runtime', async () => {
await expect(startBtwSession({ ...startInput, expectedRuntimeKey: 'obsolete-runtime' }))
.rejects.toThrow('runtime changed');
expect(useBtwStore.getState().byParent).toEqual({});
expect(registeredDirectories).toEqual([]);
});
});
test('switching runtime during snippet expansion invalidates preparation', async () => {
const { getRuntimeKey, initializeRuntimeEndpoint } = await import('@/lib/runtime-switch');
const panels = useBtwStore.getState();
panels.setPanelState('parent-1', { pending: true });
let finish = () => {};
const expansion = new Promise<void>((resolve) => { finish = resolve; });
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
initializeRuntimeEndpoint({ apiBaseUrl: 'https://btw-test.invalid', runtimeKey: 'changed-during-preparation' });
finish();
expect(await preparing).toBeNull();
expect(useBtwStore.getState().byParent).toEqual({});
});
+89 -23
View File
@@ -4,10 +4,12 @@ import * as sessionActions from '@/sync/session-actions';
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSelectionStore } from '@/sync/selection-store';
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
import { Binary } from '@/sync/binary';
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
import type { AttachedFile } from '@/stores/types/sessionTypes';
import { getRuntimeKey } from '@/lib/runtime-switch';
/**
* `/btw <question>`: fork the main session into a temporary session and send
@@ -24,12 +26,14 @@ import type { AttachedFile } from '@/stores/types/sessionTypes';
*/
export type StartBtwInput = {
parentSessionId: string;
expectedRuntimeKey?: string;
question: string;
directory: string;
providerID: string;
modelID: string;
agent?: string;
variant?: string;
variant?: string | null;
permissionAutoAccept?: boolean;
attachments?: AttachedFile[];
additionalParts?: Array<{
text: string;
@@ -144,11 +148,45 @@ function insertForkIntoDirectoryStore(session: Session, directory: string): void
}
}
/** Preparation can be discarded until the server-side fork starts. */
export async function preparePendingBtwSend(
parentSessionId: string,
expectedRuntimeKey: string,
prepare: () => Promise<void>,
): Promise<symbol | null> {
if (getRuntimeKey() !== expectedRuntimeKey) return null;
const panels = useBtwStore.getState();
const owner = panels.byParent[parentSessionId];
if (!owner?.pending || owner.creating || owner.pendingSend) return null;
const token = Symbol('btw-send');
panels.setPanelState(parentSessionId, { pendingSend: token });
try {
await prepare();
} catch (error) {
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend === token) {
panels.setPanelState(parentSessionId, { pendingSend: undefined });
}
throw error;
}
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend !== token) return null;
if (getRuntimeKey() !== expectedRuntimeKey) {
panels.clearPanelState(parentSessionId);
return null;
}
return token;
}
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
const { setPanelState, clearPanelState } = useBtwStore.getState();
const { setPanelState } = useBtwStore.getState();
if (useBtwStore.getState().byParent[input.parentSessionId]?.creating) {
throw new Error('btw session creation already in progress');
}
const expectedRuntimeKey = input.expectedRuntimeKey ?? getRuntimeKey();
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
setPanelState(input.parentSessionId, { creating: true });
try {
await sessionActions.waitForConnectionOrThrow();
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
// Fork at the parent's last completed assistant turn rather than at HEAD,
// so a `/btw` typed mid-turn does not inherit a half-finished one.
const forkPointMessageID = findLastCompletedAssistantMessageID(
@@ -165,18 +203,28 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the requested directory as the fallback.
const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory;
registerSessionDirectory(forked.id, sessionDirectory);
try {
// The boundary between inherited history and the fork's own tail is the
// id of the newest cloned message. Message ids are server-generated and
// ascending, so everything the fork produces sorts after it.
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
registerSessionDirectory(forked.id, sessionDirectory);
const selections = useSelectionStore.getState();
selections.saveSessionModelSelection(forked.id, input.providerID, input.modelID);
if (input.agent) {
selections.saveSessionAgentSelection(forked.id, input.agent);
selections.saveAgentModelForSession(forked.id, input.agent, input.providerID, input.modelID);
selections.saveAgentModelVariantForSession(forked.id, input.agent, input.providerID, input.modelID, input.variant);
}
if (input.permissionAutoAccept !== undefined) {
const { usePermissionStore } = await import('@/stores/permissionStore');
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
await usePermissionStore.getState().setSessionAutoAccept(forked.id, input.permissionAutoAccept);
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
}
// Locate the inherited-history boundary by identity, not by ID ordering.
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
// A `null` boundary makes the panel show every inherited message, so an
// empty read must not be taken as "the fork inherited nothing" when we
// know it did: having picked a fork point proves the parent had turns.
// Fall back to that id — the fork's own messages are created later and
// still sort after it, so the tail stays complete either way.
// Retain the known fork point as a fallback marker.
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id
?? forkPointMessageID
?? null;
@@ -188,16 +236,19 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// forks are hidden from session lists by this marker, so inserting an
// unmarked fork first would flash it in the sidebar.
const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) =>
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID));
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID), expectedRuntimeKey);
// patchSessionMetadata already upserted the marked fork into the global
// store; the directory child store still needs the explicit insert.
insertForkIntoDirectoryStore(marked, sessionDirectory);
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined);
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question), {
directory: sessionDirectory,
expectedRuntimeKey,
}).catch(() => undefined);
// Link the parent before sending so the panel opens as soon as the
// metadata lands; the question streams into it.
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
withBtwSessionLink(metadata, forked.id));
withBtwSessionLink(metadata, forked.id), expectedRuntimeKey);
try {
await useSessionUIStore.getState().sendMessage(
@@ -211,7 +262,7 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// its most dangerous here, with the parent's in-flight plan as the
// newest thing in its context.
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
input.variant,
input.variant ?? undefined,
'normal',
{ sessionId: forked.id, directory: sessionDirectory },
);
@@ -219,29 +270,31 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
// A fork without its first question is not a usable btw session:
// unlink the parent again before deleting the fork.
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined);
withoutBtwSessionLink(metadata, forked.id), expectedRuntimeKey).catch(() => undefined);
throw error;
}
} catch (error) {
await sessionActions.deleteSession(forked.id).catch(() => undefined);
await sessionActions.deleteSession(forked.id, { expectedRuntimeKey }).catch(() => undefined);
throw error;
}
return forked;
} finally {
clearPanelState(input.parentSessionId);
if (getRuntimeKey() === expectedRuntimeKey) setPanelState(input.parentSessionId, { creating: false });
}
}
/**
* Keep only the fork's own tail: messages after the last message cloned from
* the parent. A `null` boundary means the fork inherited nothing.
* Records are a chronologically ordered suffix of the session. Keep everything
* after the inherited-history marker; an absent marker is outside that suffix.
* Message IDs are identities, not timestamps (including client-generated IDs).
*/
export function filterBtwTailMessages(
records: Array<{ info: Message; parts: Part[] }>,
boundaryMessageID: string | null,
): Array<{ info: Message; parts: Part[] }> {
if (!boundaryMessageID) return records;
return records.filter((record) => record.info.id > boundaryMessageID);
const boundaryIndex = records.findIndex((record) => record.info.id === boundaryMessageID);
return boundaryIndex < 0 ? records : records.slice(boundaryIndex + 1);
}
export type BtwSessionRef = {
@@ -276,10 +329,23 @@ export async function destroyBtwSession(ref: BtwSessionRef): Promise<boolean> {
* session.
*/
export async function promoteBtwSession(ref: BtwSessionRef): Promise<void> {
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
withoutBtwSessionLink(metadata, ref.btwSessionId));
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker)
.catch(() => undefined);
const expectedRuntimeKey = getRuntimeKey();
let originalForkMetadata: Parameters<typeof withoutBtwSessionMarker>[0] | null = null;
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, (metadata) => {
originalForkMetadata = metadata;
return withoutBtwSessionMarker(metadata);
}, expectedRuntimeKey);
try {
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
withoutBtwSessionLink(metadata, ref.btwSessionId), expectedRuntimeKey);
} catch (error) {
const metadataToRestore = originalForkMetadata;
if (metadataToRestore) {
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, () => metadataToRestore, expectedRuntimeKey)
.catch(() => undefined);
}
throw error;
}
useBtwStore.getState().clearPanelState(ref.parentSessionId);
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
}
+4
View File
@@ -2028,6 +2028,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.',
'chat.btw.working': 'Arbeitet…',
'chat.btw.collapseAria': 'btw-Panel einklappen',
'chat.btw.draftHint': 'Stelle deine Frage',
'chat.btw.cancelAria': 'Diese BTW-Frage verwerfen',
'chat.btw.expandAria': 'btw-Panel ausklappen',
'chat.btw.promoteAria': 'Als eigene Sitzung behalten',
'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden',
@@ -2065,6 +2067,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt',
'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen',
'chat.textSelection.actions.comment': 'Kommentieren',
'chat.textSelection.actions.askOpenChamber': 'Übrigens…',
'chat.textSelection.title.askOpenChamber': 'BTW-Entwurf mit der Auswahl öffnen',
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
'chat.textSelection.comment.attach': 'Anhängen',
+4
View File
@@ -2237,6 +2237,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.',
'chat.btw.working': 'Working…',
'chat.btw.collapseAria': 'Collapse the btw panel',
'chat.btw.draftHint': 'Ask your question',
'chat.btw.cancelAria': 'Cancel this BTW question',
'chat.btw.expandAria': 'Expand the btw panel',
'chat.btw.promoteAria': 'Keep as a separate session',
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
@@ -2282,6 +2284,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
'chat.textSelection.actions.addToInput': 'Add to input',
'chat.textSelection.actions.comment': 'Comment',
'chat.textSelection.actions.askOpenChamber': 'By the way…',
'chat.textSelection.title.askOpenChamber': 'Open a BTW draft with the selection',
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
'chat.textSelection.comment.attach': 'Attach',
+4
View File
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.',
'chat.btw.working': 'Trabajando…',
'chat.btw.collapseAria': 'Contraer el panel btw',
'chat.btw.draftHint': 'Haz tu pregunta',
'chat.btw.cancelAria': 'Cancelar esta pregunta BTW',
'chat.btw.expandAria': 'Expandir el panel btw',
'chat.btw.promoteAria': 'Conservar como sesión aparte',
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
"chat.textSelection.actions.addToInput": "Añadir a la entrada",
"chat.textSelection.actions.comment": "Comentar",
"chat.textSelection.actions.askOpenChamber": "Por cierto…",
"chat.textSelection.title.askOpenChamber": "Abrir un borrador BTW con la selección",
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
"chat.textSelection.comment.attach": "Adjuntar",
+4
View File
@@ -1963,6 +1963,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.',
'chat.btw.working': 'En cours…',
'chat.btw.collapseAria': 'Réduire le panneau btw',
'chat.btw.draftHint': 'Posez votre question',
'chat.btw.cancelAria': 'Annuler cette question BTW',
'chat.btw.expandAria': 'Développer le panneau btw',
'chat.btw.promoteAria': 'Conserver comme session à part',
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
@@ -2005,6 +2007,8 @@ export const dict = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes',
'chat.textSelection.actions.addToInput': 'Ajouter à la saisie',
'chat.textSelection.actions.comment': 'Commenter',
'chat.textSelection.actions.askOpenChamber': 'Au fait…',
'chat.textSelection.title.askOpenChamber': 'Ouvrir un brouillon BTW avec la sélection',
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
'chat.textSelection.comment.attach': 'Joindre',
+4
View File
@@ -2232,6 +2232,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。',
'chat.btw.working': '処理中…',
'chat.btw.collapseAria': 'btwパネルを折りたたむ',
'chat.btw.draftHint': '質問を入力してください',
'chat.btw.cancelAria': 'このBTWの質問をキャンセル',
'chat.btw.expandAria': 'btwパネルを展開する',
'chat.btw.promoteAria': '独立したセッションとして保持',
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
@@ -2278,6 +2280,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
'chat.textSelection.actions.addToInput': '入力欄に追加',
'chat.textSelection.actions.comment': 'コメント',
'chat.textSelection.actions.askOpenChamber': 'ところで…',
'chat.textSelection.title.askOpenChamber': '選択したテキストでBTWの下書きを開く',
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
'chat.textSelection.comment.attach': '添付',
+4
View File
@@ -2238,6 +2238,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.',
'chat.btw.working': '작업 중…',
'chat.btw.collapseAria': 'btw 패널 접기',
'chat.btw.draftHint': '질문을 입력하세요',
'chat.btw.cancelAria': '이 BTW 질문 취소',
'chat.btw.expandAria': 'btw 패널 펼치기',
'chat.btw.promoteAria': '별도 세션으로 유지',
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
@@ -2284,6 +2286,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
'chat.textSelection.actions.addToInput': '입력란에 추가',
'chat.textSelection.actions.comment': '댓글',
'chat.textSelection.actions.askOpenChamber': '그런데…',
'chat.textSelection.title.askOpenChamber': '선택한 텍스트로 BTW 초안 열기',
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
'chat.textSelection.comment.attach': '첨부',
+4
View File
@@ -886,6 +886,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.',
'chat.btw.working': 'Pracuje…',
'chat.btw.collapseAria': 'Zwiń panel btw',
'chat.btw.draftHint': 'Zadaj pytanie',
'chat.btw.cancelAria': 'Anuluj to pytanie BTW',
'chat.btw.expandAria': 'Rozwiń panel btw',
'chat.btw.promoteAria': 'Zachowaj jako osobną sesję',
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
@@ -932,6 +934,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek',
'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania',
'chat.textSelection.actions.comment': 'Skomentuj',
'chat.textSelection.actions.askOpenChamber': 'A tak przy okazji…',
'chat.textSelection.title.askOpenChamber': 'Otwórz szkic BTW z zaznaczonym tekstem',
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
'chat.textSelection.comment.attach': 'Załącz',
@@ -2214,9 +2214,13 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.',
'chat.btw.working': 'Trabalhando…',
'chat.btw.collapseAria': 'Recolher o painel btw',
'chat.btw.draftHint': 'Faça sua pergunta',
'chat.btw.cancelAria': 'Cancelar esta pergunta BTW',
'chat.btw.expandAria': 'Expandir o painel btw',
'chat.btw.promoteAria': 'Manter como sessão separada',
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
'chat.textSelection.actions.askOpenChamber': 'A propósito…',
'chat.textSelection.title.askOpenChamber': 'Abrir um rascunho BTW com a seleção',
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
+4
View File
@@ -3278,6 +3278,8 @@ export const dict = {
'chat.btw.toast.destroyFailed': 'btw session yok edilemedi. Kenar çubuğunda kalacak.',
'chat.btw.working': 'Çalışıyor…',
'chat.btw.collapseAria': 'btw panelini daralt',
'chat.btw.draftHint': 'Sorunuzu sorun',
'chat.btw.cancelAria': 'Bu BTW sorusunu iptal et',
'chat.btw.expandAria': 'btw panelini genişlet',
'chat.btw.promoteAria': 'Ayrı bir session olarak sakla',
'chat.btw.toast.promoteFailed': 'btw session saklanamadı',
@@ -3308,6 +3310,8 @@ export const dict = {
'chat.container.sessionLoadError.authDescription': 'Session\'ınızın süresi doldu, bu yüzden sunucu isteği reddetti. Oturum açın, sohbet yüklenecek.',
'chat.textSelection.actions.addToInput': 'Girdiye ekle',
'chat.textSelection.actions.comment': 'Yorum yap',
'chat.textSelection.actions.askOpenChamber': 'Bu arada…',
'chat.textSelection.title.askOpenChamber': 'Seçili metinle BTW taslağı aç',
'chat.textSelection.title.commentOnSelection': 'Seçime yorum yap',
'chat.textSelection.comment.placeholder': 'İsteğe bağlı bir yorum ekleyin...',
'chat.textSelection.comment.attach': 'Ekle',
+4
View File
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.',
'chat.btw.working': 'Працює…',
'chat.btw.collapseAria': 'Згорнути панель btw',
'chat.btw.draftHint': 'Поставте своє запитання',
'chat.btw.cancelAria': 'Скасувати це запитання BTW',
'chat.btw.expandAria': 'Розгорнути панель btw',
'chat.btw.promoteAria': 'Залишити як окрему сесію',
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
"chat.textSelection.actions.addToInput": "Додати в поле вводу",
"chat.textSelection.actions.comment": "Коментувати",
"chat.textSelection.actions.askOpenChamber": "До речі…",
"chat.textSelection.title.askOpenChamber": "Відкрити BTW-чернетку з виділеним текстом",
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
"chat.textSelection.comment.attach": "Прикріпити",
@@ -2202,6 +2202,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。',
'chat.btw.working': '处理中…',
'chat.btw.collapseAria': '收起 btw 面板',
'chat.btw.draftHint': '提出你的问题',
'chat.btw.cancelAria': '取消这次 BTW 提问',
'chat.btw.expandAria': '展开 btw 面板',
'chat.btw.promoteAria': '保留为独立会话',
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
@@ -2248,6 +2250,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
'chat.textSelection.actions.addToInput': '添加到输入框',
'chat.textSelection.actions.comment': '评论',
'chat.textSelection.actions.askOpenChamber': '顺便问一下…',
'chat.textSelection.title.askOpenChamber': '用所选文本打开 BTW 草稿',
'chat.textSelection.title.commentOnSelection': '评论所选内容',
'chat.textSelection.comment.placeholder': '添加可选评论...',
'chat.textSelection.comment.attach': '附加',
@@ -2206,6 +2206,8 @@ export const dict: Record<I18nKey, string> = {
'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。',
'chat.btw.working': '處理中…',
'chat.btw.collapseAria': '收合 btw 面板',
'chat.btw.draftHint': '提出你的問題',
'chat.btw.cancelAria': '取消這次 BTW 提問',
'chat.btw.expandAria': '展開 btw 面板',
'chat.btw.promoteAria': '保留為獨立工作階段',
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
@@ -2252,6 +2254,8 @@ export const dict: Record<I18nKey, string> = {
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
'chat.textSelection.actions.addToInput': '加入輸入框',
'chat.textSelection.actions.comment': '留言',
'chat.textSelection.actions.askOpenChamber': '順便問一下…',
'chat.textSelection.title.askOpenChamber': '用所選文字開啟 BTW 草稿',
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
'chat.textSelection.comment.placeholder': '新增選填留言...',
'chat.textSelection.comment.attach': '附加',
+4 -4
View File
@@ -11,10 +11,10 @@ import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionRev
* survives reloads.
* - The fork itself is marked `openchamber.kind = 'btw'` with
* `originalSessionID` (its parent) and `btwBoundaryMessageID` the id of
* the last message cloned from the parent. Messages with a greater id are
* the fork's own tail and are what the panel renders. Message ids are
* server-generated ascending identifiers, so the boundary is a plain string
* comparison and immune to client clock skew.
* the last message cloned from the parent. The panel locates this marker
* in the chronologically ordered transcript and renders what follows it.
* IDs must not be compared to determine chronology: they can roll over and
* user-message IDs can be generated by a different client clock.
*/
type BtwMetadata = {
kind?: string;
@@ -47,6 +47,11 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
`[data-btw-composer="true"]` owns Escape instead of main-session abort priming.
While active, main and Mini Chat model/effort shortcuts yield; main agent,
expansion and dictation shortcuts also yield. Footer unmounting alone cannot
disable these global registrations or protect the parent composer's selection.
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
# Adding shortcuts
+59 -1
View File
@@ -1,5 +1,15 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useBtwStore } from './useBtwStore';
import { resolveBtwSelection, useBtwStore } from './useBtwStore';
import { useSelectionStore } from '@/sync/selection-store';
const composerModel = { providerId: 'openai', modelId: 'gpt-5.6-terra' };
const input = {
agents: [{ name: 'build', mode: 'primary' as const }, { name: 'plan', mode: 'primary' as const }],
savedAgent: null,
savedModel: null,
composerModel,
composerVariant: 'medium',
};
describe('useBtwStore', () => {
beforeEach(() => {
@@ -35,4 +45,52 @@ describe('useBtwStore', () => {
useBtwStore.getState().clearPanelState('missing');
expect(useBtwStore.getState().byParent).toBe(before);
});
test('inherits model and effort from the main composer, not from the plan agent', () => {
expect(resolveBtwSelection(input)).toEqual({ agent: 'plan', model: composerModel, variant: 'medium' });
expect(resolveBtwSelection({ ...input, composerVariant: null }).variant).toBeNull();
expect(resolveBtwSelection({ ...input, composerModel: null }).model).toBeNull();
expect(resolveBtwSelection({ ...input, agents: [
{ name: 'hidden', mode: 'primary', hidden: true },
{ name: 'custom', mode: 'primary' },
] })).toEqual({ agent: 'custom', model: composerModel, variant: 'medium' });
});
test('prefers the saved BTW selection over the current composer', () => {
const savedModel = { providerId: 'one', modelId: 'selected' };
expect(resolveBtwSelection({ ...input, savedModel, savedVariant: null }))
.toEqual({ agent: 'plan', model: savedModel, variant: null });
expect(resolveBtwSelection({ ...input, savedModel }).variant).toBe(undefined);
});
test('publishes BTW effort edits and cancellation without changing the parent', () => {
const store = useSelectionStore.getState();
const parent = 'selection-cleanup-parent';
const pending = `btw-pending:${parent}`;
for (const session of [parent, pending]) {
store.saveSessionModelSelection(session, 'one', 'model');
store.saveSessionAgentSelection(session, 'plan');
store.saveAgentModelForSession(session, 'plan', 'one', 'model');
store.saveAgentModelVariantForSession(session, 'plan', 'one', 'model', 'high');
}
const observed: Array<string | null | undefined> = [];
const unsubscribe = useSelectionStore.subscribe((state) => {
observed.push(state.getAgentModelVariantForSession(pending, 'plan', 'one', 'model'));
});
try {
store.saveAgentModelVariantForSession(pending, 'plan', 'one', 'model', null);
store.clearSessionSelections(pending);
} finally {
unsubscribe();
}
expect(observed).toEqual([null, undefined]);
expect(store.getSessionModelSelection(pending)).toBeNull();
expect(store.getSessionAgentSelection(pending)).toBeNull();
expect(store.getAgentModelForSession(pending, 'plan')).toBeNull();
expect(store.getAgentModelVariantForSession(pending, 'plan', 'one', 'model')).toBe(undefined);
expect(store.getSessionModelSelection(parent)).toEqual({ providerId: 'one', modelId: 'model' });
expect(store.getSessionAgentSelection(parent)).toBe('plan');
expect(store.getAgentModelForSession(parent, 'plan')).toEqual({ providerId: 'one', modelId: 'model' });
expect(store.getAgentModelVariantForSession(parent, 'plan', 'one', 'model')).toBe('high');
});
});
+31
View File
@@ -1,4 +1,31 @@
import { create } from 'zustand';
import type { Agent } from '@opencode-ai/sdk/v2';
type BtwModelSelection = { providerId: string; modelId: string };
export type BtwSelection = {
agent: string | undefined;
model: BtwModelSelection | null;
variant: string | null | undefined;
};
export const resolveBtwSelection = ({ agents, savedAgent, savedModel, savedVariant, composerModel, composerVariant }: {
agents: readonly Pick<Agent, 'name' | 'hidden' | 'mode'>[];
savedAgent: string | null;
savedModel: BtwModelSelection | null;
savedVariant?: string | null;
composerModel: BtwModelSelection | null;
composerVariant: string | null | undefined;
}): BtwSelection => {
const selectable = agents.filter((agent) => !agent.hidden && (agent.mode === 'primary' || agent.mode === 'all'));
const agent = selectable.find((candidate) => candidate.name === savedAgent)
?? selectable.find((candidate) => candidate.name === 'plan')
?? selectable[0];
return {
agent: agent?.name,
model: savedModel ?? composerModel,
variant: savedModel ? savedVariant : composerVariant,
};
};
/**
* UI-only state for the `/btw` peek panel.
@@ -15,11 +42,15 @@ import { create } from 'zustand';
* landing, so the panel can show its starting state immediately.
* - `destroying`: close was clicked; hides the panel optimistically while the
* unlink/delete round-trip completes.
* - `pending`: `/btw` has opened an unsent local composer. No fork exists yet.
*/
type BtwPanelUIState = {
collapsed?: boolean;
creating?: boolean;
destroying?: boolean;
pending?: boolean;
pendingAutoAccept?: boolean;
pendingSend?: symbol;
};
type BtwStore = {
+25
View File
@@ -90,6 +90,7 @@ describe("input-store attachments", () => {
pendingInputText: null,
pendingInputMode: "replace",
pendingSyntheticParts: null,
pendingBtwComposerRequest: null,
activeEditorFile: null,
})
useInputStore.getState().setAttachedFiles([])
@@ -403,3 +404,27 @@ describe("input-store attachments", () => {
expect(useInputStore.getState().attachedFiles).toEqual([])
})
})
describe("input-store BTW composer requests", () => {
test("keeps the request scoped to its parent without changing the normal composer", () => {
useInputStore.setState({
pendingInputText: "normal draft",
pendingInputMode: "replace",
pendingBtwComposerRequest: null,
attachedFiles: [],
})
useInputStore.getState().requestBtwComposer({
parentSessionId: "parent-1",
text: "> selected text",
})
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-2")).toBeNull()
expect(useInputStore.getState().pendingInputText).toBe("normal draft")
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toEqual({
parentSessionId: "parent-1",
text: "> selected text",
})
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toBeNull()
expect(useInputStore.getState().pendingInputText).toBe("normal draft")
})
})
+18
View File
@@ -120,6 +120,11 @@ export type SyntheticContextPart = {
metadata?: ContextPartMetadata
}
type PendingBtwComposerRequest = {
parentSessionId: string
text: string
}
export type VSCodeActiveEditorFile = {
filePath: string
fileName: string
@@ -144,6 +149,7 @@ export type InputState = {
* narrow layouts); consumed by ChatInput, which owns the command-aware submit.
*/
pendingPresetSubmit: { text: string; type: "command" | "skill" } | null
pendingBtwComposerRequest: PendingBtwComposerRequest | null
attachedFiles: AttachedFile[]
activeEditorFile: VSCodeActiveEditorFile | null
@@ -151,6 +157,8 @@ export type InputState = {
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
requestPresetSubmit: (text: string, type: "command" | "skill") => void
consumePendingPresetSubmit: () => { text: string; type: "command" | "skill" } | null
requestBtwComposer: (request: PendingBtwComposerRequest) => void
consumePendingBtwComposerRequest: (parentSessionId: string | null) => PendingBtwComposerRequest | null
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
addAttachedFile: (file: File) => Promise<boolean>
@@ -176,6 +184,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
pendingInputMode: "replace",
pendingSyntheticParts: null,
pendingPresetSubmit: null,
pendingBtwComposerRequest: null,
attachedFiles: [],
activeEditorFile: null,
@@ -198,6 +207,15 @@ export const useInputStore = create<InputState>()((set, get) => ({
return pendingPresetSubmit
},
requestBtwComposer: (request) => set({ pendingBtwComposerRequest: request }),
consumePendingBtwComposerRequest: (parentSessionId) => {
const request = get().pendingBtwComposerRequest
if (!request || request.parentSessionId !== parentSessionId) return null
set({ pendingBtwComposerRequest: null })
return request
},
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
consumePendingSyntheticParts: () => {
+17
View File
@@ -29,6 +29,7 @@ export type SelectionState = {
getSessionAgentSelection: (sessionId: string) => string | null
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
clearSessionSelections: (sessionId: string) => void
/**
* `variant` is the effort chosen for this agent/model in this session:
* a name, `null` for an explicit "Default" (send no effort), or `undefined`
@@ -94,6 +95,20 @@ export const useSelectionStore = create<SelectionState>()(
getAgentModelForSession: (sessionId, agentName) =>
get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null,
clearSessionSelections: (sessionId) => set((state) => {
const hadVariant = agentModelVariantSelections.delete(sessionId)
if (!hadVariant && !state.sessionModelSelections.has(sessionId)
&& !state.sessionAgentSelections.has(sessionId)
&& !state.sessionAgentModelSelections.has(sessionId)) return state
const sessionModelSelections = new Map(state.sessionModelSelections)
const sessionAgentSelections = new Map(state.sessionAgentSelections)
const sessionAgentModelSelections = new Map(state.sessionAgentModelSelections)
sessionModelSelections.delete(sessionId)
sessionAgentSelections.delete(sessionId)
sessionAgentModelSelections.delete(sessionId)
return { sessionModelSelections, sessionAgentSelections, sessionAgentModelSelections }
}),
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
const key = `${providerId}/${modelId}`
const clears = variant === undefined
@@ -118,10 +133,12 @@ export const useSelectionStore = create<SelectionState>()(
if (agentMap.size === 0) {
agentModelVariantSelections.delete(sessionId)
}
set((state) => ({ ...state }))
return
}
modelMap.set(key, variant)
set((state) => ({ ...state }))
},
getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => {
+8 -2
View File
@@ -1601,9 +1601,15 @@ export async function unarchiveSessions(
return { restoredIds, failedIds }
}
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId)
export async function updateSessionTitle(
sessionId: string,
title: string,
options?: { directory?: string | null; expectedRuntimeKey?: string },
): Promise<void> {
if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed")
const sessionDirectory = options?.directory ?? getSessionDirectory(sessionId)
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed")
useGlobalSessionsStore.getState().upsertSession(session)
mirrorSessionIntoLiveStores(session, sessionDirectory)
}