Merge main into bohdan/dev after BTW composer integration
This commit is contained in:
@@ -26,16 +26,18 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
|
|||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
import {
|
import {
|
||||||
createChatDraftIdentity,
|
createChatDraftIdentity,
|
||||||
|
getChatDraftIdentityKey,
|
||||||
|
clearChatDraft,
|
||||||
readChatDraft,
|
readChatDraft,
|
||||||
writeChatDraft,
|
|
||||||
type ChatDraftIdentity,
|
type ChatDraftIdentity,
|
||||||
type ChatDraftSnapshot,
|
type ChatDraftSnapshot,
|
||||||
} from '@/lib/chatDraftPersistence';
|
} from '@/lib/chatDraftPersistence';
|
||||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||||
import { BtwPanel } from './btw/BtwPanel';
|
import { BtwPanel } from './btw/BtwPanel';
|
||||||
import { useBtwPanelState } from './btw/useBtwPanelState';
|
import { useBtwPanelState } from './btw/useBtwPanelState';
|
||||||
|
import { resolveBtwSelection, useBtwStore } from '@/stores/useBtwStore';
|
||||||
import { wasPromotedBtwSession } from '@/lib/sessionBtwMetadata';
|
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 { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { ToolPopupContent } from './message/types';
|
import type { ToolPopupContent } from './message/types';
|
||||||
@@ -47,6 +49,7 @@ import type { SkillAutocompleteHandle } from './SkillAutocomplete';
|
|||||||
import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ModelControls } from './ModelControls';
|
import { ModelControls } from './ModelControls';
|
||||||
|
import { focusChatInput } from './composer/editor/dom';
|
||||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||||
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
|
import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts';
|
||||||
import { ComposerStatusBar } from './ComposerStatusBar';
|
import { ComposerStatusBar } from './ComposerStatusBar';
|
||||||
@@ -83,6 +86,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|||||||
import { usePermissionStore } from '@/stores/permissionStore';
|
import { usePermissionStore } from '@/stores/permissionStore';
|
||||||
import { togglePermissionAutoAccept } from './permissionAutoAccept';
|
import { togglePermissionAutoAccept } from './permissionAutoAccept';
|
||||||
import { useKeybind } from '@/hooks/useKeybind';
|
import { useKeybind } from '@/hooks/useKeybind';
|
||||||
|
import { hasOpenDropdown } from '@/hooks/keyboard-shortcut-dom';
|
||||||
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
import { useAuthSessionStore } from '@/lib/runtime-auth-expiry';
|
||||||
import { extractGitChangedFiles } from './changedFiles';
|
import { extractGitChangedFiles } from './changedFiles';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -201,6 +205,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
|
|||||||
*/
|
*/
|
||||||
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
||||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||||
|
const EMPTY_ATTACHMENTS: AttachedFile[] = [];
|
||||||
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
||||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||||
if (file.name === filename) {
|
if (file.name === filename) {
|
||||||
@@ -364,7 +369,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
return snapshot.text;
|
return snapshot.text;
|
||||||
});
|
});
|
||||||
const confirmedMentionsRef = React.useRef<Set<string>>(initialDraftSnapshotRef.current.confirmedMentions);
|
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 [isDragging, setIsDragging] = React.useState(false);
|
||||||
const [isInternalDrag, setIsInternalDrag] = React.useState(false);
|
const [isInternalDrag, setIsInternalDrag] = React.useState(false);
|
||||||
// At most one picker is open at a time; the prompt language decides which.
|
// 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 liveSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const chatColumnSession = useChatColumnSession();
|
const chatColumnSession = useChatColumnSession();
|
||||||
const currentSessionId = chatColumnSession ? chatColumnSession.sessionId : liveSessionId;
|
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 fallbackDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const liveEffectiveDirectory = useEffectiveDirectory();
|
const liveEffectiveDirectory = useEffectiveDirectory();
|
||||||
const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
|
const currentDirectory = (chatColumnSession?.sessionId ? chatColumnSession.directory : null)
|
||||||
@@ -434,13 +446,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
|
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
|
||||||
const btwSessionId = btwPanel.btwSessionId;
|
const btwSessionId = btwPanel.btwSessionId;
|
||||||
const btwDirectory = btwPanel.btwDirectory;
|
const btwDirectory = btwPanel.btwDirectory;
|
||||||
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
|
const btwComposerSessionId = btwPanel.pending && currentSessionId
|
||||||
() => (currentSessionId && btwSessionId && btwDirectory
|
? `btw-pending:${currentSessionId}`
|
||||||
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
|
: btwSessionId;
|
||||||
: null),
|
const isBtwActive = Boolean(btwComposerSessionId) && !btwPanel.collapsed;
|
||||||
[btwDirectory, btwSessionId, currentSessionId],
|
const immediateBtwSubmitRef = React.useRef<{ identity: ChatDraftIdentity; text: string } | null>(null);
|
||||||
);
|
const draftCaretModeRef = React.useRef({ btw: isBtwActive, atEnd: isBtwActive });
|
||||||
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
|
const inputMode = isBtwActive ? 'normal' : storedInputMode;
|
||||||
// A session promoted out of `/btw` keeps the boundary instructions in its
|
// 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
|
// transcript — there is no way to delete a message part — so it has to say
|
||||||
// they no longer apply.
|
// they no longer apply.
|
||||||
@@ -450,9 +462,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
() => createChatDraftIdentity(
|
() => createChatDraftIdentity(
|
||||||
activeRuntimeKey,
|
activeRuntimeKey,
|
||||||
currentSessionDirectoryForSync ?? currentDirectory,
|
currentSessionDirectoryForSync ?? currentDirectory,
|
||||||
currentSessionId,
|
isBtwActive ? btwComposerSessionId : currentSessionId,
|
||||||
),
|
),
|
||||||
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
|
[activeRuntimeKey, btwComposerSessionId, currentDirectory, currentSessionDirectoryForSync, currentSessionId, isBtwActive],
|
||||||
);
|
);
|
||||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||||
@@ -466,11 +478,21 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
|
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
|
||||||
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
||||||
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
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 addAttachedFile = useInputStore((s) => s.addAttachedFile);
|
||||||
const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles);
|
const clearAttachedFiles = useInputStore((s) => s.clearAttachedFiles);
|
||||||
const saveSessionAgentSelection = useSelectionStore((s) => s.saveSessionAgentSelection);
|
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 consumePendingInputText = useInputStore((s) => s.consumePendingInputText);
|
||||||
|
const consumePendingBtwComposerRequest = useInputStore((s) => s.consumePendingBtwComposerRequest);
|
||||||
|
const pendingBtwComposerRequest = useInputStore((s) => s.pendingBtwComposerRequest);
|
||||||
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
|
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
|
||||||
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
|
||||||
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
const pendingInputText = useInputStore((s) => s.pendingInputText);
|
||||||
@@ -499,10 +521,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
? getModelMetadata(currentProviderId, currentModelId)
|
? getModelMetadata(currentProviderId, currentModelId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const currentVariant = useConfigStore((state) => state.currentVariant);
|
const currentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
|
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
||||||
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
||||||
const setAgent = useConfigStore((state) => state.setAgent);
|
const setAgent = useConfigStore((state) => state.setAgent);
|
||||||
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
||||||
const agents = 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 isMobile = useUIStore((state) => state.isMobile);
|
||||||
const hasHardwareKeyboard = useHardwareKeyboard();
|
const hasHardwareKeyboard = useHardwareKeyboard();
|
||||||
const enterToSend = useUIStore((state) => state.enterToSend);
|
const enterToSend = useUIStore((state) => state.enterToSend);
|
||||||
@@ -513,7 +565,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
|
||||||
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
|
||||||
const largeTextPasteBehavior = useUIStore((state) => state.largeTextPasteBehavior);
|
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 setExpandedInput = useUIStore((state) => state.setExpandedInput);
|
||||||
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
const setTimelineDialogOpen = useUIStore((state) => state.setTimelineDialogOpen);
|
||||||
const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
|
const { git: runtimeGit, vscode: vscodeApi, linear: runtimeLinear } = useRuntimeAPIs();
|
||||||
@@ -531,6 +584,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
const fetchGitStatus = useGitStore((state) => state.fetchStatus);
|
||||||
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
|
const clearGitDiffCache = useGitStore((state) => state.clearDiffCache);
|
||||||
const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept);
|
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 [isNarrowComposer, setIsNarrowComposer] = React.useState(false);
|
||||||
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
const [attachmentPreview, setAttachmentPreview] = React.useState<ToolPopupContent>({
|
||||||
open: false,
|
open: false,
|
||||||
@@ -553,6 +610,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (isBtwActive) return;
|
||||||
const modelKey = `${currentProviderId ?? ''}/${currentModelId ?? ''}`;
|
const modelKey = `${currentProviderId ?? ''}/${currentModelId ?? ''}`;
|
||||||
const inputModalities = currentModelMetadata?.modalities?.input;
|
const inputModalities = currentModelMetadata?.modalities?.input;
|
||||||
const modalitySignature = inputModalities?.slice().sort().join(',') ?? null;
|
const modalitySignature = inputModalities?.slice().sort().join(',') ?? null;
|
||||||
@@ -592,7 +650,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
modalities: unsupportedModalities.map((modality) => modalityLabels[modality]).join(', '),
|
modalities: unsupportedModalities.map((modality) => modalityLabels[modality]).join(', '),
|
||||||
files: fileSummary,
|
files: fileSummary,
|
||||||
}), { id: `attachment-modalities:${modelKey}` });
|
}), { id: `attachment-modalities:${modelKey}` });
|
||||||
}, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, t]);
|
}, [attachedFiles, currentModelId, currentModelMetadata, currentProviderId, isBtwActive, t]);
|
||||||
|
|
||||||
const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => {
|
const handleShowAttachmentPreview = React.useCallback((content: ToolPopupContent) => {
|
||||||
if (!content.image) return;
|
if (!content.image) return;
|
||||||
@@ -864,7 +922,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<LinkedLinearIssueRef | null>(null);
|
const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<LinkedLinearIssueRef | null>(null);
|
||||||
|
|
||||||
// Message queue
|
// Message queue
|
||||||
const messageQueueTarget = currentSessionId
|
const messageQueueTarget = !isBtwActive && currentSessionId
|
||||||
? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory)
|
? createMessageQueueTarget(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory)
|
||||||
: null;
|
: null;
|
||||||
const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null;
|
const messageQueueKey = messageQueueTarget ? getMessageQueueKey(messageQueueTarget) : null;
|
||||||
@@ -882,7 +940,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const takeForSend = useMessageQueueStore((state) => state.takeForSend);
|
const takeForSend = useMessageQueueStore((state) => state.takeForSend);
|
||||||
|
|
||||||
// Inline comment drafts
|
// Inline comment drafts
|
||||||
const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
const inlineDraftSessionKey = isBtwActive ? btwComposerSessionId ?? '' : currentSessionId ?? (newSessionDraftOpen ? 'draft' : '');
|
||||||
const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory;
|
const inlineDraftDirectory = currentSessionDirectoryForSync ?? currentDirectory;
|
||||||
const inlineDraftTarget = React.useMemo<InlineCommentDraftTarget | null>(
|
const inlineDraftTarget = React.useMemo<InlineCommentDraftTarget | null>(
|
||||||
() => inlineDraftSessionKey && inlineDraftDirectory
|
() => inlineDraftSessionKey && inlineDraftDirectory
|
||||||
@@ -907,9 +965,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
() => createInputHistoryIdentity(
|
() => createInputHistoryIdentity(
|
||||||
activeRuntimeKey,
|
activeRuntimeKey,
|
||||||
currentSessionDirectoryForSync ?? currentDirectory ?? '',
|
currentSessionDirectoryForSync ?? currentDirectory ?? '',
|
||||||
currentSessionId ?? 'draft',
|
inlineDraftSessionKey || 'draft',
|
||||||
),
|
),
|
||||||
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
|
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, inlineDraftSessionKey],
|
||||||
);
|
);
|
||||||
const inputHistoryEntries = useInputHistoryStore(React.useCallback(
|
const inputHistoryEntries = useInputHistoryStore(React.useCallback(
|
||||||
(state) => selectInputHistoryEntries(state, inputHistoryIdentity),
|
(state) => selectInputHistoryEntries(state, inputHistoryIdentity),
|
||||||
@@ -917,7 +975,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
));
|
));
|
||||||
// Session scope also reads the visible transcript, so sessions older than
|
// Session scope also reads the visible transcript, so sessions older than
|
||||||
// the persisted history still recall their prompts.
|
// the persisted history still recall their prompts.
|
||||||
const transcriptPrompts = useUserMessageHistory(currentSessionId ?? '');
|
const transcriptPrompts = useUserMessageHistory((isBtwActive ? btwSessionId : currentSessionId) ?? '');
|
||||||
const historyValues = React.useMemo(
|
const historyValues = React.useMemo(
|
||||||
() => (inputHistoryScope === 'session'
|
() => (inputHistoryScope === 'session'
|
||||||
? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries)
|
? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries)
|
||||||
@@ -941,7 +999,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
|
|
||||||
// Draft persistence: identity switching, debounced writes and the
|
// Draft persistence: identity switching, debounced writes and the
|
||||||
// flush-on-hide edges live in the hook.
|
// flush-on-hide edges live in the hook.
|
||||||
const { persistNow: persistDraftImmediately } = useComposerDraft({
|
const {
|
||||||
|
persistNow: persistDraftImmediately,
|
||||||
|
handoffDraft,
|
||||||
|
restoreDraft,
|
||||||
|
migrateDraft,
|
||||||
|
} = useComposerDraft({
|
||||||
message,
|
message,
|
||||||
messageRef,
|
messageRef,
|
||||||
setMessage,
|
setMessage,
|
||||||
@@ -952,13 +1015,60 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
text: initialDraftRef.current ?? '',
|
text: initialDraftRef.current ?? '',
|
||||||
identity: initialDraftIdentityRef.current,
|
identity: initialDraftIdentityRef.current,
|
||||||
},
|
},
|
||||||
onIdentityChange: () => setInputMode('normal'),
|
onIdentityChange: () => {
|
||||||
|
setInputMode('normal');
|
||||||
|
draftCaretModeRef.current.atEnd = isBtwActive || draftCaretModeRef.current.btw;
|
||||||
|
draftCaretModeRef.current.btw = isBtwActive;
|
||||||
|
},
|
||||||
onDraftRestored: (source) => {
|
onDraftRestored: (source) => {
|
||||||
if (source === 'fork') composerRef.current?.focus();
|
const editor = composerRef.current;
|
||||||
composerRef.current?.selectAll();
|
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
|
// Focus textarea when new session draft is opened
|
||||||
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
const prevNewSessionDraftOpenRef = React.useRef(newSessionDraftOpen);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1003,7 +1113,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
|
|
||||||
// Consume pending input text (e.g., from revert action)
|
// Consume pending input text (e.g., from revert action)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (pendingInputText !== null) {
|
if (!isBtwActive && pendingInputText !== null) {
|
||||||
const pending = consumePendingInputText();
|
const pending = consumePendingInputText();
|
||||||
if (pending?.text) {
|
if (pending?.text) {
|
||||||
if (pending.mode === 'append') {
|
if (pending.mode === 'append') {
|
||||||
@@ -1023,11 +1133,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [pendingInputText, consumePendingInputText]);
|
}, [isBtwActive, pendingInputText, consumePendingInputText]);
|
||||||
|
|
||||||
const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts;
|
const hasContent = message.trim().length > 0 || attachedFiles.length > 0 || hasDrafts;
|
||||||
const hasQueuedMessages = queuedMessages.length > 0;
|
const hasQueuedMessages = !isBtwActive && queuedMessages.length > 0;
|
||||||
const canSend = hasContent || hasQueuedMessages;
|
const preparingBtwSend = useBtwStore((state) => Boolean(currentSessionId && state.byParent[currentSessionId]?.pendingSend));
|
||||||
|
const canSend = (hasContent || hasQueuedMessages) && !(isBtwActive && (btwPanel.creating || preparingBtwSend));
|
||||||
|
|
||||||
const canAbort = sessionPhase !== 'idle';
|
const canAbort = sessionPhase !== 'idle';
|
||||||
|
|
||||||
@@ -1170,7 +1281,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recordLinkedReferences(queueSessionId, queueTarget.directory, linked);
|
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. */
|
/** Put the context a queued message was captured with back on the composer chips. */
|
||||||
const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => {
|
const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => {
|
||||||
@@ -1237,8 +1348,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleExpandedInput = React.useCallback(() => {
|
const handleToggleExpandedInput = React.useCallback(() => {
|
||||||
|
if (isBtwActive) return;
|
||||||
setExpandedInput(!isExpandedInput);
|
setExpandedInput(!isExpandedInput);
|
||||||
}, [isExpandedInput, setExpandedInput]);
|
}, [isBtwActive, isExpandedInput, setExpandedInput]);
|
||||||
|
|
||||||
const openIssuePicker = React.useCallback(() => {
|
const openIssuePicker = React.useCallback(() => {
|
||||||
setIssuePickerOpen(true);
|
setIssuePickerOpen(true);
|
||||||
@@ -1260,19 +1372,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (options?: SubmitOptions) => {
|
const handleSubmit = async (options?: SubmitOptions) => {
|
||||||
|
if (isBtwActive && currentSessionId && (btwPanel.creating || useBtwStore.getState().byParent[currentSessionId]?.pendingSend)) return;
|
||||||
const submitRuntimeKey = getRuntimeKey();
|
const submitRuntimeKey = getRuntimeKey();
|
||||||
const queuedOnly = options?.queuedOnly ?? false;
|
const queuedOnly = options?.queuedOnly ?? false;
|
||||||
const queuedMessageId = options?.queuedMessageId;
|
const queuedMessageId = options?.queuedMessageId;
|
||||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||||
const capturedTarget = messageQueueTarget;
|
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
|
// Snapshot the draft and current-session identity before the first
|
||||||
// async gap so a later sidebar selection cannot reroute the send.
|
// async gap so a later sidebar selection cannot reroute the send.
|
||||||
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
|
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
|
||||||
@@ -1312,6 +1417,32 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}
|
}
|
||||||
if (commandPlan?.command.name === 'handoff-review' && (isMobile || isVSCodeRuntime())) commandPlan = null;
|
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 —
|
// A failed send returns the typed prompt no matter WHY it failed —
|
||||||
// auth, network, server, anything. Losing a long prompt to a toast is
|
// auth, network, server, anything. Losing a long prompt to a toast is
|
||||||
// the one outcome this handler must never produce. The mentions are
|
// 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 confirmedMentionsSnapshot = new Set(confirmedMentionsRef.current);
|
||||||
const restoreComposerText = () => {
|
const restoreComposerText = () => {
|
||||||
if (queuedOnly || !inputSnapshot.message) return;
|
if (queuedOnly || !inputSnapshot.message) return;
|
||||||
for (const mention of confirmedMentionsSnapshot) confirmedMentionsRef.current.add(mention);
|
restoreDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsSnapshot);
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The projection knows the captured send configuration; the full
|
// 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.filter((message) => message.id === queuedMessageId)
|
||||||
: queuedMessages;
|
: queuedMessages;
|
||||||
const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined;
|
const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined;
|
||||||
const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId;
|
const providerIdToSend = capturedSendConfig?.providerID ?? (isBtwActive ? effectiveBtwSelection.model?.providerId : currentProviderId);
|
||||||
const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId;
|
const modelIdToSend = capturedSendConfig?.modelID ?? (isBtwActive ? effectiveBtwSelection.model?.modelId : currentModelId);
|
||||||
const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName;
|
const agentNameToSend = capturedSendConfig?.agent ?? (isBtwActive ? effectiveBtwSelection.agent : currentAgentName);
|
||||||
const variantToSend = capturedSendConfig?.variant ?? currentVariant;
|
const variantToSend = capturedSendConfig?.variant ?? (isBtwActive ? effectiveBtwSelection.variant : currentVariant);
|
||||||
|
|
||||||
if (!providerIdToSend || !modelIdToSend) {
|
if (!providerIdToSend || !modelIdToSend) {
|
||||||
console.warn('Cannot send message: provider or model not selected');
|
console.warn('Cannot send message: provider or model not selected');
|
||||||
@@ -1401,7 +1517,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
confirmedMentionsRef.current.clear();
|
confirmedMentionsRef.current.clear();
|
||||||
persistDraftImmediately(chatDraftIdentity, '');
|
persistDraftImmediately(chatDraftIdentity, '');
|
||||||
messageHistory.reset();
|
messageHistory.reset();
|
||||||
setExpandedInput(false);
|
if (!isBtwActive) setExpandedInput(false);
|
||||||
if (isMobile) composerRef.current?.blur();
|
if (isMobile) composerRef.current?.blur();
|
||||||
try {
|
try {
|
||||||
if (actionName === 'undo') {
|
if (actionName === 'undo') {
|
||||||
@@ -1454,7 +1570,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []),
|
||||||
]);
|
]);
|
||||||
const documentMentions = await prepareDocumentMentions(
|
const documentMentions = await prepareDocumentMentions(
|
||||||
!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [],
|
!isBtwActive && !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [],
|
||||||
reservedFilenames,
|
reservedFilenames,
|
||||||
submitRuntimeKey,
|
submitRuntimeKey,
|
||||||
);
|
);
|
||||||
@@ -1497,7 +1613,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
// Inline review comments and synthetic context are consumed before
|
// Inline review comments and synthetic context are consumed before
|
||||||
// assembly so a failed send can restore exactly what it took. What is
|
// 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.
|
// here belongs to this send: queueing took its own context with it.
|
||||||
const syntheticParts = consumePendingSyntheticParts();
|
const syntheticParts = isBtwActive ? [] : consumePendingSyntheticParts();
|
||||||
const consumedDraftTarget = inlineDraftTarget;
|
const consumedDraftTarget = inlineDraftTarget;
|
||||||
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
const drafts: InlineCommentDraft[] = consumedDraftTarget
|
||||||
? consumeDrafts(consumedDraftTarget)
|
? consumeDrafts(consumedDraftTarget)
|
||||||
@@ -1537,21 +1653,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
|
...buildBtwSyntheticTexts({ isBtwActive, isPromotedBtwSession }),
|
||||||
...(syntheticParts?.map((part) => part.text) ?? []),
|
...(syntheticParts?.map((part) => part.text) ?? []),
|
||||||
],
|
],
|
||||||
linkedIssue: linkedIssue
|
linkedIssue: !isBtwActive && linkedIssue
|
||||||
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText }
|
||||||
: null,
|
: null,
|
||||||
linkedPr: linkedPr
|
linkedPr: !isBtwActive && linkedPr
|
||||||
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText }
|
||||||
: null,
|
: null,
|
||||||
linkedLinearIssue: linkedLinearIssue
|
linkedLinearIssue: !isBtwActive && linkedLinearIssue
|
||||||
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
|
? { identifier: linkedLinearIssue.identifier, title: linkedLinearIssue.title, url: linkedLinearIssue.url, contextText: linkedLinearIssue.contextText }
|
||||||
: null,
|
: null,
|
||||||
}, {
|
}, {
|
||||||
parseAgentMention: (text) => {
|
parseAgentMention: (text) => {
|
||||||
|
if (isBtwActive) return { text };
|
||||||
const { sanitizedText, mention } = parseAgentMentions(text, agents);
|
const { sanitizedText, mention } = parseAgentMentions(text, agents);
|
||||||
return { text: sanitizedText, agentName: mention?.name };
|
return { text: sanitizedText, agentName: mention?.name };
|
||||||
},
|
},
|
||||||
extractFileMentions: (text) => {
|
extractFileMentions: (text) => {
|
||||||
|
if (isBtwActive) return { text, attachments: [] };
|
||||||
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
|
const { sanitizedText, attachments } = extractInlineFileMentions(text, preparedDocumentMentions);
|
||||||
return { text: sanitizedText, attachments };
|
return { text: sanitizedText, attachments };
|
||||||
},
|
},
|
||||||
@@ -1568,6 +1686,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
// Clear input (the queue was taken above)
|
// Clear input (the queue was taken above)
|
||||||
if (!queuedOnly) {
|
if (!queuedOnly) {
|
||||||
setMessage('');
|
setMessage('');
|
||||||
|
messageRef.current = '';
|
||||||
confirmedMentionsRef.current.clear();
|
confirmedMentionsRef.current.clear();
|
||||||
// Clear per-session draft on submit
|
// Clear per-session draft on submit
|
||||||
persistDraftImmediately(chatDraftIdentity, '');
|
persistDraftImmediately(chatDraftIdentity, '');
|
||||||
@@ -1576,58 +1695,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
clearAttachedFiles();
|
clearAttachedFiles();
|
||||||
}
|
}
|
||||||
// Close expanded input overlay when submitting
|
// Close expanded input overlay when submitting
|
||||||
setExpandedInput(false);
|
if (!isBtwActive) setExpandedInput(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
composerRef.current?.blur();
|
composerRef.current?.blur();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt commands render a visible prompt (or fork a btw question) and
|
// Prompt commands render a visible prompt and send it with everything
|
||||||
// send it with everything the composer had attached.
|
// the composer had attached. `/btw` was handled above as a composer
|
||||||
|
// transition and never reaches this sending path.
|
||||||
if (commandPlan?.kind === 'prompt') {
|
if (commandPlan?.kind === 'prompt') {
|
||||||
const { name: commandName, argument } = commandPlan.command;
|
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
|
// The rest render a visible prompt plus synthetic instructions and
|
||||||
// send them as one message, the attached context riding along.
|
// send them as one message, the attached context riding along.
|
||||||
const command = findMagicPromptCommand(commandName);
|
const command = findMagicPromptCommand(commandName);
|
||||||
@@ -1672,15 +1752,29 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const expandOutgoingSnippets = async () => {
|
||||||
const expandText = useSnippetsStore.getState().expandText;
|
try {
|
||||||
primaryText = await expandText(primaryText);
|
const expandText = useSnippetsStore.getState().expandText;
|
||||||
for (const part of additionalParts) {
|
primaryText = await expandText(primaryText);
|
||||||
if (!part.synthetic) part.text = await expandText(part.text);
|
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
|
// Collect all attachments for error recovery
|
||||||
const allAttachments = [
|
const allAttachments = [
|
||||||
@@ -1693,6 +1787,59 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
// never claims the new message.
|
// never claims the new message.
|
||||||
scrollToBottom?.();
|
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(
|
const sendPromise = sendMessage(
|
||||||
primaryText,
|
primaryText,
|
||||||
providerIdToSend,
|
providerIdToSend,
|
||||||
@@ -1706,6 +1853,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
sendMessageOptions,
|
sendMessageOptions,
|
||||||
);
|
);
|
||||||
void sendPromise.then(() => {
|
void sendPromise.then(() => {
|
||||||
|
if (isBtwActive) return;
|
||||||
// On a draft there is no session yet in this closure: the send path
|
// 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
|
// creates one and makes it current before resolving, so the id is
|
||||||
// read from the store. The fallback is used only when the closure
|
// 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 });
|
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
|
// Preset chips rendered outside this component (e.g. under the welcome
|
||||||
// message on narrow surfaces) request a submit via the input store; consume
|
// message on narrow surfaces) request a submit via the input store; consume
|
||||||
// it here so it routes through the same command-aware submit path.
|
// 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
|
// Enter shell mode before CodeMirror inserts the trigger. Keeping the
|
||||||
// document unchanged also keeps the caret at the start for the first
|
// document unchanged also keeps the caret at the start for the first
|
||||||
// command character.
|
// command character.
|
||||||
if (inputMode === 'normal' && e.key === '!') {
|
if (!isBtwActive && inputMode === 'normal' && e.key === '!') {
|
||||||
const selection = composerRef.current?.getSelection();
|
const selection = composerRef.current?.getSelection();
|
||||||
if (selection?.start === 0 && selection.end === 0) {
|
if (selection?.start === 0 && selection.end === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1887,6 +2047,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBtwActive && currentSessionId && e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExitBtw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isDesktopExpanded && e.key === 'Escape') {
|
if (isDesktopExpanded && e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setExpandedInput(false);
|
setExpandedInput(false);
|
||||||
@@ -1902,7 +2069,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
? 1
|
? 1
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
if (cycleAgentDirection !== 0 && openAutocomplete === null) {
|
if (!isBtwActive && cycleAgentDirection !== 0 && openAutocomplete === null) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleCycleAgent(cycleAgentDirection);
|
handleCycleAgent(cycleAgentDirection);
|
||||||
@@ -1945,7 +2112,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const recalled = messageHistory.older({ text: message, attachments: attachedFiles });
|
const recalled = messageHistory.older({ text: message, attachments: attachedFiles });
|
||||||
if (recalled !== null) {
|
if (recalled !== null) {
|
||||||
setMessage(recalled.text);
|
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
|
// Caret to the start, so the recalled message reads from its
|
||||||
// beginning rather than from wherever the draft's caret was.
|
// beginning rather than from wherever the draft's caret was.
|
||||||
requestAnimationFrame(() => composerRef.current?.setSelection(0, 0));
|
requestAnimationFrame(() => composerRef.current?.setSelection(0, 0));
|
||||||
@@ -1958,7 +2125,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const recalled = messageHistory.newer({ text: message, attachments: attachedFiles });
|
const recalled = messageHistory.newer({ text: message, attachments: attachedFiles });
|
||||||
if (recalled !== null) {
|
if (recalled !== null) {
|
||||||
setMessage(recalled.text);
|
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));
|
requestAnimationFrame(() => composerRef.current?.setSelection(recalled.text.length, recalled.text.length));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2050,12 +2217,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
) => {
|
) => {
|
||||||
const trigger = resolveAutocompleteTrigger(value, cursorPosition, {
|
const trigger = resolveAutocompleteTrigger(value, cursorPosition, {
|
||||||
inputMode,
|
inputMode,
|
||||||
|
mentionsEnabled: !isBtwActive,
|
||||||
inputSource,
|
inputSource,
|
||||||
insertedText,
|
insertedText,
|
||||||
});
|
});
|
||||||
setOpenAutocomplete(trigger?.kind ?? null);
|
setOpenAutocomplete(trigger?.kind ?? null);
|
||||||
setAutocompleteQuery(trigger?.query ?? '');
|
setAutocompleteQuery(trigger?.query ?? '');
|
||||||
}, [inputMode]);
|
}, [inputMode, isBtwActive]);
|
||||||
|
|
||||||
const insertTextAtSelection = React.useCallback((
|
const insertTextAtSelection = React.useCallback((
|
||||||
text: string,
|
text: string,
|
||||||
@@ -2153,7 +2321,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
// Mobile keyboards and paste may update the document without a usable
|
// Mobile keyboards and paste may update the document without a usable
|
||||||
// keydown, so consume the trigger in the same editor transaction rather
|
// keydown, so consume the trigger in the same editor transaction rather
|
||||||
// than moving the caret in a later frame against stale text.
|
// 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 shellCommand = value.slice(1);
|
||||||
const nextCursor = Math.max(0, selection.start - 1);
|
const nextCursor = Math.max(0, selection.start - 1);
|
||||||
setInputMode('shell');
|
setInputMode('shell');
|
||||||
@@ -2180,6 +2348,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
}, [clearDropTextSuppression, clearFileMentionPasteSuppression]);
|
}, [clearDropTextSuppression, clearFileMentionPasteSuppression]);
|
||||||
|
|
||||||
const handlePaste = React.useCallback(async (event: ClipboardEvent) => {
|
const handlePaste = React.useCallback(async (event: ClipboardEvent) => {
|
||||||
|
if (isBtwActive && event.clipboardData?.files.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const clipboardData = event.clipboardData;
|
const clipboardData = event.clipboardData;
|
||||||
if (!clipboardData) return;
|
if (!clipboardData) return;
|
||||||
// Narrowed alias so the rest of the handler reads as it did when this
|
// 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 behavior: LargeTextPasteBehavior = largeTextPasteBehavior;
|
||||||
const shouldOfferLargePaste = sessionReady
|
const shouldOfferLargePaste = sessionReady
|
||||||
&& inputMode === 'normal'
|
&& inputMode === 'normal'
|
||||||
|
&& !isBtwActive
|
||||||
&& behavior !== 'inline'
|
&& behavior !== 'inline'
|
||||||
&& isLargePlainTextPaste(pastedText);
|
&& isLargePlainTextPaste(pastedText);
|
||||||
|
|
||||||
@@ -2392,7 +2565,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
pendingPastedAttachmentFilenamesRef.current.delete(filename);
|
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 }) => {
|
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
||||||
|
|
||||||
@@ -2527,7 +2700,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCommandSelect = (command: CommandInfo) => {
|
const handleCommandSelect = (command: CommandInfo) => {
|
||||||
|
if (command.name === 'btw' && currentSessionId) {
|
||||||
|
closeAutocomplete();
|
||||||
|
void handleSubmitRef.current({ presetText: '/btw' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setMessage(`/${command.name} `);
|
setMessage(`/${command.name} `);
|
||||||
|
|
||||||
closeAutocomplete();
|
closeAutocomplete();
|
||||||
@@ -2654,6 +2831,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
if (isBtwActive) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
dragEnterCountRef.current = 0;
|
dragEnterCountRef.current = 0;
|
||||||
const draggedFiles = hasDraggedFiles(e.dataTransfer);
|
const draggedFiles = hasDraggedFiles(e.dataTransfer);
|
||||||
if (!draggedFiles) {
|
if (!draggedFiles) {
|
||||||
@@ -2739,6 +2920,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
||||||
|
if (isBtwActive) return;
|
||||||
const list = Array.isArray(files) ? files : Array.from(files);
|
const list = Array.isArray(files) ? files : Array.from(files);
|
||||||
let attached = false;
|
let attached = false;
|
||||||
|
|
||||||
@@ -2752,9 +2934,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
if (list.length > 0 && !attached) {
|
if (list.length > 0 && !attached) {
|
||||||
toast.error(t('chat.chatInput.toast.attachFileFailed'));
|
toast.error(t('chat.chatInput.toast.attachFileFailed'));
|
||||||
}
|
}
|
||||||
}, [addAttachedFile, t]);
|
}, [addAttachedFile, isBtwActive, t]);
|
||||||
|
|
||||||
const handleVSCodePickFiles = React.useCallback(async () => {
|
const handleVSCodePickFiles = React.useCallback(async () => {
|
||||||
|
if (isBtwActive) return;
|
||||||
try {
|
try {
|
||||||
const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as {
|
const data = (await vscodeApi?.pickFiles?.({ extensions: ACCEPTED_ATTACHMENT_EXTENSIONS })) as {
|
||||||
files?: Array<{ name: string; mimeType?: string; dataUrl?: string }>;
|
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);
|
console.error('VS Code file pick failed', error);
|
||||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
|
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.vscodePickFailed'));
|
||||||
}
|
}
|
||||||
}, [attachFiles, t, vscodeApi]);
|
}, [attachFiles, isBtwActive, t, vscodeApi]);
|
||||||
|
|
||||||
const handlePickLocalFiles = React.useCallback(() => {
|
const handlePickLocalFiles = React.useCallback(() => {
|
||||||
|
if (isBtwActive) return;
|
||||||
if (isVSCodeRuntime()) {
|
if (isVSCodeRuntime()) {
|
||||||
void handleVSCodePickFiles();
|
void handleVSCodePickFiles();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, [handleVSCodePickFiles]);
|
}, [handleVSCodePickFiles, isBtwActive]);
|
||||||
|
|
||||||
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLocalFileSelect = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (isBtwActive) {
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
await attachFiles(files);
|
await attachFiles(files);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}, [attachFiles]);
|
}, [attachFiles, isBtwActive]);
|
||||||
|
|
||||||
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
const footerGapClass = 'gap-x-1.5 gap-y-0';
|
||||||
const isVSCode = isVSCodeRuntime();
|
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 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 footerIconButtonClass = cn(iconButtonBaseClass, buttonSizeClass);
|
||||||
const permissionScopeSessionId = currentSessionId ?? currentManagementSessionId;
|
const permissionScopeSessionId = isBtwActive ? btwSessionId : currentSessionId ?? currentManagementSessionId;
|
||||||
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
|
const permissionAutoAcceptEnabled = usePermissionStore((state) => {
|
||||||
|
if (isBtwActive && !btwSessionId) return pendingBtwAutoAccept;
|
||||||
if (!permissionScopeSessionId) {
|
if (!permissionScopeSessionId) {
|
||||||
return draftPermissionAutoAcceptEnabled;
|
return draftPermissionAutoAcceptEnabled;
|
||||||
}
|
}
|
||||||
@@ -2971,6 +3160,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
const isPermissionAutoAcceptInteractive = Boolean(permissionScopeSessionId || newSessionDraftOpen);
|
const isPermissionAutoAcceptInteractive = Boolean(permissionScopeSessionId || newSessionDraftOpen);
|
||||||
|
|
||||||
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
|
const handlePermissionAutoAcceptToggle = React.useCallback(() => {
|
||||||
|
if (isBtwActive && !btwSessionId && currentSessionId) {
|
||||||
|
useBtwStore.getState().setPanelState(currentSessionId, { pendingAutoAccept: !pendingBtwAutoAccept });
|
||||||
|
return;
|
||||||
|
}
|
||||||
togglePermissionAutoAccept({
|
togglePermissionAutoAccept({
|
||||||
permissionScopeSessionId,
|
permissionScopeSessionId,
|
||||||
newSessionDraftOpen,
|
newSessionDraftOpen,
|
||||||
@@ -2986,6 +3179,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
newSessionDraftOpen,
|
newSessionDraftOpen,
|
||||||
permissionAutoAcceptEnabled,
|
permissionAutoAcceptEnabled,
|
||||||
permissionScopeSessionId,
|
permissionScopeSessionId,
|
||||||
|
isBtwActive,
|
||||||
|
btwSessionId,
|
||||||
|
currentSessionId,
|
||||||
|
pendingBtwAutoAccept,
|
||||||
setDraftPermissionAutoAcceptEnabled,
|
setDraftPermissionAutoAcceptEnabled,
|
||||||
setSessionAutoAccept,
|
setSessionAutoAccept,
|
||||||
t,
|
t,
|
||||||
@@ -3010,6 +3207,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
ref={composerFormRef}
|
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(); }}
|
onSubmit={(e) => { e.preventDefault(); handlePrimaryAction(); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full pt-0 pb-4",
|
"relative w-full pt-0 pb-4",
|
||||||
@@ -3032,11 +3238,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={cn('chat-input-column relative overflow-visible', isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
<div className={cn('chat-input-column relative overflow-visible', isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||||
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
|
{!isBtwActive ? <AttachedFilesList onShowPopup={handleShowAttachmentPreview} /> : null}
|
||||||
<QueuedMessageChips
|
{!isBtwActive ? <QueuedMessageChips
|
||||||
onEditMessage={handleQueuedMessageEdit}
|
onEditMessage={handleQueuedMessageEdit}
|
||||||
onSendMessage={handleQueuedMessageSend}
|
onSendMessage={handleQueuedMessageSend}
|
||||||
/>
|
/> : null}
|
||||||
<AutoReviewBanner />
|
<AutoReviewBanner />
|
||||||
{hasDrafts ? (
|
{hasDrafts ? (
|
||||||
<ComposerContextChips
|
<ComposerContextChips
|
||||||
@@ -3132,7 +3338,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
isMobileExpanded && 'flex min-h-0 flex-1 flex-col',
|
isMobileExpanded && 'flex min-h-0 flex-1 flex-col',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && !mobileComposerExpanded ? (
|
{isMobile && !mobileComposerExpanded && !isBtwActive ? (
|
||||||
<MobilePillComposer
|
<MobilePillComposer
|
||||||
message={message}
|
message={message}
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
@@ -3162,18 +3368,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<SessionGoalRow
|
{!isBtwActive ? <SessionGoalRow
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||||
className="mb-1.5"
|
className="mb-1.5"
|
||||||
/>
|
/> : null}
|
||||||
<SessionSuggestionChip
|
{!isBtwActive ? <SessionSuggestionChip
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
directory={currentSessionDirectoryForSync ?? currentDirectory}
|
||||||
hidden={hasContent || newSessionDraftOpen}
|
hidden={hasContent || newSessionDraftOpen}
|
||||||
onApply={applyAssistSuggestion}
|
onApply={applyAssistSuggestion}
|
||||||
className="mb-1.5"
|
className="mb-1.5"
|
||||||
/>
|
/> : null}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible",
|
"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')}>
|
<div className={cn("overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||||
{isMobile ? (
|
{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">
|
<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" />
|
{isBtwActive ? <ModelControls
|
||||||
<MemoMobileAgentButton
|
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}
|
onOpenAgentPanel={handleOpenAgentPanel}
|
||||||
onCycleAgent={handleCycleAgent}
|
onCycleAgent={handleCycleAgent}
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
/>
|
/> : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||||
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
|
{!isBtwActive ? <AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} /> : null}
|
||||||
<ActiveEditorFileSuggestion />
|
{!isBtwActive ? <ActiveEditorFileSuggestion /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn("relative overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}
|
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}
|
onDictationInsert={handleDictationInsert}
|
||||||
onDictationInsertAndSend={handleDictationInsertAndSend}
|
onDictationInsertAndSend={handleDictationInsertAndSend}
|
||||||
onDictationContentHeightChange={handleDictationContentHeightChange}
|
onDictationContentHeightChange={handleDictationContentHeightChange}
|
||||||
|
isBtw={isBtwActive}
|
||||||
|
modelSessionId={btwComposerSessionId}
|
||||||
|
btwSelection={effectiveBtwSelection}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3359,7 +3573,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
the pill ↔ composer swap so a recording started from the pill
|
the pill ↔ composer swap so a recording started from the pill
|
||||||
survives the morph. Its absolute overlay covers whichever
|
survives the morph. Its absolute overlay covers whichever
|
||||||
shape the wrapper currently has. */}
|
shape the wrapper currently has. */}
|
||||||
{isMobile ? (
|
{isMobile && !isBtwActive ? (
|
||||||
<MemoComposerDictation
|
<MemoComposerDictation
|
||||||
radius={chatInputRadius}
|
radius={chatInputRadius}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
@@ -3378,7 +3592,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
{/* Hidden host for the model/agent/variant bottom sheets. Kept
|
{/* Hidden host for the model/agent/variant bottom sheets. Kept
|
||||||
outside the pill conditional so an open panel survives (and
|
outside the pill conditional so an open panel survives (and
|
||||||
stays visible over) the collapsed composer. */}
|
stays visible over) the collapsed composer. */}
|
||||||
{isMobile ? (
|
{isMobile && !isBtwActive ? (
|
||||||
<MemoModelControls
|
<MemoModelControls
|
||||||
className="hidden"
|
className="hidden"
|
||||||
mobilePanel={mobileControlsPanel}
|
mobilePanel={mobileControlsPanel}
|
||||||
@@ -3392,7 +3606,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
|||||||
className={cn('chat-input-column mt-4', draftPresentationClassName)}
|
className={cn('chat-input-column mt-4', draftPresentationClassName)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
|
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} onExit={handleExitBtw} /> : null}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Issue Picker Dialog */}
|
{/* Issue Picker Dialog */}
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
interface MobileModelButtonProps {
|
interface MobileModelButtonProps {
|
||||||
onOpenModel: () => void;
|
onOpenModel: () => void;
|
||||||
className?: string;
|
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 { t } = useI18n();
|
||||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
const currentModelId = useConfigStore((state) => model === undefined ? state.currentModelId : model?.modelId);
|
||||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
const currentProviderId = useConfigStore((state) => model === undefined ? state.currentProviderId : model?.providerId);
|
||||||
const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider);
|
const providers = useConfigStore((state) => state.providers);
|
||||||
const currentProvider = getCurrentProvider();
|
const currentProvider = providers.find((provider) => provider.id === currentProviderId);
|
||||||
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
|
const modelLabel = getModelDisplayName(currentProvider, currentModelId, t('chat.modelControls.selectModel'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { focusChatInput } from './composer/editor/dom';
|
import { focusChatInput } from './composer/editor/dom';
|
||||||
|
import { MobileModelButton } from './MobileModelButton';
|
||||||
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
import type { EditPermissionMode } from '@/stores/types/sessionTypes';
|
||||||
import type { ModelMetadata } from '@/types';
|
import type { ModelMetadata } from '@/types';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +18,7 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { IconName } from "@/components/icon/icons";
|
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 { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
|
||||||
import { isDesktopShell } from '@/lib/desktop';
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
import { getAgentColor } from '@/lib/agentColors';
|
import { getAgentColor } from '@/lib/agentColors';
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
shouldPreserveManualModelOverride,
|
shouldPreserveManualModelOverride,
|
||||||
} from '@/lib/messages/userModelChoice';
|
} from '@/lib/messages/userModelChoice';
|
||||||
import { getSyncParts } from '@/sync/sync-refs';
|
import { getSyncParts } from '@/sync/sync-refs';
|
||||||
|
import type { BtwSelection } from '@/stores/useBtwStore';
|
||||||
|
|
||||||
type IconComponent = IconName;
|
type IconComponent = IconName;
|
||||||
|
|
||||||
@@ -307,33 +309,36 @@ const formatDate = (value?: string) => {
|
|||||||
return formatReleaseDate(parsedDate);
|
return formatReleaseDate(parsedDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ModelControlsProps {
|
type ModelControlsProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
mobilePanel?: MobileControlsPanel;
|
mobilePanel?: MobileControlsPanel;
|
||||||
onMobilePanelChange?: (panel: MobileControlsPanel) => void;
|
onMobilePanelChange?: (panel: MobileControlsPanel) => void;
|
||||||
}
|
} & ({ selection?: never; sessionId?: never } | { selection: BtwSelection; sessionId: string | null });
|
||||||
|
|
||||||
export const ModelControls: React.FC<ModelControlsProps> = ({
|
export const ModelControls: React.FC<ModelControlsProps> = ({
|
||||||
className,
|
className,
|
||||||
mobilePanel,
|
mobilePanel,
|
||||||
onMobilePanelChange,
|
onMobilePanelChange,
|
||||||
|
selection,
|
||||||
|
sessionId: controlledSessionId,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { isReady, isUnavailable } = useOpenCodeReadiness();
|
const { isReady, isUnavailable } = useOpenCodeReadiness();
|
||||||
const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading');
|
const readinessLabel = isUnavailable ? t('common.unavailable') : t('common.loading');
|
||||||
const providers = useConfigStore((state) => state.providers);
|
const providers = useConfigStore((state) => state.providers);
|
||||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
const currentProviderId = useConfigStore((state) => selection ? selection.model?.providerId ?? '' : state.currentProviderId);
|
||||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
const currentModelId = useConfigStore((state) => selection ? selection.model?.modelId ?? '' : state.currentModelId);
|
||||||
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant);
|
||||||
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection);
|
||||||
// What the picker shows is what the next send carries: an explicit choice
|
// 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
|
// when there is one, "Default" when "Default" was picked, and otherwise the
|
||||||
// inherited effort — showing "Default" while an inherited effort is in
|
// inherited effort — showing "Default" while an inherited effort is in
|
||||||
// force is how a switch away from it looks like it did not stick.
|
// 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
|
? undefined
|
||||||
: currentVariantSelection.override ?? effectiveCurrentVariant;
|
: 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 settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant);
|
||||||
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent);
|
||||||
const setProvider = useConfigStore((state) => state.setProvider);
|
const setProvider = useConfigStore((state) => state.setProvider);
|
||||||
@@ -354,7 +359,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const tracedReadyRef = React.useRef(false);
|
const tracedReadyRef = React.useRef(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (tracedReadyRef.current || !isReady) return;
|
if (selection || tracedReadyRef.current || !isReady) return;
|
||||||
tracedReadyRef.current = true;
|
tracedReadyRef.current = true;
|
||||||
markStartupTrace('ModelControls:ready', {
|
markStartupTrace('ModelControls:ready', {
|
||||||
providers: providers.length,
|
providers: providers.length,
|
||||||
@@ -363,9 +368,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
currentModelId,
|
currentModelId,
|
||||||
currentAgentName,
|
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 getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession);
|
||||||
const sync = useSync();
|
const sync = useSync();
|
||||||
|
|
||||||
@@ -409,7 +415,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
const addRecentModel = useUIStore((state) => state.addRecentModel);
|
||||||
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
|
const addRecentAgent = useUIStore((state) => state.addRecentAgent);
|
||||||
const addRecentEffort = useUIStore((state) => state.addRecentEffort);
|
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 setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen);
|
||||||
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
||||||
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
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)
|
// Use global state for model selector (allows Ctrl+M shortcut)
|
||||||
const agentMenuOpen = isModelSelectorOpen;
|
const agentMenuOpen = isModelSelectorOpen;
|
||||||
const setAgentMenuOpen = setModelSelectorOpen;
|
const setAgentMenuOpen = selection ? setLocalModelSelectorOpen : setModelSelectorOpen;
|
||||||
const openAddProviderSettings = React.useCallback(() => {
|
const openAddProviderSettings = React.useCallback(() => {
|
||||||
setSelectedProvider(ADD_PROVIDER_ID);
|
setSelectedProvider(ADD_PROVIDER_ID);
|
||||||
setSettingsPage('providers');
|
setSettingsPage('providers');
|
||||||
@@ -524,13 +532,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
// Handle agent selector close behavior
|
// Handle agent selector close behavior
|
||||||
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
|
const [agentSearchQuery, setAgentSearchQuery] = React.useState('');
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAgentSelectorOpen) {
|
if (!selection && !isAgentSelectorOpen) {
|
||||||
setAgentSearchQuery('');
|
setAgentSearchQuery('');
|
||||||
if (!isCompact) {
|
if (!isCompact) {
|
||||||
requestAnimationFrame(focusChatInput);
|
requestAnimationFrame(focusChatInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isAgentSelectorOpen, isCompact]);
|
}, [isAgentSelectorOpen, isCompact, selection]);
|
||||||
|
|
||||||
const selectableDesktopAgents = React.useMemo(() => {
|
const selectableDesktopAgents = React.useMemo(() => {
|
||||||
return agents.filter((agent) => isPrimaryMode(agent.mode));
|
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 controlTextSize = isCompact ? 'typography-micro' : 'typography-meta';
|
||||||
const inlineGapClass = sizeVariant === 'mobile' ? 'gap-x-1' : sizeVariant === 'vscode' ? 'gap-x-2' : 'gap-x-3';
|
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 models = Array.isArray(currentProvider?.models) ? currentProvider.models : [];
|
||||||
|
|
||||||
const visibleProviders = React.useMemo(() => {
|
const visibleProviders = React.useMemo(() => {
|
||||||
@@ -623,7 +631,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
// Compute from current model each render to avoid stale variants
|
// Compute from current model each render to avoid stale variants
|
||||||
// in draft/session transitions.
|
// 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 hasVariants = availableVariants.length > 0;
|
||||||
|
|
||||||
const costRows = [
|
const costRows = [
|
||||||
@@ -650,14 +660,22 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
// Skip synthetic subagent-completion nudges — restoring from them resets a
|
// Skip synthetic subagent-completion nudges — restoring from them resets a
|
||||||
// manual model override back to the agent default (issue #2404).
|
// manual model override back to the agent default (issue #2404).
|
||||||
const latestLoadedUserChoice = React.useMemo(() => {
|
const latestLoadedUserChoice = React.useMemo(() => {
|
||||||
|
if (selection) return null;
|
||||||
return findLatestUserModelChoice(
|
return findLatestUserModelChoice(
|
||||||
currentSessionMessagesFromSync,
|
currentSessionMessagesFromSync,
|
||||||
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
|
(messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined),
|
||||||
);
|
);
|
||||||
}, [currentSessionDirectory, currentSessionMessagesFromSync]);
|
}, [currentSessionDirectory, currentSessionMessagesFromSync, selection]);
|
||||||
|
|
||||||
const tryApplyModelSelection = React.useCallback(
|
const tryApplyModelSelection = React.useCallback(
|
||||||
(providerId: string, modelId: string, agentName?: string): ModelApplyResult => {
|
(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) {
|
if (!providerId || !modelId) {
|
||||||
return 'model-missing';
|
return 'model-missing';
|
||||||
}
|
}
|
||||||
@@ -691,7 +709,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
return 'applied';
|
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) => {
|
const getModelVariantOptions = React.useCallback((providerId: string, modelId: string) => {
|
||||||
@@ -739,8 +757,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const effectiveAgentName = uiAgentName || currentAgentName;
|
const effectiveAgentName = uiAgentName || currentAgentName;
|
||||||
if (currentSessionId && effectiveAgentName) {
|
const selectionSessionId = selection ? controlledSessionId : currentSessionId;
|
||||||
const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId);
|
if (selectionSessionId && effectiveAgentName) {
|
||||||
|
const savedVariant = getAgentModelVariantForSession(selectionSessionId, effectiveAgentName, providerId, modelId);
|
||||||
// An explicit "Default" is a choice: it stops the fallbacks below.
|
// An explicit "Default" is a choice: it stops the fallbacks below.
|
||||||
if (savedVariant === null) {
|
if (savedVariant === null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -764,9 +783,12 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
getAgentModelVariantForSession,
|
getAgentModelVariantForSession,
|
||||||
getModelVariantOptions,
|
getModelVariantOptions,
|
||||||
uiAgentName,
|
uiAgentName,
|
||||||
|
controlledSessionId,
|
||||||
|
selection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const resolveLiveAgentName = React.useCallback(() => {
|
const resolveLiveAgentName = React.useCallback(() => {
|
||||||
|
if (selection) return selection.agent;
|
||||||
const liveConfigAgentName = useConfigStore.getState().currentAgentName;
|
const liveConfigAgentName = useConfigStore.getState().currentAgentName;
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
return useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
|
return useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
|
||||||
@@ -775,7 +797,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|| currentAgentName;
|
|| currentAgentName;
|
||||||
}
|
}
|
||||||
return liveConfigAgentName || currentAgentName;
|
return liveConfigAgentName || currentAgentName;
|
||||||
}, [currentAgentName, currentSessionId]);
|
}, [currentAgentName, currentSessionId, selection]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records `variant` as this session's effort for the model, in the same
|
* 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".
|
* user having chosen "Default".
|
||||||
*/
|
*/
|
||||||
const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
|
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);
|
const variantOptions = getModelVariantOptions(providerId, modelId);
|
||||||
if (variantOptions.length === 0) {
|
if (variantOptions.length === 0) {
|
||||||
manualVariantSelectionRef.current = false;
|
manualVariantSelectionRef.current = false;
|
||||||
@@ -814,6 +842,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
saveAgentModelVariantForSession,
|
saveAgentModelVariantForSession,
|
||||||
setCurrentVariant,
|
setCurrentVariant,
|
||||||
setCurrentVariantOverride,
|
setCurrentVariantOverride,
|
||||||
|
controlledSessionId,
|
||||||
|
selection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
addRecentModel(providerId, modelId);
|
if (!selection) addRecentModel(providerId, modelId);
|
||||||
commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName);
|
commitVariantSelectionForModel(providerId, modelId, variant, effectiveAgentName);
|
||||||
return 'applied';
|
return 'applied';
|
||||||
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection]);
|
}, [addRecentModel, commitVariantSelectionForModel, resolveLiveAgentName, tryApplyModelSelection, selection]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!currentSessionId) {
|
if (!currentSessionId) {
|
||||||
@@ -1074,7 +1104,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!contextHydrated) {
|
if (selection || !contextHydrated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
@@ -1127,9 +1157,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
getAgentModelForSession,
|
getAgentModelForSession,
|
||||||
tryApplyModelSelection,
|
tryApplyModelSelection,
|
||||||
contextHydrated,
|
contextHydrated,
|
||||||
|
selection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (selection) return;
|
||||||
if (!contextHydrated || !currentAgentName) {
|
if (!contextHydrated || !currentAgentName) {
|
||||||
manualVariantSelectionRef.current = false;
|
manualVariantSelectionRef.current = false;
|
||||||
setCurrentVariant(undefined);
|
setCurrentVariant(undefined);
|
||||||
@@ -1199,6 +1231,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
setCurrentVariant,
|
setCurrentVariant,
|
||||||
setCurrentVariantOverride,
|
setCurrentVariantOverride,
|
||||||
settingsDefaultVariant,
|
settingsDefaultVariant,
|
||||||
|
selection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -1214,6 +1247,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
|
}, [commitVariantSelectionForModel, currentModelId, currentProviderId]);
|
||||||
|
|
||||||
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
|
const handleAgentChange = React.useCallback((agentName: string, options?: { closeModelSelector?: boolean }) => {
|
||||||
|
if (selection) return;
|
||||||
try {
|
try {
|
||||||
setAgent(agentName);
|
setAgent(agentName);
|
||||||
addRecentAgent(agentName);
|
addRecentAgent(agentName);
|
||||||
@@ -1238,6 +1272,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
saveSessionAgentSelection,
|
saveSessionAgentSelection,
|
||||||
setAgent,
|
setAgent,
|
||||||
setAgentMenuOpen,
|
setAgentMenuOpen,
|
||||||
|
selection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => {
|
const handleCycleAgentFromModelPicker = React.useCallback((direction: 1 | -1) => {
|
||||||
@@ -1249,6 +1284,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}, [agents, currentAgentName, handleAgentChange]);
|
}, [agents, currentAgentName, handleAgentChange]);
|
||||||
|
|
||||||
const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => {
|
const getCycleAgentDirectionFromEvent = React.useCallback((event: KeyboardEvent | React.KeyboardEvent): 1 | -1 | null => {
|
||||||
|
if (selection) return null;
|
||||||
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
|
const cycleAgentBackwardShortcut = cycleAgentShortcut && !cycleAgentShortcut.includes('shift')
|
||||||
? normalizeCombo(`shift+${cycleAgentShortcut}`)
|
? normalizeCombo(`shift+${cycleAgentShortcut}`)
|
||||||
: '';
|
: '';
|
||||||
@@ -1262,7 +1298,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [cycleAgentShortcut]);
|
}, [cycleAgentShortcut, selection]);
|
||||||
|
|
||||||
const handleProviderAndModelChange = (
|
const handleProviderAndModelChange = (
|
||||||
providerId: string,
|
providerId: string,
|
||||||
@@ -1284,7 +1320,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!options?.applyVariant) {
|
if (!selection && !options?.applyVariant) {
|
||||||
// Add to recent models on successful selection.
|
// Add to recent models on successful selection.
|
||||||
addRecentModel(providerId, modelId);
|
addRecentModel(providerId, modelId);
|
||||||
}
|
}
|
||||||
@@ -2184,6 +2220,13 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderModelSelector = () => {
|
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) => {
|
const handleThinkingVariantKey = (e: React.KeyboardEvent, selectedItem: ModelPickerEntry) => {
|
||||||
keyboardOwnsModelSelectionRef.current = true;
|
keyboardOwnsModelSelectionRef.current = true;
|
||||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return false;
|
||||||
@@ -2364,7 +2407,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
collisionAvoidance={{ side: 'none', align: 'shift' }}
|
collisionAvoidance={{ side: 'none', align: 'shift' }}
|
||||||
onKeyDownCapture={handleModelShortcutKeyDownCapture}
|
onKeyDownCapture={handleModelShortcutKeyDownCapture}
|
||||||
>
|
>
|
||||||
<div className="p-1 border-b border-border/40">
|
{!selection && <div className="p-1 border-b border-border/40">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openAddProviderSettings}
|
onClick={openAddProviderSettings}
|
||||||
@@ -2375,9 +2418,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
|
<span className="font-medium text-foreground">{t('chat.modelControls.addNewProvider')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
<ModelPickerList
|
<ModelPickerList
|
||||||
providers={providers as ModelPickerProvider[]}
|
providers={providers}
|
||||||
favoriteModels={favoriteModelsList}
|
favoriteModels={favoriteModelsList}
|
||||||
recentModels={recentModelsList}
|
recentModels={recentModelsList}
|
||||||
modelsMetadata={useConfigStore.getState().modelsMetadata}
|
modelsMetadata={useConfigStore.getState().modelsMetadata}
|
||||||
@@ -2413,7 +2456,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-2 whitespace-nowrap overflow-hidden">
|
<div className="flex items-center gap-x-2 whitespace-nowrap overflow-hidden">
|
||||||
<span>{t('chat.modelControls.keyboardHintNavigate')}</span>
|
<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}
|
{activeHasThinkingVariants ? <span>{t('chat.modelControls.keyboardHintThinking')}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -2616,6 +2659,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveMobilePanel('variant')}
|
onClick={() => setActiveMobilePanel('variant')}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
if (event.pointerType === 'touch') event.preventDefault();
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
'model-controls__variant-trigger flex items-center gap-1.5 transition-opacity min-w-0 focus:outline-none',
|
||||||
buttonHeight,
|
buttonHeight,
|
||||||
@@ -2690,7 +2737,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p className="typography-meta">Thinking: {displayVariant}</p>
|
<p className="typography-meta">{t('chat.modelControls.thinking')}: {displayVariant}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
@@ -2875,6 +2922,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inlineMobileSelection = isMobile && Boolean(selection);
|
||||||
const inlineClassName = cn(
|
const inlineClassName = cn(
|
||||||
'@container/model-controls flex items-center min-w-0',
|
'@container/model-controls flex items-center min-w-0',
|
||||||
// Only force full-width + truncation behaviors on true mobile layouts.
|
// 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={inlineClassName}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
inlineGapClass,
|
||||||
isMobile && 'overflow-hidden'
|
isMobile && 'overflow-hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderVariantSelector()}
|
{!inlineMobileSelection && renderVariantSelector()}
|
||||||
{renderModelSelector()}
|
{renderModelSelector()}
|
||||||
{renderAgentSelector()}
|
{inlineMobileSelection && renderVariantSelector()}
|
||||||
|
{!selection && renderAgentSelector()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{renderMobileModelPanel()}
|
{renderMobileModelPanel()}
|
||||||
{renderMobileVariantPanel()}
|
{renderMobileVariantPanel()}
|
||||||
{renderMobileAgentPanel()}
|
{!selection && renderMobileAgentPanel()}
|
||||||
{renderMobileModelTooltip()}
|
{renderMobileModelTooltip()}
|
||||||
{renderMobileAgentTooltip()}
|
{!selection && renderMobileAgentTooltip()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { act } from 'react';
|
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 { createRoot } from 'react-dom/client';
|
||||||
import { Window } from 'happy-dom';
|
import { Window } from 'happy-dom';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
@@ -122,9 +122,9 @@ type SelectionState = {
|
|||||||
getSessionAgentSelection: () => string | null;
|
getSessionAgentSelection: () => string | null;
|
||||||
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
|
getAgentModelForSession: () => { providerId: string; modelId: string } | null;
|
||||||
getAgentModelVariantForSession: () => VariantChoice;
|
getAgentModelVariantForSession: () => VariantChoice;
|
||||||
saveSessionModelSelection: () => void;
|
saveSessionModelSelection: (sessionId: string, providerId: string, modelId: string) => void;
|
||||||
saveSessionAgentSelection: () => void;
|
saveSessionAgentSelection: () => void;
|
||||||
saveAgentModelForSession: () => void;
|
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void;
|
||||||
saveAgentModelVariantForSession: (
|
saveAgentModelVariantForSession: (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
@@ -206,9 +206,11 @@ mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) }));
|
|||||||
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
|
mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] }));
|
||||||
|
|
||||||
mock.module('@/components/ui/dropdown-menu', () => ({
|
mock.module('@/components/ui/dropdown-menu', () => ({
|
||||||
DropdownMenu: passthrough,
|
DropdownMenu: ({ children, open }: React.PropsWithChildren<{ open?: boolean }>) => <div data-menu-open={open}>{children}</div>,
|
||||||
DropdownMenuContent: passthrough,
|
DropdownMenuContent: passthrough,
|
||||||
DropdownMenuItem: passthrough,
|
DropdownMenuItem: ({ children, onSelect }: React.PropsWithChildren<{ onSelect?: () => void }>) => (
|
||||||
|
<button onClick={onSelect}>{children}</button>
|
||||||
|
),
|
||||||
DropdownMenuLabel: passthrough,
|
DropdownMenuLabel: passthrough,
|
||||||
DropdownMenuSeparator: () => null,
|
DropdownMenuSeparator: () => null,
|
||||||
DropdownMenuTrigger: passthrough,
|
DropdownMenuTrigger: passthrough,
|
||||||
@@ -216,7 +218,9 @@ mock.module('@/components/ui/dropdown-menu', () => ({
|
|||||||
mock.module('@/components/ui/input', () => ({
|
mock.module('@/components/ui/input', () => ({
|
||||||
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
|
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/ProviderLogo', () => ({ ProviderLogo: () => null }));
|
||||||
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
|
mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough }));
|
||||||
mock.module('@/components/ui/tooltip', () => ({
|
mock.module('@/components/ui/tooltip', () => ({
|
||||||
@@ -225,9 +229,13 @@ mock.module('@/components/ui/tooltip', () => ({
|
|||||||
TooltipTrigger: passthrough,
|
TooltipTrigger: passthrough,
|
||||||
}));
|
}));
|
||||||
mock.module('@/components/icon/Icon', () => ({ Icon: () => null }));
|
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/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/useIsTextTruncated', () => ({ useIsTextTruncated: () => false }));
|
||||||
mock.module('@/hooks/useOpenCodeReadiness', () => ({
|
mock.module('@/hooks/useOpenCodeReadiness', () => ({
|
||||||
useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }),
|
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 dom = installDom();
|
||||||
const root = createRoot(dom.container);
|
const root = createRoot(dom.container);
|
||||||
await act(async () => root.render(
|
await act(async () => root.render(
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ModelControls />
|
<ModelControls {...props} />
|
||||||
</I18nProvider>,
|
</I18nProvider>,
|
||||||
));
|
));
|
||||||
return {
|
return {
|
||||||
@@ -327,6 +335,7 @@ describe('ModelControls effort restore', () => {
|
|||||||
overrideWrites.length = 0;
|
overrideWrites.length = 0;
|
||||||
latestUserChoice = null;
|
latestUserChoice = null;
|
||||||
forcePreserveManualOverride = null;
|
forcePreserveManualOverride = null;
|
||||||
|
useUIStore.setState({ isMobile: false, isModelSelectorOpen: false });
|
||||||
useSelectionStore.setState({ savedVariant: undefined });
|
useSelectionStore.setState({ savedVariant: undefined });
|
||||||
useConfigStore.setState({
|
useConfigStore.setState({
|
||||||
currentProviderId: PROVIDER_ID,
|
currentProviderId: PROVIDER_ID,
|
||||||
@@ -341,9 +350,11 @@ describe('ModelControls effort restore', () => {
|
|||||||
|
|
||||||
test('restores the concrete effort the session history carries', async () => {
|
test('restores the concrete effort the session history carries', async () => {
|
||||||
latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' };
|
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 {
|
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).toContain('low');
|
||||||
expect(variantWrites).not.toContain(null);
|
expect(variantWrites).not.toContain(null);
|
||||||
expect(useSelectionStore.getState().savedVariant).toBe('low');
|
expect(useSelectionStore.getState().savedVariant).toBe('low');
|
||||||
@@ -403,4 +414,55 @@ describe('ModelControls effort restore', () => {
|
|||||||
await cleanup();
|
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 React from 'react';
|
||||||
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/button';
|
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
|
* and the app navigates to it), destroy (the fork is deleted; the main
|
||||||
* conversation is never touched).
|
* 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,
|
parentSessionId,
|
||||||
panel,
|
panel,
|
||||||
|
onExit,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
useEscapeToExit(onExit, !panel.collapsed && Boolean(panel.pending || panel.creating || panel.btwSessionId));
|
||||||
|
|
||||||
if (panel.btwSessionId && panel.btwDirectory) {
|
if (panel.btwSessionId && panel.btwDirectory) {
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +57,6 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
|
|||||||
btwSessionId: panel.btwSessionId,
|
btwSessionId: panel.btwSessionId,
|
||||||
directory: panel.btwDirectory,
|
directory: panel.btwDirectory,
|
||||||
}}
|
}}
|
||||||
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
|
|
||||||
boundaryMessageID={panel.boundaryMessageID}
|
boundaryMessageID={panel.boundaryMessageID}
|
||||||
collapsed={panel.collapsed}
|
collapsed={panel.collapsed}
|
||||||
/>
|
/>
|
||||||
@@ -63,7 +65,7 @@ export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState
|
|||||||
|
|
||||||
if (panel.creating) {
|
if (panel.creating) {
|
||||||
return (
|
return (
|
||||||
<BtwFrame title={t('chat.btw.titleFallback')}>
|
<BtwFrame>
|
||||||
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
|
<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" />
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||||
<span>{t('chat.btw.loading')}</span>
|
<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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,21 +184,18 @@ const useBtwSessionData = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
|
/** Composer and popup handlers get first refusal; the owner decides cancel versus collapse. */
|
||||||
const useEscapeToCollapse = (onCollapse: () => void): void => {
|
const useEscapeToExit = (onExit: () => void, enabled: boolean): void => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Escape') return;
|
if (event.key !== 'Escape' || event.defaultPrevented || isIMECompositionEvent(event)) return;
|
||||||
// SAFETY: keydown targets are DOM elements (or null on window).
|
event.preventDefault();
|
||||||
const target = event.target as HTMLElement | null;
|
onExit();
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCollapse();
|
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onCollapse]);
|
}, [enabled, onExit]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,14 +237,14 @@ const useAutoScroll = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BtwFrame: React.FC<{
|
const BtwFrame: React.FC<{
|
||||||
title: string;
|
|
||||||
actions?: React.ReactNode;
|
actions?: React.ReactNode;
|
||||||
onTitleClick?: () => void;
|
onTitleClick?: () => void;
|
||||||
titleClickLabel?: string;
|
titleClickLabel?: string;
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
headerSpinner?: boolean;
|
headerSpinner?: boolean;
|
||||||
|
draftHint?: string;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
|
}> = ({ actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, draftHint, children }) => (
|
||||||
<div
|
<div
|
||||||
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
|
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -245,17 +265,12 @@ const BtwFrame: React.FC<{
|
|||||||
) : (
|
) : (
|
||||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
<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" />
|
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||||
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
|
||||||
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
|
{draftHint ? <span className="typography-ui-label truncate">{draftHint}</span> : null}
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1" />
|
<div className="min-w-0 flex-1" />
|
||||||
@@ -273,23 +288,20 @@ const BtwFrame: React.FC<{
|
|||||||
|
|
||||||
const BtwSheet: React.FC<{
|
const BtwSheet: React.FC<{
|
||||||
sessionRef: BtwSessionRef;
|
sessionRef: BtwSessionRef;
|
||||||
title: string;
|
|
||||||
boundaryMessageID: string | null;
|
boundaryMessageID: string | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
|
}> = ({ sessionRef, boundaryMessageID, collapsed }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const handleDestroy = useBtwDestroy(sessionRef);
|
const handleDestroy = useBtwDestroy(sessionRef);
|
||||||
const setCollapsed = React.useCallback((next: boolean) => {
|
const setCollapsed = React.useCallback((next: boolean) => {
|
||||||
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
|
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
|
||||||
}, [sessionRef.parentSessionId]);
|
}, [sessionRef.parentSessionId]);
|
||||||
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
|
||||||
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
|
|
||||||
const handlePromote = React.useCallback(() => {
|
const handlePromote = React.useCallback(() => {
|
||||||
void promoteBtwSession(sessionRef).catch(() => {
|
void promoteBtwSession(sessionRef).catch(() => {
|
||||||
toast.error(t('chat.btw.toast.promoteFailed'));
|
toast.error(t('chat.btw.toast.promoteFailed'));
|
||||||
});
|
});
|
||||||
}, [sessionRef, t]);
|
}, [sessionRef, t]);
|
||||||
useEscapeToCollapse(handleCollapse);
|
|
||||||
|
|
||||||
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
|
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';
|
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 (
|
return (
|
||||||
<BtwCollapsedStrip
|
<BtwCollapsedStrip
|
||||||
sessionRef={sessionRef}
|
sessionRef={sessionRef}
|
||||||
title={title}
|
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onExpand={handleToggleCollapsed}
|
onExpand={handleToggleCollapsed}
|
||||||
expandLabel={toggleLabel}
|
expandLabel={toggleLabel}
|
||||||
@@ -335,7 +346,6 @@ const BtwSheet: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<BtwExpandedSheet
|
<BtwExpandedSheet
|
||||||
sessionRef={sessionRef}
|
sessionRef={sessionRef}
|
||||||
title={title}
|
|
||||||
boundaryMessageID={boundaryMessageID}
|
boundaryMessageID={boundaryMessageID}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onTitleClick={handleToggleCollapsed}
|
onTitleClick={handleToggleCollapsed}
|
||||||
@@ -351,16 +361,14 @@ const BtwSheet: React.FC<{
|
|||||||
*/
|
*/
|
||||||
const BtwCollapsedStrip: React.FC<{
|
const BtwCollapsedStrip: React.FC<{
|
||||||
sessionRef: BtwSessionRef;
|
sessionRef: BtwSessionRef;
|
||||||
title: string;
|
|
||||||
actions: React.ReactNode;
|
actions: React.ReactNode;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
expandLabel: string;
|
expandLabel: string;
|
||||||
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
|
}> = ({ sessionRef, actions, onExpand, expandLabel }) => {
|
||||||
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
|
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
|
||||||
const isBusy = status.type === 'busy' || status.type === 'retry';
|
const isBusy = status.type === 'busy' || status.type === 'retry';
|
||||||
return (
|
return (
|
||||||
<BtwFrame
|
<BtwFrame
|
||||||
title={title}
|
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onTitleClick={onExpand}
|
onTitleClick={onExpand}
|
||||||
titleClickLabel={expandLabel}
|
titleClickLabel={expandLabel}
|
||||||
@@ -372,12 +380,11 @@ const BtwCollapsedStrip: React.FC<{
|
|||||||
|
|
||||||
const BtwExpandedSheet: React.FC<{
|
const BtwExpandedSheet: React.FC<{
|
||||||
sessionRef: BtwSessionRef;
|
sessionRef: BtwSessionRef;
|
||||||
title: string;
|
|
||||||
boundaryMessageID: string | null;
|
boundaryMessageID: string | null;
|
||||||
actions: React.ReactNode;
|
actions: React.ReactNode;
|
||||||
onTitleClick: () => void;
|
onTitleClick: () => void;
|
||||||
titleClickLabel: string;
|
titleClickLabel: string;
|
||||||
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
|
}> = ({ sessionRef, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
|
||||||
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
|
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
|
||||||
const bodyRef = React.useRef<HTMLDivElement | null>(null);
|
const bodyRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
@@ -395,7 +402,7 @@ const BtwExpandedSheet: React.FC<{
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
|
<BtwFrame actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
|
||||||
<ChatSurfaceProvider mode="peek">
|
<ChatSurfaceProvider mode="peek">
|
||||||
<BtwMessages
|
<BtwMessages
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type BtwPanelState = {
|
|||||||
boundaryMessageID: string | null;
|
boundaryMessageID: string | null;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
|
pending: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,5 +54,6 @@ export function useBtwPanelState(
|
|||||||
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
|
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
|
||||||
collapsed: Boolean(uiState?.collapsed),
|
collapsed: Boolean(uiState?.collapsed),
|
||||||
creating: Boolean(uiState?.creating),
|
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
|
attached context is consumed. Commands that act on session or UI state
|
||||||
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
|
(`/undo`, `/redo`, `/compact`, `/timeline`, `/handoff-review`) take only
|
||||||
their command text and leave comments, files, and linked context attached;
|
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
|
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
|
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
|
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
|
timeline. A prompt present in both collapses to the persisted entry. Global
|
||||||
scope reads the persisted runtime bucket only.
|
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
|
## Mobile
|
||||||
|
|
||||||
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
|
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
|
||||||
|
|||||||
@@ -125,3 +125,12 @@ describe('precedence and disabling', () => {
|
|||||||
expect(at('|')).toBeNull();
|
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 {
|
export interface TriggerContext {
|
||||||
/** Shell mode (`!cmd`) disables every picker. */
|
/** Shell mode (`!cmd`) disables every picker. */
|
||||||
inputMode: 'normal' | 'shell';
|
inputMode: 'normal' | 'shell';
|
||||||
|
mentionsEnabled?: boolean;
|
||||||
/** Whether the change that moved the caret came from a paste. */
|
/** Whether the change that moved the caret came from a paste. */
|
||||||
inputSource?: FileMentionAutocompleteInputSource;
|
inputSource?: FileMentionAutocompleteInputSource;
|
||||||
/** The text that change inserted, when known. */
|
/** The text that change inserted, when known. */
|
||||||
@@ -106,6 +107,7 @@ function matchMention(
|
|||||||
cursorPosition: number,
|
cursorPosition: number,
|
||||||
context: TriggerContext,
|
context: TriggerContext,
|
||||||
): AutocompleteTrigger | null {
|
): AutocompleteTrigger | null {
|
||||||
|
if (context.mentionsEnabled === false) return null;
|
||||||
const query = getFileMentionAutocompleteQuery({
|
const query = getFileMentionAutocompleteQuery({
|
||||||
value,
|
value,
|
||||||
cursorPosition,
|
cursorPosition,
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@ describe('fork composer restoration', () => {
|
|||||||
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
|
expect(readChatDraft(source).text).toBe(persistEnabled ? 'source draft @source.ts' : '');
|
||||||
|
|
||||||
composer.render(source);
|
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' : '');
|
expect(readChatDraft(fork).text).toBe(persistEnabled ? 'replay prompt' : '');
|
||||||
} finally {
|
} finally {
|
||||||
composer.teardown();
|
composer.teardown();
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import React from 'react';
|
|||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
clearChatDraft,
|
||||||
getChatDraftIdentityKey,
|
getChatDraftIdentityKey,
|
||||||
readChatDraft,
|
readChatDraft,
|
||||||
subscribeChatDraftDeletion,
|
subscribeChatDraftDeletion,
|
||||||
writeChatDraft,
|
writeChatDraft,
|
||||||
type ChatDraftIdentity,
|
type ChatDraftIdentity,
|
||||||
|
type ChatDraftSnapshot,
|
||||||
} from '@/lib/chatDraftPersistence';
|
} from '@/lib/chatDraftPersistence';
|
||||||
|
|
||||||
const PERSIST_DEBOUNCE_MS = 500;
|
const PERSIST_DEBOUNCE_MS = 500;
|
||||||
@@ -47,7 +49,7 @@ export interface ComposerDraftOptions {
|
|||||||
confirmedMentionsRef: React.RefObject<Set<string>>;
|
confirmedMentionsRef: React.RefObject<Set<string>>;
|
||||||
/** The draft this composer currently belongs to. */
|
/** The draft this composer currently belongs to. */
|
||||||
identity: ChatDraftIdentity | null;
|
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;
|
persistEnabled: boolean;
|
||||||
/** The draft restored on mount, if any. */
|
/** The draft restored on mount, if any. */
|
||||||
initialDraft: { text: string; identity: ChatDraftIdentity | null };
|
initialDraft: { text: string; identity: ChatDraftIdentity | null };
|
||||||
@@ -63,6 +65,12 @@ export interface ComposerDraftControls {
|
|||||||
* cleared composer must be stored before the send resolves.
|
* cleared composer must be stored before the send resolves.
|
||||||
*/
|
*/
|
||||||
persistNow: (identity: ChatDraftIdentity | null, draft: string) => void;
|
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 {
|
export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftControls {
|
||||||
@@ -82,6 +90,15 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
const skipNextPersistRef = React.useRef(false);
|
const skipNextPersistRef = React.useRef(false);
|
||||||
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
|
const lastPersistedRef = React.useRef<Map<string, string>>(new Map());
|
||||||
const currentIdentityRef = React.useRef<ChatDraftIdentity | null>(initialDraft.identity);
|
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);
|
const pendingComposerRestore = useInputStore((state) => state.pendingComposerRestore);
|
||||||
|
|
||||||
// Callbacks reach the effects through a ref so a caller passing inline
|
// 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;
|
currentIdentityRef.current = identity;
|
||||||
}, [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) => {
|
const persistNow = React.useCallback((target: ChatDraftIdentity | null, draft: string) => {
|
||||||
if (!target) return;
|
if (!persistEnabled || !target) return;
|
||||||
const key = getChatDraftIdentityKey(target);
|
const key = getChatDraftIdentityKey(target);
|
||||||
|
|
||||||
// Only keep confirmed mentions the draft still contains: a mention the
|
// 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);
|
writeChatDraft(target, draft, activeMentions);
|
||||||
lastPersistedRef.current.set(key, signature);
|
lastPersistedRef.current.set(key, signature);
|
||||||
}, [confirmedMentionsRef]);
|
}, [confirmedMentionsRef, persistEnabled]);
|
||||||
|
|
||||||
const clearPending = React.useCallback(() => {
|
const clearPending = React.useCallback(() => {
|
||||||
if (!persistTimerRef.current) return;
|
if (!persistTimerRef.current) return;
|
||||||
@@ -127,8 +149,9 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
if (!initialDraft.text) return;
|
if (!initialDraft.text) return;
|
||||||
|
|
||||||
if (!persistEnabled) {
|
if (!persistEnabled) {
|
||||||
|
messageRef.current = '';
|
||||||
|
confirmedMentionsRef.current = new Set();
|
||||||
setMessage('');
|
setMessage('');
|
||||||
writeChatDraft(initialDraft.identity, '', []);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
|
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
|
||||||
@@ -151,16 +174,18 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
// debounced effect must not immediately write it back out.
|
// debounced effect must not immediately write it back out.
|
||||||
skipNextPersistRef.current = true;
|
skipNextPersistRef.current = true;
|
||||||
|
|
||||||
if (!persistEnabled) {
|
if (!skipOutgoingDraftRef.current && previousKey) {
|
||||||
setMessage('');
|
const outgoing = { text: messageRef.current, confirmedMentions: new Set(confirmedMentionsRef.current) };
|
||||||
confirmedMentionsRef.current = new Set();
|
draftMemoryRef.current.set(previousKey, outgoing);
|
||||||
return;
|
if (persistEnabled) persistNow(previous, outgoing.text);
|
||||||
}
|
}
|
||||||
|
skipOutgoingDraftRef.current = false;
|
||||||
|
|
||||||
persistNow(previous, messageRef.current);
|
const restored = (currentKey && draftMemoryRef.current.get(currentKey))
|
||||||
const restored = readChatDraft(identity);
|
|| (persistEnabled ? readChatDraft(identity) : { text: '', confirmedMentions: new Set<string>() });
|
||||||
|
messageRef.current = restored.text;
|
||||||
setMessage(restored.text);
|
setMessage(restored.text);
|
||||||
confirmedMentionsRef.current = restored.confirmedMentions;
|
confirmedMentionsRef.current = new Set(restored.confirmedMentions);
|
||||||
if (restored.text) {
|
if (restored.text) {
|
||||||
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
|
requestAnimationFrame(() => callbacksRef.current.onDraftRestored?.('saved'));
|
||||||
}
|
}
|
||||||
@@ -197,6 +222,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
const deletedKey = getChatDraftIdentityKey(deleted);
|
const deletedKey = getChatDraftIdentityKey(deleted);
|
||||||
// Record the empty signature so a queued write does not resurrect it.
|
// Record the empty signature so a queued write does not resurrect it.
|
||||||
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
|
lastPersistedRef.current.set(deletedKey, draftSignature('', []));
|
||||||
|
draftMemoryRef.current.set(deletedKey, { text: '', confirmedMentions: new Set() });
|
||||||
|
|
||||||
const current = currentIdentityRef.current;
|
const current = currentIdentityRef.current;
|
||||||
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
|
if (!current || getChatDraftIdentityKey(current) !== deletedKey) return;
|
||||||
@@ -210,11 +236,7 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
|
|
||||||
// Debounced write while typing.
|
// Debounced write while typing.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!persistEnabled) {
|
if (!persistEnabled) return;
|
||||||
clearPending();
|
|
||||||
persistNow(identity, '');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipNextPersistRef.current) {
|
if (skipNextPersistRef.current) {
|
||||||
skipNextPersistRef.current = false;
|
skipNextPersistRef.current = false;
|
||||||
@@ -253,5 +275,62 @@ export function useComposerDraft(options: ComposerDraftOptions): ComposerDraftCo
|
|||||||
};
|
};
|
||||||
}, [clearPending, messageRef, persistEnabled, persistNow]);
|
}, [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 { ComposerAttachmentControls } from './ComposerAttachmentControls';
|
||||||
import { FocusModeButton } from './FocusModeButton';
|
import { FocusModeButton } from './FocusModeButton';
|
||||||
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
|
import { PermissionAutoAcceptButton } from './PermissionAutoAcceptButton';
|
||||||
|
import type { BtwSelection } from '@/stores/useBtwStore';
|
||||||
|
|
||||||
const MemoModelControls = React.memo(ModelControls);
|
const MemoModelControls = React.memo(ModelControls);
|
||||||
const MemoComposerDictation = React.memo(ComposerDictation);
|
const MemoComposerDictation = React.memo(ComposerDictation);
|
||||||
@@ -67,6 +68,9 @@ export interface ComposerFooterProps {
|
|||||||
onDictationInsert: (text: string) => void;
|
onDictationInsert: (text: string) => void;
|
||||||
onDictationInsertAndSend: (text: string) => void;
|
onDictationInsertAndSend: (text: string) => void;
|
||||||
onDictationContentHeightChange: (height: number | null) => void;
|
onDictationContentHeightChange: (height: number | null) => void;
|
||||||
|
isBtw?: boolean;
|
||||||
|
modelSessionId?: string | null;
|
||||||
|
btwSelection: BtwSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComposerFooter(props: ComposerFooterProps) {
|
export function ComposerFooter(props: ComposerFooterProps) {
|
||||||
@@ -108,6 +112,9 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
onDictationInsert,
|
onDictationInsert,
|
||||||
onDictationInsertAndSend,
|
onDictationInsertAndSend,
|
||||||
onDictationContentHeightChange,
|
onDictationContentHeightChange,
|
||||||
|
isBtw = false,
|
||||||
|
modelSessionId,
|
||||||
|
btwSelection,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
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="flex w-full items-center justify-between gap-x-1.5">
|
||||||
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
|
<div className="composer-mobile-actions flex items-center gap-x-2 pl-1">
|
||||||
<ComposerAttachmentControls
|
{!isBtw ? <ComposerAttachmentControls
|
||||||
isVSCode={isVSCode}
|
isVSCode={isVSCode}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
@@ -138,7 +145,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
openLinearPicker={onOpenLinearPicker}
|
openLinearPicker={onOpenLinearPicker}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
onOpenMobileSheet={onOpenAttachSheet}
|
onOpenMobileSheet={onOpenAttachSheet}
|
||||||
/>
|
/> : null}
|
||||||
<PermissionAutoAcceptButton
|
<PermissionAutoAcceptButton
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
@@ -146,18 +153,18 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
permissionAutoAcceptEnabled={permissionAutoAcceptEnabled}
|
||||||
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
|
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
|
||||||
/>
|
/>
|
||||||
<SessionGoalButton
|
{!isBtw ? <SessionGoalButton
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
draftOpen={newSessionDraftOpen}
|
draftOpen={newSessionDraftOpen}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
/>
|
/> : null}
|
||||||
<SessionGoalObjectiveCounter length={messageLength} />
|
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||||
<div className="flex items-center gap-x-1 flex-shrink-0">
|
<div className="flex items-center gap-x-1 flex-shrink-0">
|
||||||
<button
|
{!isBtw ? <button
|
||||||
type="button"
|
type="button"
|
||||||
className={footerIconButtonClass}
|
className={footerIconButtonClass}
|
||||||
// Keep the soft keyboard open (same guard as
|
// Keep the soft keyboard open (same guard as
|
||||||
@@ -176,7 +183,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
aria-label={t('chat.dictation.start')}
|
aria-label={t('chat.dictation.start')}
|
||||||
>
|
>
|
||||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||||
</button>
|
</button> : null}
|
||||||
<ComposerActionButtons
|
<ComposerActionButtons
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
@@ -198,7 +205,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
<div className={cn("flex items-center flex-shrink-0", footerGapClass)}>
|
||||||
<ComposerAttachmentControls
|
{!isBtw ? <ComposerAttachmentControls
|
||||||
isVSCode={isVSCode}
|
isVSCode={isVSCode}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
@@ -208,13 +215,13 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
showLinearPicker={showLinearPicker}
|
showLinearPicker={showLinearPicker}
|
||||||
openLinearPicker={onOpenLinearPicker}
|
openLinearPicker={onOpenLinearPicker}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
/>
|
/> : null}
|
||||||
<FocusModeButton
|
{!isBtw ? <FocusModeButton
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
isExpandedInput={isExpandedInput}
|
isExpandedInput={isExpandedInput}
|
||||||
onToggle={onToggleExpandedInput}
|
onToggle={onToggleExpandedInput}
|
||||||
/>
|
/> : null}
|
||||||
<PermissionAutoAcceptButton
|
<PermissionAutoAcceptButton
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
@@ -223,19 +230,19 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
|
handlePermissionAutoAcceptToggle={onTogglePermissionAutoAccept}
|
||||||
withTooltip
|
withTooltip
|
||||||
/>
|
/>
|
||||||
<SessionGoalButton
|
{!isBtw ? <SessionGoalButton
|
||||||
sessionId={currentSessionId}
|
sessionId={currentSessionId}
|
||||||
directory={directory}
|
directory={directory}
|
||||||
draftOpen={newSessionDraftOpen}
|
draftOpen={newSessionDraftOpen}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
iconSizeClass={iconSizeClass}
|
iconSizeClass={iconSizeClass}
|
||||||
withTooltip
|
withTooltip
|
||||||
/>
|
/> : null}
|
||||||
<SessionGoalObjectiveCounter length={messageLength} />
|
{!isBtw ? <SessionGoalObjectiveCounter length={messageLength} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('flex items-center flex-1 justify-end', footerGapClass, 'md:gap-x-3')}>
|
<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')} />
|
{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')} />}
|
||||||
<MemoComposerDictation
|
{!isBtw ? <MemoComposerDictation
|
||||||
radius={chatInputRadius}
|
radius={chatInputRadius}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
@@ -245,7 +252,7 @@ export function ComposerFooter(props: ComposerFooterProps) {
|
|||||||
onInsert={onDictationInsert}
|
onInsert={onDictationInsert}
|
||||||
onInsertAndSend={onDictationInsertAndSend}
|
onInsertAndSend={onDictationInsertAndSend}
|
||||||
onContentHeightChange={onDictationContentHeightChange}
|
onContentHeightChange={onDictationContentHeightChange}
|
||||||
/>
|
/> : null}
|
||||||
<ComposerActionButtons
|
<ComposerActionButtons
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
footerIconButtonClass={footerIconButtonClass}
|
footerIconButtonClass={footerIconButtonClass}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||||
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||||
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
|
||||||
|
const requestBtwComposer = useInputStore((state) => state.requestBtwComposer);
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||||
@@ -469,6 +470,19 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
addMarkdownToChat(selectedTextMarkdown);
|
addMarkdownToChat(selectedTextMarkdown);
|
||||||
}, [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(() => {
|
const handleOpenComment = React.useCallback(() => {
|
||||||
if (!selectedTextMarkdown) return;
|
if (!selectedTextMarkdown) return;
|
||||||
setCommentMode(true);
|
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>
|
<span className="min-w-0 whitespace-normal">{t('chat.textSelection.actions.addToInput')}</span>
|
||||||
</button>
|
</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() ? (
|
{!isVSCodeRuntime() ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToNotes}
|
onClick={handleAddToNotes}
|
||||||
@@ -766,6 +798,26 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
|||||||
{t('chat.textSelection.actions.comment')}
|
{t('chat.textSelection.actions.comment')}
|
||||||
</button>
|
</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() ? (
|
{!isVSCodeRuntime() ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export function hasOpenDropdown(root: ParentNode = document): boolean {
|
|||||||
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
|
return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasActiveBtwComposer(root: ParentNode = document): boolean {
|
||||||
|
return Boolean(root.querySelector('[data-btw-composer="true"]'));
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldStopDropdownImeEscape(
|
export function shouldStopDropdownImeEscape(
|
||||||
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
|
event: Pick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
|
||||||
dropdownOpen: boolean,
|
dropdownOpen: boolean,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
invokeActiveSelectionAddToChat,
|
invokeActiveSelectionAddToChat,
|
||||||
} from '@/lib/addSelectionToChat';
|
} from '@/lib/addSelectionToChat';
|
||||||
import { isIMECompositionEvent } from '@/lib/ime';
|
import { isIMECompositionEvent } from '@/lib/ime';
|
||||||
import { hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
|
import { hasActiveBtwComposer, hasOpenDropdown, isEditableEventTarget, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom';
|
||||||
|
|
||||||
const dropdownTargetSelector = [
|
const dropdownTargetSelector = [
|
||||||
'[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]',
|
'[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]',
|
||||||
@@ -227,6 +227,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
focusChatInput();
|
focusChatInput();
|
||||||
},
|
},
|
||||||
cycle_agent: (event) => {
|
cycle_agent: (event) => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
const hasOverlay = state.isSettingsDialogOpen
|
const hasOverlay = state.isSettingsDialogOpen
|
||||||
|| state.isCommandPaletteOpen
|
|| state.isCommandPaletteOpen
|
||||||
@@ -258,6 +259,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
return toggleTerminalSurfaceExpanded();
|
return toggleTerminalSurfaceExpanded();
|
||||||
},
|
},
|
||||||
open_model_selector: () => {
|
open_model_selector: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
const hasOverlay = state.isCommandPaletteOpen
|
const hasOverlay = state.isCommandPaletteOpen
|
||||||
|| state.isHelpDialogOpen
|
|| state.isHelpDialogOpen
|
||||||
@@ -267,6 +269,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
state.setModelSelectorOpen(!state.isModelSelectorOpen);
|
state.setModelSelectorOpen(!state.isModelSelectorOpen);
|
||||||
},
|
},
|
||||||
cycle_thinking_variant: () => {
|
cycle_thinking_variant: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
const hasOverlay = state.isCommandPaletteOpen
|
const hasOverlay = state.isCommandPaletteOpen
|
||||||
|| state.isHelpDialogOpen
|
|| state.isHelpDialogOpen
|
||||||
@@ -291,10 +294,12 @@ export const useKeyboardShortcuts = () => {
|
|||||||
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
|
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
|
||||||
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
|
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
|
||||||
expand_input: () => {
|
expand_input: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
if (useUIStore.getState().isMobile) return false;
|
if (useUIStore.getState().isMobile) return false;
|
||||||
useUIStore.getState().toggleExpandedInput();
|
useUIStore.getState().toggleExpandedInput();
|
||||||
},
|
},
|
||||||
toggle_dictation: () => {
|
toggle_dictation: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
if (
|
if (
|
||||||
state.isCommandPaletteOpen
|
state.isCommandPaletteOpen
|
||||||
@@ -313,6 +318,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function cycleFavoriteModel(delta: number): boolean | void {
|
function cycleFavoriteModel(delta: number): boolean | void {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const state = useUIStore.getState();
|
const state = useUIStore.getState();
|
||||||
const hasOverlay = state.isCommandPaletteOpen
|
const hasOverlay = state.isCommandPaletteOpen
|
||||||
|| state.isHelpDialogOpen
|
|| state.isHelpDialogOpen
|
||||||
@@ -400,6 +406,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
target?.closest('[role="dialog"]')
|
target?.closest('[role="dialog"]')
|
||||||
|
|| target?.closest('[data-btw-composer="true"]')
|
||||||
|| isTerminalEventTarget(target)
|
|| isTerminalEventTarget(target)
|
||||||
|| dropdownOpen
|
|| dropdownOpen
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useKeybinds } from './useKeybind';
|
import { useKeybinds } from './useKeybind';
|
||||||
import { isEditableEventTarget } from './keyboard-shortcut-dom';
|
import { hasActiveBtwComposer, isEditableEventTarget } from './keyboard-shortcut-dom';
|
||||||
|
|
||||||
export const useMiniChatKeyboardShortcuts = () => {
|
export const useMiniChatKeyboardShortcuts = () => {
|
||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
@@ -25,6 +25,7 @@ export const useMiniChatKeyboardShortcuts = () => {
|
|||||||
const dispatcher = dispatcherRef.current;
|
const dispatcher = dispatcherRef.current;
|
||||||
|
|
||||||
const cycleFavoriteModel = (delta: number): boolean | void => {
|
const cycleFavoriteModel = (delta: number): boolean | void => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const { favoriteModels, addRecentModel } = useUIStore.getState();
|
const { favoriteModels, addRecentModel } = useUIStore.getState();
|
||||||
if (favoriteModels.length === 0) return false;
|
if (favoriteModels.length === 0) return false;
|
||||||
|
|
||||||
@@ -64,10 +65,12 @@ export const useMiniChatKeyboardShortcuts = () => {
|
|||||||
focusChatInput();
|
focusChatInput();
|
||||||
},
|
},
|
||||||
open_model_selector: () => {
|
open_model_selector: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
|
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
|
||||||
setModelSelectorOpen(!isModelSelectorOpen);
|
setModelSelectorOpen(!isModelSelectorOpen);
|
||||||
},
|
},
|
||||||
cycle_thinking_variant: () => {
|
cycle_thinking_variant: () => {
|
||||||
|
if (hasActiveBtwComposer()) return false;
|
||||||
const configState = useConfigStore.getState();
|
const configState = useConfigStore.getState();
|
||||||
if (configState.getCurrentModelVariants().length === 0) return false;
|
if (configState.getCurrentModelVariants().length === 0) return false;
|
||||||
|
|
||||||
|
|||||||
+130
-15
@@ -18,13 +18,16 @@ const childStoreSessions: Session[] = [];
|
|||||||
const currentSessionSwitches: string[] = [];
|
const currentSessionSwitches: string[] = [];
|
||||||
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
|
const metadataPatches: Array<{ sessionId: string; result: Record<string, unknown> }> = [];
|
||||||
const parentSyncMessages: Message[] = [];
|
const parentSyncMessages: Message[] = [];
|
||||||
|
const sessionMessageReads: string[] = [];
|
||||||
|
|
||||||
mock.module('@/lib/opencode/client', () => ({
|
mock.module('@/lib/opencode/client', () => ({
|
||||||
opencodeClient: {
|
opencodeClient: {
|
||||||
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
|
forkSession: (sessionId: string, messageId?: string, directory?: string | null) =>
|
||||||
forkSessionImpl(sessionId, messageId, directory),
|
forkSessionImpl(sessionId, messageId, directory),
|
||||||
getSessionMessages: (id: string, limit?: number, directory?: string | null) =>
|
getSessionMessages: (id: string, limit?: number, directory?: string | null) => {
|
||||||
getSessionMessagesImpl(id, limit, directory),
|
sessionMessageReads.push(id);
|
||||||
|
return getSessionMessagesImpl(id, limit, directory);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
mock.module('@/sync/session-actions', () => ({
|
mock.module('@/sync/session-actions', () => ({
|
||||||
@@ -59,9 +62,10 @@ mock.module('@/sync/sync-refs', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
|
const { preparePendingBtwSend, btwSessionTitle, startBtwSession, destroyBtwSession, promoteBtwSession, filterBtwTailMessages, findLastCompletedAssistantMessageID, BTW_BOUNDARY_INSTRUCTION, BTW_PROMOTION_NOTICE, buildBtwSyntheticTexts } =
|
||||||
await import('@/lib/btw');
|
await import('@/lib/btw');
|
||||||
const { useBtwStore } = await import('@/stores/useBtwStore');
|
const { useBtwStore } = await import('@/stores/useBtwStore');
|
||||||
|
const { useSelectionStore } = await import('@/sync/selection-store');
|
||||||
|
|
||||||
const makeSession = (id: string, directory?: string): Session => ({
|
const makeSession = (id: string, directory?: string): Session => ({
|
||||||
id,
|
id,
|
||||||
@@ -72,8 +76,8 @@ const makeSession = (id: string, directory?: string): Session => ({
|
|||||||
version: 1,
|
version: 1,
|
||||||
}) as unknown as Session;
|
}) as unknown as Session;
|
||||||
|
|
||||||
const record = (id: string): { info: Message; parts: Part[] } => ({
|
const record = (id: string, created = 1): { info: Message; parts: Part[] } => ({
|
||||||
info: { id, role: 'user', time: { created: 1 } } as unknown as Message,
|
info: { id, sessionID: 'fork-1', role: 'user', time: { created }, agent: 'plan', model: { providerID: 'provider', modelID: 'model' } },
|
||||||
parts: [],
|
parts: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -103,6 +107,7 @@ beforeEach(() => {
|
|||||||
currentSessionSwitches.length = 0;
|
currentSessionSwitches.length = 0;
|
||||||
metadataPatches.length = 0;
|
metadataPatches.length = 0;
|
||||||
parentSyncMessages.length = 0;
|
parentSyncMessages.length = 0;
|
||||||
|
sessionMessageReads.length = 0;
|
||||||
useBtwStore.setState({ byParent: {} });
|
useBtwStore.setState({ byParent: {} });
|
||||||
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
|
forkSessionImpl = () => Promise.reject(new Error('no forkSession stub'));
|
||||||
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
|
getSessionMessagesImpl = () => Promise.resolve([record('msg-boundary')]);
|
||||||
@@ -123,6 +128,17 @@ describe('btwSessionTitle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('filterBtwTailMessages', () => {
|
describe('filterBtwTailMessages', () => {
|
||||||
|
test('keeps a newer user message whose ID sorts before the inherited boundary', () => {
|
||||||
|
const records = [record('msg_f001', 1), record('msg_0001', 2), record('msg_f002', 3)];
|
||||||
|
expect(filterBtwTailMessages(records, 'msg_f001').map((entry) => entry.info.id))
|
||||||
|
.toEqual(['msg_0001', 'msg_f002']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a loaded tail when its inherited boundary is outside the retained page', () => {
|
||||||
|
const records = [record('msg_0001', 2), record('msg_f002', 3)];
|
||||||
|
expect(filterBtwTailMessages(records, 'msg_f001')).toEqual(records);
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps only messages after the boundary id', () => {
|
test('keeps only messages after the boundary id', () => {
|
||||||
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
|
const records = [record('msg-1'), record('msg-2'), record('msg-3')];
|
||||||
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
|
expect(filterBtwTailMessages(records, 'msg-2').map((r) => r.info.id)).toEqual(['msg-3']);
|
||||||
@@ -155,14 +171,16 @@ describe('startBtwSession', () => {
|
|||||||
let sentText: unknown = null;
|
let sentText: unknown = null;
|
||||||
let sentOptions: unknown = null;
|
let sentOptions: unknown = null;
|
||||||
sendMessageImpl = (...args) => {
|
sendMessageImpl = (...args) => {
|
||||||
sentText = args[0];
|
sentText = args[0];
|
||||||
sentOptions = args[9];
|
sentOptions = args[9];
|
||||||
return Promise.resolve();
|
expect(args[7]).toBe(undefined);
|
||||||
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = await startBtwSession(startInput);
|
const session = await startBtwSession({ ...startInput, variant: null });
|
||||||
|
|
||||||
expect(session.id).toBe('fork-1');
|
expect(session.id).toBe('fork-1');
|
||||||
|
expect(useSelectionStore.getState().getAgentModelVariantForSession('fork-1', 'build', 'provider', 'model')).toBeNull();
|
||||||
expect(registeredDirectories).toEqual(['fork-1:/project']);
|
expect(registeredDirectories).toEqual(['fork-1:/project']);
|
||||||
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
|
expect(childStoreSessions.map((s) => s.id)).toEqual(['fork-1']);
|
||||||
expect(sentText).toBe('wtf is kafka');
|
expect(sentText).toBe('wtf is kafka');
|
||||||
@@ -172,7 +190,7 @@ describe('startBtwSession', () => {
|
|||||||
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
|
{ sessionId: 'parent-1', result: { openchamber: { btwSessionID: 'fork-1' } } },
|
||||||
]);
|
]);
|
||||||
// Transient creating flag is cleared once the flow settles.
|
// Transient creating flag is cleared once the flow settles.
|
||||||
expect(useBtwStore.getState().byParent).toEqual({});
|
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
|
test('forks at the last completed assistant turn, not at the in-flight one', async () => {
|
||||||
@@ -265,7 +283,7 @@ describe('startBtwSession', () => {
|
|||||||
// marker, link, then unlink rollback
|
// marker, link, then unlink rollback
|
||||||
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
|
expect(metadataPatches.map((p) => p.sessionId)).toEqual(['fork-1', 'parent-1', 'parent-1']);
|
||||||
expect(metadataPatches[2]?.result).toEqual({});
|
expect(metadataPatches[2]?.result).toEqual({});
|
||||||
expect(useBtwStore.getState().byParent).toEqual({});
|
expect(useBtwStore.getState().byParent).toEqual({ 'parent-1': { creating: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed boundary fetch deletes the fork', async () => {
|
test('a failed boundary fetch deletes the fork', async () => {
|
||||||
@@ -278,6 +296,22 @@ describe('startBtwSession', () => {
|
|||||||
expect(deleted).toEqual(['fork-1']);
|
expect(deleted).toEqual(['fork-1']);
|
||||||
expect(metadataPatches).toEqual([]);
|
expect(metadataPatches).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects a second creation for the same parent before it forks', async () => {
|
||||||
|
let releaseFork: ((session: Session) => void) | undefined;
|
||||||
|
const forkStarted = new Promise<void>((resolve) => {
|
||||||
|
forkSessionImpl = () => {
|
||||||
|
resolve();
|
||||||
|
return new Promise((release) => { releaseFork = release; });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = startBtwSession(startInput);
|
||||||
|
await forkStarted;
|
||||||
|
await expect(startBtwSession(startInput)).rejects.toThrow('btw session creation already in progress');
|
||||||
|
releaseFork?.(makeSession('fork-1', '/project'));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('destroyBtwSession', () => {
|
describe('destroyBtwSession', () => {
|
||||||
@@ -310,7 +344,12 @@ describe('destroyBtwSession', () => {
|
|||||||
describe('promoteBtwSession', () => {
|
describe('promoteBtwSession', () => {
|
||||||
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
|
const ref = { parentSessionId: 'parent-1', btwSessionId: 'fork-1', directory: '/project' };
|
||||||
|
|
||||||
test('unlinks the parent, strips the marker, and navigates to the fork', async () => {
|
test('unlinks the parent, strips the marker, and navigates to the fork without generating a title', async () => {
|
||||||
|
const renamedTitles: string[] = [];
|
||||||
|
updateSessionTitleImpl = (_sessionId, title) => {
|
||||||
|
renamedTitles.push(title);
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
|
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
|
||||||
const base = sessionId === 'fork-1'
|
const base = sessionId === 'fork-1'
|
||||||
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
|
? { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } }
|
||||||
@@ -323,19 +362,43 @@ describe('promoteBtwSession', () => {
|
|||||||
await promoteBtwSession(ref);
|
await promoteBtwSession(ref);
|
||||||
|
|
||||||
expect(metadataPatches).toEqual([
|
expect(metadataPatches).toEqual([
|
||||||
{ sessionId: 'parent-1', result: {} },
|
|
||||||
// The fork stops being a btw session but stays marked as promoted: its
|
// The fork stops being a btw session but stays marked as promoted: its
|
||||||
// transcript still carries the boundary instructions.
|
// transcript still carries the boundary instructions.
|
||||||
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
|
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
|
||||||
|
{ sessionId: 'parent-1', result: {} },
|
||||||
]);
|
]);
|
||||||
expect(currentSessionSwitches).toEqual(['fork-1']);
|
expect(currentSessionSwitches).toEqual(['fork-1']);
|
||||||
|
expect(sessionMessageReads).toEqual([]);
|
||||||
|
expect(renamedTitles).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a failed unlink aborts the promote without navigating', async () => {
|
test('a failed unlink aborts the promote without navigating', async () => {
|
||||||
patchSessionMetadataImpl = () => Promise.reject(new Error('patch failed'));
|
const originalMetadata = { openchamber: { kind: 'btw', originalSessionID: 'parent-1', btwBoundaryMessageID: 'msg-1' } };
|
||||||
await expect(promoteBtwSession(ref)).rejects.toThrow('patch failed');
|
patchSessionMetadataImpl = (sessionId, _directory, updater) => {
|
||||||
|
if (sessionId === 'parent-1') return Promise.reject(new Error('unlink failed'));
|
||||||
|
const result = updater(originalMetadata);
|
||||||
|
metadataPatches.push({ sessionId, result });
|
||||||
|
return Promise.resolve(makeSession(sessionId));
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(promoteBtwSession(ref)).rejects.toThrow('unlink failed');
|
||||||
|
expect(currentSessionSwitches).toEqual([]);
|
||||||
|
expect(metadataPatches).toEqual([
|
||||||
|
{ sessionId: 'fork-1', result: { openchamber: { btwPromoted: true } } },
|
||||||
|
{ sessionId: 'fork-1', result: originalMetadata },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed marker removal preserves the parent link', async () => {
|
||||||
|
patchSessionMetadataImpl = (sessionId) => {
|
||||||
|
if (sessionId === 'fork-1') return Promise.reject(new Error('marker failed'));
|
||||||
|
throw new Error('the parent must remain linked');
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(promoteBtwSession(ref)).rejects.toThrow('marker failed');
|
||||||
expect(currentSessionSwitches).toEqual([]);
|
expect(currentSessionSwitches).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildBtwSyntheticTexts', () => {
|
describe('buildBtwSyntheticTexts', () => {
|
||||||
@@ -358,3 +421,55 @@ describe('buildBtwSyntheticTexts', () => {
|
|||||||
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
|
expect(buildBtwSyntheticTexts({ isBtwActive: false, isPromotedBtwSession: false })).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('pending BTW preparation', () => {
|
||||||
|
test('cancelling and reopening during snippet expansion cannot revive the old send', async () => {
|
||||||
|
const { getRuntimeKey } = await import('@/lib/runtime-switch');
|
||||||
|
const panels = useBtwStore.getState();
|
||||||
|
panels.setPanelState('parent-1', { pending: true });
|
||||||
|
let finish = () => {};
|
||||||
|
const expansion = new Promise<void>((resolve) => { finish = resolve; });
|
||||||
|
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
|
||||||
|
panels.clearPanelState('parent-1');
|
||||||
|
panels.setPanelState('parent-1', { pending: true });
|
||||||
|
finish();
|
||||||
|
expect(await preparing).toBeNull();
|
||||||
|
expect(useBtwStore.getState().byParent['parent-1']).toEqual({ pending: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preparation belongs to its parent and rejects duplicate sends', async () => {
|
||||||
|
const { getRuntimeKey } = await import('@/lib/runtime-switch');
|
||||||
|
const panels = useBtwStore.getState();
|
||||||
|
panels.setPanelState('parent-1', { pending: true });
|
||||||
|
panels.setPanelState('parent-2', { pending: true });
|
||||||
|
let finish = () => {};
|
||||||
|
const expansion = new Promise<void>((resolve) => { finish = resolve; });
|
||||||
|
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
|
||||||
|
expect(await preparePendingBtwSend('parent-1', getRuntimeKey(), async () => {})).toBeNull();
|
||||||
|
panels.clearPanelState('parent-2');
|
||||||
|
finish();
|
||||||
|
expect(await preparing).toBe(useBtwStore.getState().byParent['parent-1']?.pendingSend);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stale composer cannot fork on the newly selected runtime', async () => {
|
||||||
|
await expect(startBtwSession({ ...startInput, expectedRuntimeKey: 'obsolete-runtime' }))
|
||||||
|
.rejects.toThrow('runtime changed');
|
||||||
|
expect(useBtwStore.getState().byParent).toEqual({});
|
||||||
|
expect(registeredDirectories).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('switching runtime during snippet expansion invalidates preparation', async () => {
|
||||||
|
const { getRuntimeKey, initializeRuntimeEndpoint } = await import('@/lib/runtime-switch');
|
||||||
|
const panels = useBtwStore.getState();
|
||||||
|
panels.setPanelState('parent-1', { pending: true });
|
||||||
|
let finish = () => {};
|
||||||
|
const expansion = new Promise<void>((resolve) => { finish = resolve; });
|
||||||
|
const preparing = preparePendingBtwSend('parent-1', getRuntimeKey(), () => expansion);
|
||||||
|
initializeRuntimeEndpoint({ apiBaseUrl: 'https://btw-test.invalid', runtimeKey: 'changed-during-preparation' });
|
||||||
|
finish();
|
||||||
|
expect(await preparing).toBeNull();
|
||||||
|
expect(useBtwStore.getState().byParent).toEqual({});
|
||||||
|
});
|
||||||
|
|||||||
+89
-23
@@ -4,10 +4,12 @@ import * as sessionActions from '@/sync/session-actions';
|
|||||||
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
|
import { withBtwSessionLink, withBtwSessionMarker, withoutBtwSessionLink, withoutBtwSessionMarker } from '@/lib/sessionBtwMetadata';
|
||||||
import { useBtwStore } from '@/stores/useBtwStore';
|
import { useBtwStore } from '@/stores/useBtwStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
|
import { getSyncChildStores, getSyncMessages, registerSessionDirectory } from '@/sync/sync-refs';
|
||||||
import { Binary } from '@/sync/binary';
|
import { Binary } from '@/sync/binary';
|
||||||
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
import type { ContextPartMetadata } from '@/lib/messages/contextParts';
|
||||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||||
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `/btw <question>`: fork the main session into a temporary session and send
|
* `/btw <question>`: fork the main session into a temporary session and send
|
||||||
@@ -24,12 +26,14 @@ import type { AttachedFile } from '@/stores/types/sessionTypes';
|
|||||||
*/
|
*/
|
||||||
export type StartBtwInput = {
|
export type StartBtwInput = {
|
||||||
parentSessionId: string;
|
parentSessionId: string;
|
||||||
|
expectedRuntimeKey?: string;
|
||||||
question: string;
|
question: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
providerID: string;
|
providerID: string;
|
||||||
modelID: string;
|
modelID: string;
|
||||||
agent?: string;
|
agent?: string;
|
||||||
variant?: string;
|
variant?: string | null;
|
||||||
|
permissionAutoAccept?: boolean;
|
||||||
attachments?: AttachedFile[];
|
attachments?: AttachedFile[];
|
||||||
additionalParts?: Array<{
|
additionalParts?: Array<{
|
||||||
text: string;
|
text: string;
|
||||||
@@ -144,11 +148,45 @@ function insertForkIntoDirectoryStore(session: Session, directory: string): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preparation can be discarded until the server-side fork starts. */
|
||||||
|
export async function preparePendingBtwSend(
|
||||||
|
parentSessionId: string,
|
||||||
|
expectedRuntimeKey: string,
|
||||||
|
prepare: () => Promise<void>,
|
||||||
|
): Promise<symbol | null> {
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) return null;
|
||||||
|
const panels = useBtwStore.getState();
|
||||||
|
const owner = panels.byParent[parentSessionId];
|
||||||
|
if (!owner?.pending || owner.creating || owner.pendingSend) return null;
|
||||||
|
const token = Symbol('btw-send');
|
||||||
|
panels.setPanelState(parentSessionId, { pendingSend: token });
|
||||||
|
try {
|
||||||
|
await prepare();
|
||||||
|
} catch (error) {
|
||||||
|
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend === token) {
|
||||||
|
panels.setPanelState(parentSessionId, { pendingSend: undefined });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (useBtwStore.getState().byParent[parentSessionId]?.pendingSend !== token) return null;
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) {
|
||||||
|
panels.clearPanelState(parentSessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
||||||
const { setPanelState, clearPanelState } = useBtwStore.getState();
|
const { setPanelState } = useBtwStore.getState();
|
||||||
|
if (useBtwStore.getState().byParent[input.parentSessionId]?.creating) {
|
||||||
|
throw new Error('btw session creation already in progress');
|
||||||
|
}
|
||||||
|
const expectedRuntimeKey = input.expectedRuntimeKey ?? getRuntimeKey();
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
|
||||||
setPanelState(input.parentSessionId, { creating: true });
|
setPanelState(input.parentSessionId, { creating: true });
|
||||||
try {
|
try {
|
||||||
await sessionActions.waitForConnectionOrThrow();
|
await sessionActions.waitForConnectionOrThrow();
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
|
||||||
// Fork at the parent's last completed assistant turn rather than at HEAD,
|
// Fork at the parent's last completed assistant turn rather than at HEAD,
|
||||||
// so a `/btw` typed mid-turn does not inherit a half-finished one.
|
// so a `/btw` typed mid-turn does not inherit a half-finished one.
|
||||||
const forkPointMessageID = findLastCompletedAssistantMessageID(
|
const forkPointMessageID = findLastCompletedAssistantMessageID(
|
||||||
@@ -165,18 +203,28 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
|||||||
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
// SAFETY: the SDK Session type omits the server's `directory` field; this
|
||||||
// widening only reads it, with the requested directory as the fallback.
|
// widening only reads it, with the requested directory as the fallback.
|
||||||
const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory;
|
const sessionDirectory = (forked as Session & { directory?: string | null }).directory ?? input.directory;
|
||||||
registerSessionDirectory(forked.id, sessionDirectory);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The boundary between inherited history and the fork's own tail is the
|
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
|
||||||
// id of the newest cloned message. Message ids are server-generated and
|
registerSessionDirectory(forked.id, sessionDirectory);
|
||||||
// ascending, so everything the fork produces sorts after it.
|
const selections = useSelectionStore.getState();
|
||||||
|
selections.saveSessionModelSelection(forked.id, input.providerID, input.modelID);
|
||||||
|
if (input.agent) {
|
||||||
|
selections.saveSessionAgentSelection(forked.id, input.agent);
|
||||||
|
selections.saveAgentModelForSession(forked.id, input.agent, input.providerID, input.modelID);
|
||||||
|
selections.saveAgentModelVariantForSession(forked.id, input.agent, input.providerID, input.modelID, input.variant);
|
||||||
|
}
|
||||||
|
if (input.permissionAutoAccept !== undefined) {
|
||||||
|
const { usePermissionStore } = await import('@/stores/permissionStore');
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
|
||||||
|
await usePermissionStore.getState().setSessionAutoAccept(forked.id, input.permissionAutoAccept);
|
||||||
|
if (getRuntimeKey() !== expectedRuntimeKey) throw new Error('runtime changed');
|
||||||
|
}
|
||||||
|
// Locate the inherited-history boundary by identity, not by ID ordering.
|
||||||
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
|
const newestCloned = await opencodeClient.getSessionMessages(forked.id, 1, sessionDirectory);
|
||||||
// A `null` boundary makes the panel show every inherited message, so an
|
// A `null` boundary makes the panel show every inherited message, so an
|
||||||
// empty read must not be taken as "the fork inherited nothing" when we
|
// empty read must not be taken as "the fork inherited nothing" when we
|
||||||
// know it did: having picked a fork point proves the parent had turns.
|
// know it did: having picked a fork point proves the parent had turns.
|
||||||
// Fall back to that id — the fork's own messages are created later and
|
// Retain the known fork point as a fallback marker.
|
||||||
// still sort after it, so the tail stays complete either way.
|
|
||||||
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id
|
const boundaryMessageID = newestCloned[newestCloned.length - 1]?.info.id
|
||||||
?? forkPointMessageID
|
?? forkPointMessageID
|
||||||
?? null;
|
?? null;
|
||||||
@@ -188,16 +236,19 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
|||||||
// forks are hidden from session lists by this marker, so inserting an
|
// forks are hidden from session lists by this marker, so inserting an
|
||||||
// unmarked fork first would flash it in the sidebar.
|
// unmarked fork first would flash it in the sidebar.
|
||||||
const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) =>
|
const marked = await sessionActions.patchSessionMetadata(forked.id, sessionDirectory, (metadata) =>
|
||||||
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID));
|
withBtwSessionMarker(metadata, input.parentSessionId, boundaryMessageID), expectedRuntimeKey);
|
||||||
// patchSessionMetadata already upserted the marked fork into the global
|
// patchSessionMetadata already upserted the marked fork into the global
|
||||||
// store; the directory child store still needs the explicit insert.
|
// store; the directory child store still needs the explicit insert.
|
||||||
insertForkIntoDirectoryStore(marked, sessionDirectory);
|
insertForkIntoDirectoryStore(marked, sessionDirectory);
|
||||||
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question)).catch(() => undefined);
|
void sessionActions.updateSessionTitle(forked.id, btwSessionTitle(input.question), {
|
||||||
|
directory: sessionDirectory,
|
||||||
|
expectedRuntimeKey,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
// Link the parent before sending so the panel opens as soon as the
|
// Link the parent before sending so the panel opens as soon as the
|
||||||
// metadata lands; the question streams into it.
|
// metadata lands; the question streams into it.
|
||||||
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
||||||
withBtwSessionLink(metadata, forked.id));
|
withBtwSessionLink(metadata, forked.id), expectedRuntimeKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await useSessionUIStore.getState().sendMessage(
|
await useSessionUIStore.getState().sendMessage(
|
||||||
@@ -211,7 +262,7 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
|||||||
// its most dangerous here, with the parent's in-flight plan as the
|
// its most dangerous here, with the parent's in-flight plan as the
|
||||||
// newest thing in its context.
|
// newest thing in its context.
|
||||||
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
|
[...btwBoundaryParts(), ...(input.additionalParts ?? [])],
|
||||||
input.variant,
|
input.variant ?? undefined,
|
||||||
'normal',
|
'normal',
|
||||||
{ sessionId: forked.id, directory: sessionDirectory },
|
{ sessionId: forked.id, directory: sessionDirectory },
|
||||||
);
|
);
|
||||||
@@ -219,29 +270,31 @@ export async function startBtwSession(input: StartBtwInput): Promise<Session> {
|
|||||||
// A fork without its first question is not a usable btw session:
|
// A fork without its first question is not a usable btw session:
|
||||||
// unlink the parent again before deleting the fork.
|
// unlink the parent again before deleting the fork.
|
||||||
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
await sessionActions.patchSessionMetadata(input.parentSessionId, input.directory, (metadata) =>
|
||||||
withoutBtwSessionLink(metadata, forked.id)).catch(() => undefined);
|
withoutBtwSessionLink(metadata, forked.id), expectedRuntimeKey).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await sessionActions.deleteSession(forked.id).catch(() => undefined);
|
await sessionActions.deleteSession(forked.id, { expectedRuntimeKey }).catch(() => undefined);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return forked;
|
return forked;
|
||||||
} finally {
|
} finally {
|
||||||
clearPanelState(input.parentSessionId);
|
if (getRuntimeKey() === expectedRuntimeKey) setPanelState(input.parentSessionId, { creating: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep only the fork's own tail: messages after the last message cloned from
|
* Records are a chronologically ordered suffix of the session. Keep everything
|
||||||
* the parent. A `null` boundary means the fork inherited nothing.
|
* after the inherited-history marker; an absent marker is outside that suffix.
|
||||||
|
* Message IDs are identities, not timestamps (including client-generated IDs).
|
||||||
*/
|
*/
|
||||||
export function filterBtwTailMessages(
|
export function filterBtwTailMessages(
|
||||||
records: Array<{ info: Message; parts: Part[] }>,
|
records: Array<{ info: Message; parts: Part[] }>,
|
||||||
boundaryMessageID: string | null,
|
boundaryMessageID: string | null,
|
||||||
): Array<{ info: Message; parts: Part[] }> {
|
): Array<{ info: Message; parts: Part[] }> {
|
||||||
if (!boundaryMessageID) return records;
|
if (!boundaryMessageID) return records;
|
||||||
return records.filter((record) => record.info.id > boundaryMessageID);
|
const boundaryIndex = records.findIndex((record) => record.info.id === boundaryMessageID);
|
||||||
|
return boundaryIndex < 0 ? records : records.slice(boundaryIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BtwSessionRef = {
|
export type BtwSessionRef = {
|
||||||
@@ -276,10 +329,23 @@ export async function destroyBtwSession(ref: BtwSessionRef): Promise<boolean> {
|
|||||||
* session.
|
* session.
|
||||||
*/
|
*/
|
||||||
export async function promoteBtwSession(ref: BtwSessionRef): Promise<void> {
|
export async function promoteBtwSession(ref: BtwSessionRef): Promise<void> {
|
||||||
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
|
const expectedRuntimeKey = getRuntimeKey();
|
||||||
withoutBtwSessionLink(metadata, ref.btwSessionId));
|
let originalForkMetadata: Parameters<typeof withoutBtwSessionMarker>[0] | null = null;
|
||||||
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, withoutBtwSessionMarker)
|
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, (metadata) => {
|
||||||
.catch(() => undefined);
|
originalForkMetadata = metadata;
|
||||||
|
return withoutBtwSessionMarker(metadata);
|
||||||
|
}, expectedRuntimeKey);
|
||||||
|
try {
|
||||||
|
await sessionActions.patchSessionMetadata(ref.parentSessionId, ref.directory, (metadata) =>
|
||||||
|
withoutBtwSessionLink(metadata, ref.btwSessionId), expectedRuntimeKey);
|
||||||
|
} catch (error) {
|
||||||
|
const metadataToRestore = originalForkMetadata;
|
||||||
|
if (metadataToRestore) {
|
||||||
|
await sessionActions.patchSessionMetadata(ref.btwSessionId, ref.directory, () => metadataToRestore, expectedRuntimeKey)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
useBtwStore.getState().clearPanelState(ref.parentSessionId);
|
useBtwStore.getState().clearPanelState(ref.parentSessionId);
|
||||||
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
|
useSessionUIStore.getState().setCurrentSession(ref.btwSessionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2028,6 +2028,8 @@ export const dict = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.',
|
'chat.btw.toast.destroyFailed': 'Die btw-Sitzung konnte nicht gelöscht werden. Sie bleibt in der Seitenleiste.',
|
||||||
'chat.btw.working': 'Arbeitet…',
|
'chat.btw.working': 'Arbeitet…',
|
||||||
'chat.btw.collapseAria': 'btw-Panel einklappen',
|
'chat.btw.collapseAria': 'btw-Panel einklappen',
|
||||||
|
'chat.btw.draftHint': 'Stelle deine Frage',
|
||||||
|
'chat.btw.cancelAria': 'Diese BTW-Frage verwerfen',
|
||||||
'chat.btw.expandAria': 'btw-Panel ausklappen',
|
'chat.btw.expandAria': 'btw-Panel ausklappen',
|
||||||
'chat.btw.promoteAria': 'Als eigene Sitzung behalten',
|
'chat.btw.promoteAria': 'Als eigene Sitzung behalten',
|
||||||
'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden',
|
'chat.btw.toast.promoteFailed': 'Die btw-Sitzung konnte nicht behalten werden',
|
||||||
@@ -2065,6 +2067,8 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt',
|
'chat.textSelection.toast.addToNotesSummaryFailed': 'Zusammenfassung der Auswahl nicht möglich, ausgewählter Text wurde zu Notizen hinzugefügt',
|
||||||
'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen',
|
'chat.textSelection.actions.addToInput': 'Zur Eingabe hinzufügen',
|
||||||
'chat.textSelection.actions.comment': 'Kommentieren',
|
'chat.textSelection.actions.comment': 'Kommentieren',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'Übrigens…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'BTW-Entwurf mit der Auswahl öffnen',
|
||||||
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
|
'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren',
|
||||||
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
|
'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...',
|
||||||
'chat.textSelection.comment.attach': 'Anhängen',
|
'chat.textSelection.comment.attach': 'Anhängen',
|
||||||
|
|||||||
@@ -2237,6 +2237,8 @@ export const dict = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.',
|
'chat.btw.toast.destroyFailed': 'Failed to destroy the btw session. It will remain in the sidebar.',
|
||||||
'chat.btw.working': 'Working…',
|
'chat.btw.working': 'Working…',
|
||||||
'chat.btw.collapseAria': 'Collapse the btw panel',
|
'chat.btw.collapseAria': 'Collapse the btw panel',
|
||||||
|
'chat.btw.draftHint': 'Ask your question',
|
||||||
|
'chat.btw.cancelAria': 'Cancel this BTW question',
|
||||||
'chat.btw.expandAria': 'Expand the btw panel',
|
'chat.btw.expandAria': 'Expand the btw panel',
|
||||||
'chat.btw.promoteAria': 'Keep as a separate session',
|
'chat.btw.promoteAria': 'Keep as a separate session',
|
||||||
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
|
'chat.btw.toast.promoteFailed': 'Failed to keep the btw session',
|
||||||
@@ -2282,6 +2284,8 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
|
'chat.textSelection.toast.addToNotesSummaryFailed': 'Could not summarize selection, added selected text to notes',
|
||||||
'chat.textSelection.actions.addToInput': 'Add to input',
|
'chat.textSelection.actions.addToInput': 'Add to input',
|
||||||
'chat.textSelection.actions.comment': 'Comment',
|
'chat.textSelection.actions.comment': 'Comment',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'By the way…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'Open a BTW draft with the selection',
|
||||||
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
|
'chat.textSelection.title.commentOnSelection': 'Comment on selection',
|
||||||
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
|
'chat.textSelection.comment.placeholder': 'Add an optional comment...',
|
||||||
'chat.textSelection.comment.attach': 'Attach',
|
'chat.textSelection.comment.attach': 'Attach',
|
||||||
|
|||||||
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.',
|
'chat.btw.toast.destroyFailed': 'No se pudo destruir la sesión btw. Permanecerá en la barra lateral.',
|
||||||
'chat.btw.working': 'Trabajando…',
|
'chat.btw.working': 'Trabajando…',
|
||||||
'chat.btw.collapseAria': 'Contraer el panel btw',
|
'chat.btw.collapseAria': 'Contraer el panel btw',
|
||||||
|
'chat.btw.draftHint': 'Haz tu pregunta',
|
||||||
|
'chat.btw.cancelAria': 'Cancelar esta pregunta BTW',
|
||||||
'chat.btw.expandAria': 'Expandir el panel btw',
|
'chat.btw.expandAria': 'Expandir el panel btw',
|
||||||
'chat.btw.promoteAria': 'Conservar como sesión aparte',
|
'chat.btw.promoteAria': 'Conservar como sesión aparte',
|
||||||
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
|
'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw',
|
||||||
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
|
"chat.textSelection.toast.addToNotesSummaryFailed": "No se pudo resumir la selección; se añadió el texto seleccionado a las notas",
|
||||||
"chat.textSelection.actions.addToInput": "Añadir a la entrada",
|
"chat.textSelection.actions.addToInput": "Añadir a la entrada",
|
||||||
"chat.textSelection.actions.comment": "Comentar",
|
"chat.textSelection.actions.comment": "Comentar",
|
||||||
|
"chat.textSelection.actions.askOpenChamber": "Por cierto…",
|
||||||
|
"chat.textSelection.title.askOpenChamber": "Abrir un borrador BTW con la selección",
|
||||||
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
|
"chat.textSelection.title.commentOnSelection": "Comentar la selección",
|
||||||
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
|
"chat.textSelection.comment.placeholder": "Añade un comentario opcional...",
|
||||||
"chat.textSelection.comment.attach": "Adjuntar",
|
"chat.textSelection.comment.attach": "Adjuntar",
|
||||||
|
|||||||
@@ -1963,6 +1963,8 @@ export const dict = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.',
|
'chat.btw.toast.destroyFailed': 'Échec de la suppression de la session btw. Elle restera dans la barre latérale.',
|
||||||
'chat.btw.working': 'En cours…',
|
'chat.btw.working': 'En cours…',
|
||||||
'chat.btw.collapseAria': 'Réduire le panneau btw',
|
'chat.btw.collapseAria': 'Réduire le panneau btw',
|
||||||
|
'chat.btw.draftHint': 'Posez votre question',
|
||||||
|
'chat.btw.cancelAria': 'Annuler cette question BTW',
|
||||||
'chat.btw.expandAria': 'Développer le panneau btw',
|
'chat.btw.expandAria': 'Développer le panneau btw',
|
||||||
'chat.btw.promoteAria': 'Conserver comme session à part',
|
'chat.btw.promoteAria': 'Conserver comme session à part',
|
||||||
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
|
'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw',
|
||||||
@@ -2005,6 +2007,8 @@ export const dict = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes',
|
'chat.textSelection.toast.addToNotesSummaryFailed': 'Impossible de résumer la sélection, ajout du texte sélectionné aux notes',
|
||||||
'chat.textSelection.actions.addToInput': 'Ajouter à la saisie',
|
'chat.textSelection.actions.addToInput': 'Ajouter à la saisie',
|
||||||
'chat.textSelection.actions.comment': 'Commenter',
|
'chat.textSelection.actions.comment': 'Commenter',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'Au fait…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'Ouvrir un brouillon BTW avec la sélection',
|
||||||
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
|
'chat.textSelection.title.commentOnSelection': 'Commenter la sélection',
|
||||||
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
|
'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...',
|
||||||
'chat.textSelection.comment.attach': 'Joindre',
|
'chat.textSelection.comment.attach': 'Joindre',
|
||||||
|
|||||||
@@ -2232,6 +2232,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。',
|
'chat.btw.toast.destroyFailed': 'btwセッションを破棄できませんでした。サイドバーに残ります。',
|
||||||
'chat.btw.working': '処理中…',
|
'chat.btw.working': '処理中…',
|
||||||
'chat.btw.collapseAria': 'btwパネルを折りたたむ',
|
'chat.btw.collapseAria': 'btwパネルを折りたたむ',
|
||||||
|
'chat.btw.draftHint': '質問を入力してください',
|
||||||
|
'chat.btw.cancelAria': 'このBTWの質問をキャンセル',
|
||||||
'chat.btw.expandAria': 'btwパネルを展開する',
|
'chat.btw.expandAria': 'btwパネルを展開する',
|
||||||
'chat.btw.promoteAria': '独立したセッションとして保持',
|
'chat.btw.promoteAria': '独立したセッションとして保持',
|
||||||
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
|
'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした',
|
||||||
@@ -2278,6 +2280,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
|
'chat.textSelection.toast.addToNotesSummaryFailed': '選択範囲を要約できませんでした。選択テキストをメモに追加しました。',
|
||||||
'chat.textSelection.actions.addToInput': '入力欄に追加',
|
'chat.textSelection.actions.addToInput': '入力欄に追加',
|
||||||
'chat.textSelection.actions.comment': 'コメント',
|
'chat.textSelection.actions.comment': 'コメント',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'ところで…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': '選択したテキストでBTWの下書きを開く',
|
||||||
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
|
'chat.textSelection.title.commentOnSelection': '選択範囲にコメント',
|
||||||
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
|
'chat.textSelection.comment.placeholder': '任意のコメントを追加...',
|
||||||
'chat.textSelection.comment.attach': '添付',
|
'chat.textSelection.comment.attach': '添付',
|
||||||
|
|||||||
@@ -2238,6 +2238,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.',
|
'chat.btw.toast.destroyFailed': 'btw 세션을 삭제하지 못했습니다. 사이드바에 남아 있습니다.',
|
||||||
'chat.btw.working': '작업 중…',
|
'chat.btw.working': '작업 중…',
|
||||||
'chat.btw.collapseAria': 'btw 패널 접기',
|
'chat.btw.collapseAria': 'btw 패널 접기',
|
||||||
|
'chat.btw.draftHint': '질문을 입력하세요',
|
||||||
|
'chat.btw.cancelAria': '이 BTW 질문 취소',
|
||||||
'chat.btw.expandAria': 'btw 패널 펼치기',
|
'chat.btw.expandAria': 'btw 패널 펼치기',
|
||||||
'chat.btw.promoteAria': '별도 세션으로 유지',
|
'chat.btw.promoteAria': '별도 세션으로 유지',
|
||||||
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
|
'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다',
|
||||||
@@ -2284,6 +2286,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
|
'chat.textSelection.toast.addToNotesSummaryFailed': '선택 영역을 요약할 수 없어 선택한 텍스트를 메모에 추가함',
|
||||||
'chat.textSelection.actions.addToInput': '입력란에 추가',
|
'chat.textSelection.actions.addToInput': '입력란에 추가',
|
||||||
'chat.textSelection.actions.comment': '댓글',
|
'chat.textSelection.actions.comment': '댓글',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': '그런데…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': '선택한 텍스트로 BTW 초안 열기',
|
||||||
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
|
'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기',
|
||||||
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
|
'chat.textSelection.comment.placeholder': '선택적 댓글 추가...',
|
||||||
'chat.textSelection.comment.attach': '첨부',
|
'chat.textSelection.comment.attach': '첨부',
|
||||||
|
|||||||
@@ -886,6 +886,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.',
|
'chat.btw.toast.destroyFailed': 'Nie udało się zniszczyć sesji btw. Pozostanie na pasku bocznym.',
|
||||||
'chat.btw.working': 'Pracuje…',
|
'chat.btw.working': 'Pracuje…',
|
||||||
'chat.btw.collapseAria': 'Zwiń panel btw',
|
'chat.btw.collapseAria': 'Zwiń panel btw',
|
||||||
|
'chat.btw.draftHint': 'Zadaj pytanie',
|
||||||
|
'chat.btw.cancelAria': 'Anuluj to pytanie BTW',
|
||||||
'chat.btw.expandAria': 'Rozwiń panel btw',
|
'chat.btw.expandAria': 'Rozwiń panel btw',
|
||||||
'chat.btw.promoteAria': 'Zachowaj jako osobną sesję',
|
'chat.btw.promoteAria': 'Zachowaj jako osobną sesję',
|
||||||
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
|
'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw',
|
||||||
@@ -932,6 +934,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek',
|
'chat.textSelection.toast.addToNotesSummaryFailed': 'Nie można podsumować zaznaczenia, dodano wybrany tekst do notatek',
|
||||||
'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania',
|
'chat.textSelection.actions.addToInput': 'Dodaj do pola wpisywania',
|
||||||
'chat.textSelection.actions.comment': 'Skomentuj',
|
'chat.textSelection.actions.comment': 'Skomentuj',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'A tak przy okazji…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'Otwórz szkic BTW z zaznaczonym tekstem',
|
||||||
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
|
'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie',
|
||||||
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
|
'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...',
|
||||||
'chat.textSelection.comment.attach': 'Załącz',
|
'chat.textSelection.comment.attach': 'Załącz',
|
||||||
|
|||||||
@@ -2214,9 +2214,13 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.',
|
'chat.btw.toast.destroyFailed': 'Falha ao destruir a sessão btw. Ela permanecerá na barra lateral.',
|
||||||
'chat.btw.working': 'Trabalhando…',
|
'chat.btw.working': 'Trabalhando…',
|
||||||
'chat.btw.collapseAria': 'Recolher o painel btw',
|
'chat.btw.collapseAria': 'Recolher o painel btw',
|
||||||
|
'chat.btw.draftHint': 'Faça sua pergunta',
|
||||||
|
'chat.btw.cancelAria': 'Cancelar esta pergunta BTW',
|
||||||
'chat.btw.expandAria': 'Expandir o painel btw',
|
'chat.btw.expandAria': 'Expandir o painel btw',
|
||||||
'chat.btw.promoteAria': 'Manter como sessão separada',
|
'chat.btw.promoteAria': 'Manter como sessão separada',
|
||||||
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
|
'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'A propósito…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'Abrir um rascunho BTW com a seleção',
|
||||||
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
|
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
|
||||||
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
|
"chat.container.sessionLoadError.title": "Não foi possível carregar a sessão",
|
||||||
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
|
"chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.",
|
||||||
|
|||||||
@@ -3278,6 +3278,8 @@ export const dict = {
|
|||||||
'chat.btw.toast.destroyFailed': 'btw session yok edilemedi. Kenar çubuğunda kalacak.',
|
'chat.btw.toast.destroyFailed': 'btw session yok edilemedi. Kenar çubuğunda kalacak.',
|
||||||
'chat.btw.working': 'Çalışıyor…',
|
'chat.btw.working': 'Çalışıyor…',
|
||||||
'chat.btw.collapseAria': 'btw panelini daralt',
|
'chat.btw.collapseAria': 'btw panelini daralt',
|
||||||
|
'chat.btw.draftHint': 'Sorunuzu sorun',
|
||||||
|
'chat.btw.cancelAria': 'Bu BTW sorusunu iptal et',
|
||||||
'chat.btw.expandAria': 'btw panelini genişlet',
|
'chat.btw.expandAria': 'btw panelini genişlet',
|
||||||
'chat.btw.promoteAria': 'Ayrı bir session olarak sakla',
|
'chat.btw.promoteAria': 'Ayrı bir session olarak sakla',
|
||||||
'chat.btw.toast.promoteFailed': 'btw session saklanamadı',
|
'chat.btw.toast.promoteFailed': 'btw session saklanamadı',
|
||||||
@@ -3308,6 +3310,8 @@ export const dict = {
|
|||||||
'chat.container.sessionLoadError.authDescription': 'Session\'ınızın süresi doldu, bu yüzden sunucu isteği reddetti. Oturum açın, sohbet yüklenecek.',
|
'chat.container.sessionLoadError.authDescription': 'Session\'ınızın süresi doldu, bu yüzden sunucu isteği reddetti. Oturum açın, sohbet yüklenecek.',
|
||||||
'chat.textSelection.actions.addToInput': 'Girdiye ekle',
|
'chat.textSelection.actions.addToInput': 'Girdiye ekle',
|
||||||
'chat.textSelection.actions.comment': 'Yorum yap',
|
'chat.textSelection.actions.comment': 'Yorum yap',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': 'Bu arada…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': 'Seçili metinle BTW taslağı aç',
|
||||||
'chat.textSelection.title.commentOnSelection': 'Seçime yorum yap',
|
'chat.textSelection.title.commentOnSelection': 'Seçime yorum yap',
|
||||||
'chat.textSelection.comment.placeholder': 'İsteğe bağlı bir yorum ekleyin...',
|
'chat.textSelection.comment.placeholder': 'İsteğe bağlı bir yorum ekleyin...',
|
||||||
'chat.textSelection.comment.attach': 'Ekle',
|
'chat.textSelection.comment.attach': 'Ekle',
|
||||||
|
|||||||
@@ -2214,6 +2214,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.',
|
'chat.btw.toast.destroyFailed': 'Не вдалося знищити сесію btw. Вона залишиться в бічній панелі.',
|
||||||
'chat.btw.working': 'Працює…',
|
'chat.btw.working': 'Працює…',
|
||||||
'chat.btw.collapseAria': 'Згорнути панель btw',
|
'chat.btw.collapseAria': 'Згорнути панель btw',
|
||||||
|
'chat.btw.draftHint': 'Поставте своє запитання',
|
||||||
|
'chat.btw.cancelAria': 'Скасувати це запитання BTW',
|
||||||
'chat.btw.expandAria': 'Розгорнути панель btw',
|
'chat.btw.expandAria': 'Розгорнути панель btw',
|
||||||
'chat.btw.promoteAria': 'Залишити як окрему сесію',
|
'chat.btw.promoteAria': 'Залишити як окрему сесію',
|
||||||
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
|
'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw',
|
||||||
@@ -2260,6 +2262,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
|
"chat.textSelection.toast.addToNotesSummaryFailed": "Не вдалося підсумувати виділення, виділений текст додано до нотаток",
|
||||||
"chat.textSelection.actions.addToInput": "Додати в поле вводу",
|
"chat.textSelection.actions.addToInput": "Додати в поле вводу",
|
||||||
"chat.textSelection.actions.comment": "Коментувати",
|
"chat.textSelection.actions.comment": "Коментувати",
|
||||||
|
"chat.textSelection.actions.askOpenChamber": "До речі…",
|
||||||
|
"chat.textSelection.title.askOpenChamber": "Відкрити BTW-чернетку з виділеним текстом",
|
||||||
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
|
"chat.textSelection.title.commentOnSelection": "Коментувати виділене",
|
||||||
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
|
"chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...",
|
||||||
"chat.textSelection.comment.attach": "Прикріпити",
|
"chat.textSelection.comment.attach": "Прикріпити",
|
||||||
|
|||||||
@@ -2202,6 +2202,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。',
|
'chat.btw.toast.destroyFailed': '销毁 btw 会话失败。它将保留在侧边栏中。',
|
||||||
'chat.btw.working': '处理中…',
|
'chat.btw.working': '处理中…',
|
||||||
'chat.btw.collapseAria': '收起 btw 面板',
|
'chat.btw.collapseAria': '收起 btw 面板',
|
||||||
|
'chat.btw.draftHint': '提出你的问题',
|
||||||
|
'chat.btw.cancelAria': '取消这次 BTW 提问',
|
||||||
'chat.btw.expandAria': '展开 btw 面板',
|
'chat.btw.expandAria': '展开 btw 面板',
|
||||||
'chat.btw.promoteAria': '保留为独立会话',
|
'chat.btw.promoteAria': '保留为独立会话',
|
||||||
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
|
'chat.btw.toast.promoteFailed': '保留 btw 会话失败',
|
||||||
@@ -2248,6 +2250,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
|
'chat.textSelection.toast.addToNotesSummaryFailed': '无法总结所选内容,已将所选文本添加到笔记',
|
||||||
'chat.textSelection.actions.addToInput': '添加到输入框',
|
'chat.textSelection.actions.addToInput': '添加到输入框',
|
||||||
'chat.textSelection.actions.comment': '评论',
|
'chat.textSelection.actions.comment': '评论',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': '顺便问一下…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': '用所选文本打开 BTW 草稿',
|
||||||
'chat.textSelection.title.commentOnSelection': '评论所选内容',
|
'chat.textSelection.title.commentOnSelection': '评论所选内容',
|
||||||
'chat.textSelection.comment.placeholder': '添加可选评论...',
|
'chat.textSelection.comment.placeholder': '添加可选评论...',
|
||||||
'chat.textSelection.comment.attach': '附加',
|
'chat.textSelection.comment.attach': '附加',
|
||||||
|
|||||||
@@ -2206,6 +2206,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。',
|
'chat.btw.toast.destroyFailed': '銷毀 btw 工作階段失敗。它將保留在側邊欄中。',
|
||||||
'chat.btw.working': '處理中…',
|
'chat.btw.working': '處理中…',
|
||||||
'chat.btw.collapseAria': '收合 btw 面板',
|
'chat.btw.collapseAria': '收合 btw 面板',
|
||||||
|
'chat.btw.draftHint': '提出你的問題',
|
||||||
|
'chat.btw.cancelAria': '取消這次 BTW 提問',
|
||||||
'chat.btw.expandAria': '展開 btw 面板',
|
'chat.btw.expandAria': '展開 btw 面板',
|
||||||
'chat.btw.promoteAria': '保留為獨立工作階段',
|
'chat.btw.promoteAria': '保留為獨立工作階段',
|
||||||
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
|
'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗',
|
||||||
@@ -2252,6 +2254,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
|
'chat.textSelection.toast.addToNotesSummaryFailed': '無法總結所選內容,已將所選文字加入筆記',
|
||||||
'chat.textSelection.actions.addToInput': '加入輸入框',
|
'chat.textSelection.actions.addToInput': '加入輸入框',
|
||||||
'chat.textSelection.actions.comment': '留言',
|
'chat.textSelection.actions.comment': '留言',
|
||||||
|
'chat.textSelection.actions.askOpenChamber': '順便問一下…',
|
||||||
|
'chat.textSelection.title.askOpenChamber': '用所選文字開啟 BTW 草稿',
|
||||||
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
|
'chat.textSelection.title.commentOnSelection': '對所選內容留言',
|
||||||
'chat.textSelection.comment.placeholder': '新增選填留言...',
|
'chat.textSelection.comment.placeholder': '新增選填留言...',
|
||||||
'chat.textSelection.comment.attach': '附加',
|
'chat.textSelection.comment.attach': '附加',
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import { getSessionMetadata, type SessionMetadataRecord } from '@/lib/sessionRev
|
|||||||
* survives reloads.
|
* survives reloads.
|
||||||
* - The fork itself is marked `openchamber.kind = 'btw'` with
|
* - The fork itself is marked `openchamber.kind = 'btw'` with
|
||||||
* `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of
|
* `originalSessionID` (its parent) and `btwBoundaryMessageID` — the id of
|
||||||
* the last message cloned from the parent. Messages with a greater id are
|
* the last message cloned from the parent. The panel locates this marker
|
||||||
* the fork's own tail and are what the panel renders. Message ids are
|
* in the chronologically ordered transcript and renders what follows it.
|
||||||
* server-generated ascending identifiers, so the boundary is a plain string
|
* IDs must not be compared to determine chronology: they can roll over and
|
||||||
* comparison and immune to client clock skew.
|
* user-message IDs can be generated by a different client clock.
|
||||||
*/
|
*/
|
||||||
type BtwMetadata = {
|
type BtwMetadata = {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
|
|||||||
|
|
||||||
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
|
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
|
||||||
|
|
||||||
|
`[data-btw-composer="true"]` owns Escape instead of main-session abort priming.
|
||||||
|
While active, main and Mini Chat model/effort shortcuts yield; main agent,
|
||||||
|
expansion and dictation shortcuts also yield. Footer unmounting alone cannot
|
||||||
|
disable these global registrations or protect the parent composer's selection.
|
||||||
|
|
||||||
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
|
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
|
||||||
|
|
||||||
# Adding shortcuts
|
# Adding shortcuts
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { beforeEach, describe, expect, test } from 'bun:test';
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||||
import { useBtwStore } from './useBtwStore';
|
import { resolveBtwSelection, useBtwStore } from './useBtwStore';
|
||||||
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
|
|
||||||
|
const composerModel = { providerId: 'openai', modelId: 'gpt-5.6-terra' };
|
||||||
|
const input = {
|
||||||
|
agents: [{ name: 'build', mode: 'primary' as const }, { name: 'plan', mode: 'primary' as const }],
|
||||||
|
savedAgent: null,
|
||||||
|
savedModel: null,
|
||||||
|
composerModel,
|
||||||
|
composerVariant: 'medium',
|
||||||
|
};
|
||||||
|
|
||||||
describe('useBtwStore', () => {
|
describe('useBtwStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -35,4 +45,52 @@ describe('useBtwStore', () => {
|
|||||||
useBtwStore.getState().clearPanelState('missing');
|
useBtwStore.getState().clearPanelState('missing');
|
||||||
expect(useBtwStore.getState().byParent).toBe(before);
|
expect(useBtwStore.getState().byParent).toBe(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inherits model and effort from the main composer, not from the plan agent', () => {
|
||||||
|
expect(resolveBtwSelection(input)).toEqual({ agent: 'plan', model: composerModel, variant: 'medium' });
|
||||||
|
expect(resolveBtwSelection({ ...input, composerVariant: null }).variant).toBeNull();
|
||||||
|
expect(resolveBtwSelection({ ...input, composerModel: null }).model).toBeNull();
|
||||||
|
expect(resolveBtwSelection({ ...input, agents: [
|
||||||
|
{ name: 'hidden', mode: 'primary', hidden: true },
|
||||||
|
{ name: 'custom', mode: 'primary' },
|
||||||
|
] })).toEqual({ agent: 'custom', model: composerModel, variant: 'medium' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers the saved BTW selection over the current composer', () => {
|
||||||
|
const savedModel = { providerId: 'one', modelId: 'selected' };
|
||||||
|
expect(resolveBtwSelection({ ...input, savedModel, savedVariant: null }))
|
||||||
|
.toEqual({ agent: 'plan', model: savedModel, variant: null });
|
||||||
|
expect(resolveBtwSelection({ ...input, savedModel }).variant).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishes BTW effort edits and cancellation without changing the parent', () => {
|
||||||
|
const store = useSelectionStore.getState();
|
||||||
|
const parent = 'selection-cleanup-parent';
|
||||||
|
const pending = `btw-pending:${parent}`;
|
||||||
|
for (const session of [parent, pending]) {
|
||||||
|
store.saveSessionModelSelection(session, 'one', 'model');
|
||||||
|
store.saveSessionAgentSelection(session, 'plan');
|
||||||
|
store.saveAgentModelForSession(session, 'plan', 'one', 'model');
|
||||||
|
store.saveAgentModelVariantForSession(session, 'plan', 'one', 'model', 'high');
|
||||||
|
}
|
||||||
|
const observed: Array<string | null | undefined> = [];
|
||||||
|
const unsubscribe = useSelectionStore.subscribe((state) => {
|
||||||
|
observed.push(state.getAgentModelVariantForSession(pending, 'plan', 'one', 'model'));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
store.saveAgentModelVariantForSession(pending, 'plan', 'one', 'model', null);
|
||||||
|
store.clearSessionSelections(pending);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
expect(observed).toEqual([null, undefined]);
|
||||||
|
expect(store.getSessionModelSelection(pending)).toBeNull();
|
||||||
|
expect(store.getSessionAgentSelection(pending)).toBeNull();
|
||||||
|
expect(store.getAgentModelForSession(pending, 'plan')).toBeNull();
|
||||||
|
expect(store.getAgentModelVariantForSession(pending, 'plan', 'one', 'model')).toBe(undefined);
|
||||||
|
expect(store.getSessionModelSelection(parent)).toEqual({ providerId: 'one', modelId: 'model' });
|
||||||
|
expect(store.getSessionAgentSelection(parent)).toBe('plan');
|
||||||
|
expect(store.getAgentModelForSession(parent, 'plan')).toEqual({ providerId: 'one', modelId: 'model' });
|
||||||
|
expect(store.getAgentModelVariantForSession(parent, 'plan', 'one', 'model')).toBe('high');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,31 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { Agent } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
type BtwModelSelection = { providerId: string; modelId: string };
|
||||||
|
export type BtwSelection = {
|
||||||
|
agent: string | undefined;
|
||||||
|
model: BtwModelSelection | null;
|
||||||
|
variant: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveBtwSelection = ({ agents, savedAgent, savedModel, savedVariant, composerModel, composerVariant }: {
|
||||||
|
agents: readonly Pick<Agent, 'name' | 'hidden' | 'mode'>[];
|
||||||
|
savedAgent: string | null;
|
||||||
|
savedModel: BtwModelSelection | null;
|
||||||
|
savedVariant?: string | null;
|
||||||
|
composerModel: BtwModelSelection | null;
|
||||||
|
composerVariant: string | null | undefined;
|
||||||
|
}): BtwSelection => {
|
||||||
|
const selectable = agents.filter((agent) => !agent.hidden && (agent.mode === 'primary' || agent.mode === 'all'));
|
||||||
|
const agent = selectable.find((candidate) => candidate.name === savedAgent)
|
||||||
|
?? selectable.find((candidate) => candidate.name === 'plan')
|
||||||
|
?? selectable[0];
|
||||||
|
return {
|
||||||
|
agent: agent?.name,
|
||||||
|
model: savedModel ?? composerModel,
|
||||||
|
variant: savedModel ? savedVariant : composerVariant,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI-only state for the `/btw` peek panel.
|
* UI-only state for the `/btw` peek panel.
|
||||||
@@ -15,11 +42,15 @@ import { create } from 'zustand';
|
|||||||
* landing, so the panel can show its starting state immediately.
|
* landing, so the panel can show its starting state immediately.
|
||||||
* - `destroying`: close was clicked; hides the panel optimistically while the
|
* - `destroying`: close was clicked; hides the panel optimistically while the
|
||||||
* unlink/delete round-trip completes.
|
* unlink/delete round-trip completes.
|
||||||
|
* - `pending`: `/btw` has opened an unsent local composer. No fork exists yet.
|
||||||
*/
|
*/
|
||||||
type BtwPanelUIState = {
|
type BtwPanelUIState = {
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
creating?: boolean;
|
creating?: boolean;
|
||||||
destroying?: boolean;
|
destroying?: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
pendingAutoAccept?: boolean;
|
||||||
|
pendingSend?: symbol;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BtwStore = {
|
type BtwStore = {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe("input-store attachments", () => {
|
|||||||
pendingInputText: null,
|
pendingInputText: null,
|
||||||
pendingInputMode: "replace",
|
pendingInputMode: "replace",
|
||||||
pendingSyntheticParts: null,
|
pendingSyntheticParts: null,
|
||||||
|
pendingBtwComposerRequest: null,
|
||||||
activeEditorFile: null,
|
activeEditorFile: null,
|
||||||
})
|
})
|
||||||
useInputStore.getState().setAttachedFiles([])
|
useInputStore.getState().setAttachedFiles([])
|
||||||
@@ -403,3 +404,27 @@ describe("input-store attachments", () => {
|
|||||||
expect(useInputStore.getState().attachedFiles).toEqual([])
|
expect(useInputStore.getState().attachedFiles).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("input-store BTW composer requests", () => {
|
||||||
|
test("keeps the request scoped to its parent without changing the normal composer", () => {
|
||||||
|
useInputStore.setState({
|
||||||
|
pendingInputText: "normal draft",
|
||||||
|
pendingInputMode: "replace",
|
||||||
|
pendingBtwComposerRequest: null,
|
||||||
|
attachedFiles: [],
|
||||||
|
})
|
||||||
|
useInputStore.getState().requestBtwComposer({
|
||||||
|
parentSessionId: "parent-1",
|
||||||
|
text: "> selected text",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-2")).toBeNull()
|
||||||
|
expect(useInputStore.getState().pendingInputText).toBe("normal draft")
|
||||||
|
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toEqual({
|
||||||
|
parentSessionId: "parent-1",
|
||||||
|
text: "> selected text",
|
||||||
|
})
|
||||||
|
expect(useInputStore.getState().consumePendingBtwComposerRequest("parent-1")).toBeNull()
|
||||||
|
expect(useInputStore.getState().pendingInputText).toBe("normal draft")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -120,6 +120,11 @@ export type SyntheticContextPart = {
|
|||||||
metadata?: ContextPartMetadata
|
metadata?: ContextPartMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingBtwComposerRequest = {
|
||||||
|
parentSessionId: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export type VSCodeActiveEditorFile = {
|
export type VSCodeActiveEditorFile = {
|
||||||
filePath: string
|
filePath: string
|
||||||
fileName: string
|
fileName: string
|
||||||
@@ -144,6 +149,7 @@ export type InputState = {
|
|||||||
* narrow layouts); consumed by ChatInput, which owns the command-aware submit.
|
* narrow layouts); consumed by ChatInput, which owns the command-aware submit.
|
||||||
*/
|
*/
|
||||||
pendingPresetSubmit: { text: string; type: "command" | "skill" } | null
|
pendingPresetSubmit: { text: string; type: "command" | "skill" } | null
|
||||||
|
pendingBtwComposerRequest: PendingBtwComposerRequest | null
|
||||||
attachedFiles: AttachedFile[]
|
attachedFiles: AttachedFile[]
|
||||||
activeEditorFile: VSCodeActiveEditorFile | null
|
activeEditorFile: VSCodeActiveEditorFile | null
|
||||||
|
|
||||||
@@ -151,6 +157,8 @@ export type InputState = {
|
|||||||
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
|
consumePendingInputText: () => { text: string; mode: "replace" | "append" | "append-inline" } | null
|
||||||
requestPresetSubmit: (text: string, type: "command" | "skill") => void
|
requestPresetSubmit: (text: string, type: "command" | "skill") => void
|
||||||
consumePendingPresetSubmit: () => { text: string; type: "command" | "skill" } | null
|
consumePendingPresetSubmit: () => { text: string; type: "command" | "skill" } | null
|
||||||
|
requestBtwComposer: (request: PendingBtwComposerRequest) => void
|
||||||
|
consumePendingBtwComposerRequest: (parentSessionId: string | null) => PendingBtwComposerRequest | null
|
||||||
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
|
setPendingSyntheticParts: (parts: SyntheticContextPart[] | null) => void
|
||||||
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
|
consumePendingSyntheticParts: () => SyntheticContextPart[] | null
|
||||||
addAttachedFile: (file: File) => Promise<boolean>
|
addAttachedFile: (file: File) => Promise<boolean>
|
||||||
@@ -176,6 +184,7 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
pendingInputMode: "replace",
|
pendingInputMode: "replace",
|
||||||
pendingSyntheticParts: null,
|
pendingSyntheticParts: null,
|
||||||
pendingPresetSubmit: null,
|
pendingPresetSubmit: null,
|
||||||
|
pendingBtwComposerRequest: null,
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
activeEditorFile: null,
|
activeEditorFile: null,
|
||||||
|
|
||||||
@@ -198,6 +207,15 @@ export const useInputStore = create<InputState>()((set, get) => ({
|
|||||||
return pendingPresetSubmit
|
return pendingPresetSubmit
|
||||||
},
|
},
|
||||||
|
|
||||||
|
requestBtwComposer: (request) => set({ pendingBtwComposerRequest: request }),
|
||||||
|
|
||||||
|
consumePendingBtwComposerRequest: (parentSessionId) => {
|
||||||
|
const request = get().pendingBtwComposerRequest
|
||||||
|
if (!request || request.parentSessionId !== parentSessionId) return null
|
||||||
|
set({ pendingBtwComposerRequest: null })
|
||||||
|
return request
|
||||||
|
},
|
||||||
|
|
||||||
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
|
setPendingSyntheticParts: (parts) => set({ pendingSyntheticParts: parts }),
|
||||||
|
|
||||||
consumePendingSyntheticParts: () => {
|
consumePendingSyntheticParts: () => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type SelectionState = {
|
|||||||
getSessionAgentSelection: (sessionId: string) => string | null
|
getSessionAgentSelection: (sessionId: string) => string | null
|
||||||
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
|
saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void
|
||||||
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
|
getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null
|
||||||
|
clearSessionSelections: (sessionId: string) => void
|
||||||
/**
|
/**
|
||||||
* `variant` is the effort chosen for this agent/model in this session:
|
* `variant` is the effort chosen for this agent/model in this session:
|
||||||
* a name, `null` for an explicit "Default" (send no effort), or `undefined`
|
* a name, `null` for an explicit "Default" (send no effort), or `undefined`
|
||||||
@@ -94,6 +95,20 @@ export const useSelectionStore = create<SelectionState>()(
|
|||||||
getAgentModelForSession: (sessionId, agentName) =>
|
getAgentModelForSession: (sessionId, agentName) =>
|
||||||
get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null,
|
get().sessionAgentModelSelections.get(sessionId)?.get(agentName) ?? null,
|
||||||
|
|
||||||
|
clearSessionSelections: (sessionId) => set((state) => {
|
||||||
|
const hadVariant = agentModelVariantSelections.delete(sessionId)
|
||||||
|
if (!hadVariant && !state.sessionModelSelections.has(sessionId)
|
||||||
|
&& !state.sessionAgentSelections.has(sessionId)
|
||||||
|
&& !state.sessionAgentModelSelections.has(sessionId)) return state
|
||||||
|
const sessionModelSelections = new Map(state.sessionModelSelections)
|
||||||
|
const sessionAgentSelections = new Map(state.sessionAgentSelections)
|
||||||
|
const sessionAgentModelSelections = new Map(state.sessionAgentModelSelections)
|
||||||
|
sessionModelSelections.delete(sessionId)
|
||||||
|
sessionAgentSelections.delete(sessionId)
|
||||||
|
sessionAgentModelSelections.delete(sessionId)
|
||||||
|
return { sessionModelSelections, sessionAgentSelections, sessionAgentModelSelections }
|
||||||
|
}),
|
||||||
|
|
||||||
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
|
saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => {
|
||||||
const key = `${providerId}/${modelId}`
|
const key = `${providerId}/${modelId}`
|
||||||
const clears = variant === undefined
|
const clears = variant === undefined
|
||||||
@@ -118,10 +133,12 @@ export const useSelectionStore = create<SelectionState>()(
|
|||||||
if (agentMap.size === 0) {
|
if (agentMap.size === 0) {
|
||||||
agentModelVariantSelections.delete(sessionId)
|
agentModelVariantSelections.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
set((state) => ({ ...state }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modelMap.set(key, variant)
|
modelMap.set(key, variant)
|
||||||
|
set((state) => ({ ...state }))
|
||||||
},
|
},
|
||||||
|
|
||||||
getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => {
|
getAgentModelVariantForSession: (sessionId, agentName, providerId, modelId) => {
|
||||||
|
|||||||
@@ -1601,9 +1601,15 @@ export async function unarchiveSessions(
|
|||||||
return { restoredIds, failedIds }
|
return { restoredIds, failedIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
|
export async function updateSessionTitle(
|
||||||
const sessionDirectory = getSessionDirectory(sessionId)
|
sessionId: string,
|
||||||
|
title: string,
|
||||||
|
options?: { directory?: string | null; expectedRuntimeKey?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed")
|
||||||
|
const sessionDirectory = options?.directory ?? getSessionDirectory(sessionId)
|
||||||
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
|
||||||
|
if (isStaleRuntime(options?.expectedRuntimeKey)) throw new Error("runtime changed")
|
||||||
useGlobalSessionsStore.getState().upsertSession(session)
|
useGlobalSessionsStore.getState().upsertSession(session)
|
||||||
mirrorSessionIntoLiveStores(session, sessionDirectory)
|
mirrorSessionIntoLiveStores(session, sessionDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user