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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
7b206b1014
commit
02581d08c5
@@ -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,
|
||||
|
||||
+1
-1
@@ -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() ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user