diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c42e3446..64129df3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,6 @@ There are a few ways to contribute: We highly recommend reading [this](https://github.com/microsoft/vscode/wiki/Source-Code-Organization) article on VSCode's sourcecode organization. -We are currently putting together our own articles on VSCode and Void's sourcecode organization. The best way to get this information right now is by attending a weekly meeting. - diff --git a/create-appimage.sh b/create-appimage.sh new file mode 100644 index 00000000..c79a351c --- /dev/null +++ b/create-appimage.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -e # Exit on error +set -x # Print commands as they are executed + +# Configuration +APP_NAME="void" +APP_VERSION="1.0.0" +ARCH="x86_64" + +export ARCH + +# Check if void binary exists in current directory +if [ ! -f "./void" ]; then + echo "Error: void binary not found in current directory" + exit 1 +fi + +# Check if icon exists +if [ ! -f "./void.png" ]; then + echo "Error: void.png icon not found in current directory" + exit 1 +fi + +# Create temporary directory +TEMP_DIR="$(mktemp -d)" +echo "Created temporary directory: $TEMP_DIR" +APP_DIR="$TEMP_DIR/$APP_NAME.AppDir" + +# Create basic AppDir structure +mkdir -pv "$APP_DIR/usr/bin" +mkdir -pv "$APP_DIR/usr/lib" +mkdir -pv "$APP_DIR/usr/share/applications" +mkdir -pv "$APP_DIR/usr/share/icons/hicolor/256x256/apps" + +# Exclude create-appimage.sh and appimagetool-x86_64.AppImage from being copied +echo "Copying files excluding create-appimage.sh and appimagetool-x86_64.AppImage..." +for file in ./*; do + if [[ "$file" != "./create-appimage.sh" && "$file" != "./appimagetool-x86_64.AppImage" ]]; then + cp -rv "$file" "$APP_DIR/usr/bin/" + fi +done + +# Copy the icon to required locations +cp -v ./void.png "$APP_DIR/void.png" +cp -v ./void.png "$APP_DIR/usr/share/icons/hicolor/256x256/apps/void.png" + +# Copy dependencies with error checking +echo "Copying dependencies..." +for lib in $(ldd ./void | grep "=> /" | awk '{print $3}'); do + if [ -f "$lib" ]; then + cp -v "$lib" "$APP_DIR/usr/lib/" || echo "Failed to copy $lib" + else + echo "Warning: Library $lib not found" + fi +done + +# Create desktop file with error checking +echo "Creating desktop file..." +if ! cat > "$APP_DIR/$APP_NAME.desktop" < "$APP_DIR/AppRun" < { return null } else if (typeof fullError === 'object') { + if (Object.keys(fullError).length === 0) return null return JSON.stringify(fullError, null, 2) } else if (typeof fullError === 'string') { @@ -41,10 +42,10 @@ type _InternalSendFIMMessage = { } type SendLLMType = { - type: 'sendChatMessage'; + messagesType: 'chatMessages'; messages: LLMChatMessage[]; } | { - type: 'sendFIMMessage'; + messagesType: 'FIMMessage'; messages: _InternalSendFIMMessage; } diff --git a/src/vs/platform/void/common/refreshModelService.ts b/src/vs/platform/void/common/refreshModelService.ts index 811db0db..7ef6a068 100644 --- a/src/vs/platform/void/common/refreshModelService.ts +++ b/src/vs/platform/void/common/refreshModelService.ts @@ -44,8 +44,8 @@ export type RefreshModelStateOfProvider = Record -type EventProp = T extends 'globalSettings' ? [T, keyof VoidSettingsState[T]] : T | 'all' +// type RealVoidSettings = Exclude +// type EventProp = T extends 'globalSettings' ? [T, keyof VoidSettingsState[T]] : T | 'all' export interface IVoidSettingsService { @@ -51,7 +51,7 @@ export interface IVoidSettingsService { readonly state: VoidSettingsState; // in order to play nicely with react, you should immutably change state readonly waitForInitState: Promise; - onDidChangeState: Event; + onDidChangeState: Event; setSettingOfProvider: SetSettingOfProviderFn; setModelSelectionOfFeature: SetModelSelectionOfFeatureFn; @@ -64,26 +64,76 @@ export interface IVoidSettingsService { } -let _computeModelOptions = (settingsOfProvider: SettingsOfProvider) => { - let modelOptions: ModelOption[] = [] + +const _updatedValidatedState = (state: Omit) => { + + let newSettingsOfProvider = state.settingsOfProvider + + // recompute _didFillInProviderSettings for (const providerName of providerNames) { - const providerConfig = settingsOfProvider[providerName] - if (!providerConfig._enabled) continue // if disabled, don't display model options - for (const { modelName, isHidden } of providerConfig.models) { - if (isHidden) continue - modelOptions.push({ name: `${modelName} (${providerName})`, selection: { providerName, modelName } }) + const settingsAtProvider = newSettingsOfProvider[providerName] + + const didFillInProviderSettings = Object.keys(defaultProviderSettings[providerName]).every(key => !!settingsAtProvider[key as keyof typeof settingsAtProvider]) + + if (didFillInProviderSettings === settingsAtProvider._didFillInProviderSettings) continue + + newSettingsOfProvider = { + ...newSettingsOfProvider, + [providerName]: { + ...settingsAtProvider, + _didFillInProviderSettings: didFillInProviderSettings, + }, } } - return modelOptions + + // update model options + let newModelOptions: ModelOption[] = [] + for (const providerName of providerNames) { + const providerTitle = displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName + if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue // if disabled, don't display model options + for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) { + if (isHidden) continue + newModelOptions.push({ name: `${modelName} (${providerTitle})`, selection: { providerName, modelName } }) + } + } + + // now that model options are updated, make sure the selection is valid + // if the user-selected model is no longer in the list, update the selection for each feature that needs it to something relevant (the 0th model available, or null) + let newModelSelectionOfFeature = state.modelSelectionOfFeature + for (const featureName of featureNames) { + + const modelSelectionAtFeature = newModelSelectionOfFeature[featureName] + const selnIdx = modelSelectionAtFeature === null ? -1 : newModelOptions.findIndex(m => modelSelectionsEqual(m.selection, modelSelectionAtFeature)) + + if (selnIdx !== -1) continue + + newModelSelectionOfFeature = { + ...newModelSelectionOfFeature, + [featureName]: newModelOptions.length === 0 ? null : newModelOptions[0].selection + } + } + + + const newState = { + ...state, + settingsOfProvider: newSettingsOfProvider, + modelSelectionOfFeature: newModelSelectionOfFeature, + _modelOptions: newModelOptions, + } satisfies VoidSettingsState + + return newState } + + + const defaultState = () => { const d: VoidSettingsState = { settingsOfProvider: deepClone(defaultSettingsOfProvider), modelSelectionOfFeature: { 'Ctrl+L': null, 'Ctrl+K': null, 'Autocomplete': null, 'FastApply': null }, globalSettings: deepClone(defaultGlobalSettings), - _modelOptions: _computeModelOptions(defaultSettingsOfProvider), // computed + _modelOptions: [], // computed later } return d } @@ -93,8 +143,8 @@ export const IVoidSettingsService = createDecorator('VoidS class VoidSettingsService extends Disposable implements IVoidSettingsService { _serviceBrand: undefined; - private readonly _onDidChangeState = new Emitter(); - readonly onDidChangeState: Event = this._onDidChangeState.event; // this is primarily for use in react, so react can listen + update on state changes + private readonly _onDidChangeState = new Emitter(); + readonly onDidChangeState: Event = this._onDidChangeState.event; // this is primarily for use in react, so react can listen + update on state changes state: VoidSettingsState; waitForInitState: Promise // await this if you need a valid state initially @@ -118,39 +168,47 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { this._readState().then(readS => { // the stored data structure might be outdated, so we need to update it here (can do a more general solution later when we need to) - readS = { - ...readS, - settingsOfProvider: { - // A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS) - ...{ deepseek: defaultSettingsOfProvider.deepseek }, + const newSettingsOfProvider = { + // A HACK BECAUSE WE ADDED DEEPSEEK (did not exist before, comes before readS) + ...{ deepseek: defaultSettingsOfProvider.deepseek }, - // A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS) - ...{ mistral: defaultSettingsOfProvider.mistral }, + // A HACK BECAUSE WE ADDED MISTRAL (did not exist before, comes before readS) + ...{ mistral: defaultSettingsOfProvider.mistral }, - ...readS.settingsOfProvider, + ...readS.settingsOfProvider, - // A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS) - gemini: { - ...readS.settingsOfProvider.gemini, - models: [ - ...readS.settingsOfProvider.gemini.models, - ...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName)) - ] - } - }, - modelSelectionOfFeature: { - // A HACK BECAUSE WE ADDED FastApply - ...{ 'FastApply': null }, - ...readS.modelSelectionOfFeature, + // A HACK BECAUSE WE ADDED NEW GEMINI MODELS (existed before, comes after readS) + gemini: { + ...readS.settingsOfProvider.gemini, + models: [ + ...readS.settingsOfProvider.gemini.models, + ...defaultSettingsOfProvider.gemini.models.filter(m => /* if cant find the model in readS (yes this is O(n^2), very small) */ !readS.settingsOfProvider.gemini.models.find(m2 => m2.modelName === m.modelName)) + ] } } - this.state = readS + const newModelSelectionOfFeature = { + // A HACK BECAUSE WE ADDED FastApply + ...{ 'FastApply': null }, + ...readS.modelSelectionOfFeature, + } + + readS = { + ...readS, + settingsOfProvider: newSettingsOfProvider, + modelSelectionOfFeature: newModelSelectionOfFeature, + } + + this.state = _updatedValidatedState(readS) + resolver() - this._onDidChangeState.fire('all') + this._onDidChangeState.fire() }) + + } + private async _readState(): Promise { const encryptedState = this._storageService.get(STORAGE_KEY, StorageScope.APPLICATION) @@ -172,7 +230,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { const newModelSelectionOfFeature = this.state.modelSelectionOfFeature - const newSettingsOfProvider = { + const newSettingsOfProvider: SettingsOfProvider = { ...this.state.settingsOfProvider, [providerName]: { ...this.state.settingsOfProvider[providerName], @@ -182,38 +240,17 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { const newGlobalSettings = this.state.globalSettings - // if changed models or enabled a provider, recompute models list - const modelsListChanged = settingName === 'models' || settingName === '_enabled' - const newModelsList = modelsListChanged ? _computeModelOptions(newSettingsOfProvider) : this.state._modelOptions - - const newState: VoidSettingsState = { + const newState = { modelSelectionOfFeature: newModelSelectionOfFeature, settingsOfProvider: newSettingsOfProvider, globalSettings: newGlobalSettings, - _modelOptions: newModelsList, } - // this must go above this.setanythingelse() - this.state = newState - - // if the user-selected model is no longer in the list, update the selection for each feature that needs it to something relevant (the 0th model available, or null) - if (modelsListChanged) { - for (const featureName of featureNames) { - - const currentSelection = newModelSelectionOfFeature[featureName] - const selnIdx = currentSelection === null ? -1 : newModelsList.findIndex(m => modelSelectionsEqual(m.selection, currentSelection)) - - if (selnIdx === -1) { - if (newModelsList.length !== 0) - this.setModelSelectionOfFeature(featureName, newModelsList[0].selection, { doNotApplyEffects: true }) - else - this.setModelSelectionOfFeature(featureName, null, { doNotApplyEffects: true }) - } - } - } + this.state = _updatedValidatedState(newState) await this._storeState() - this._onDidChangeState.fire('settingsOfProvider') + this._onDidChangeState.fire() + } @@ -227,7 +264,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } this.state = newState await this._storeState() - this._onDidChangeState.fire(['globalSettings', settingName]) + this._onDidChangeState.fire() } @@ -247,7 +284,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { return await this._storeState() - this._onDidChangeState.fire('modelSelectionOfFeature') + this._onDidChangeState.fire() } diff --git a/src/vs/platform/void/common/voidSettingsTypes.ts b/src/vs/platform/void/common/voidSettingsTypes.ts index 26758593..c9eadefd 100644 --- a/src/vs/platform/void/common/voidSettingsTypes.ts +++ b/src/vs/platform/void/common/voidSettingsTypes.ts @@ -4,7 +4,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ - +import { VoidSettingsState } from './voidSettingsService.js' export type VoidModelInfo = { @@ -87,10 +87,11 @@ export const defaultDeepseekModels = modelInfoOfDefaultModelNames([ // https://console.groq.com/docs/models export const defaultGroqModels = modelInfoOfDefaultModelNames([ - "distil-whisper-large-v3-en", + "llama3-70b-8192", "llama-3.3-70b-versatile", "llama-3.1-8b-instant", - "gemma2-9b-it" + "gemma2-9b-it", + "mixtral-8x7b-32768" ]) @@ -186,28 +187,23 @@ export const customSettingNamesOfProvider = (providerName: ProviderName) => { return Object.keys(defaultProviderSettings[providerName]) as CustomSettingName[] } -export const getProvidersWithoutModels = (settingsOfProvider: SettingsOfProvider) => { - return Object.entries(settingsOfProvider) - .filter(([name, provider]) => provider._enabled && provider.models.length === 0) - .map(([name]) => name) -} type CommonProviderSettings = { - _enabled: boolean | undefined, // undefined initially, computed when user types in all fields + _didFillInProviderSettings: boolean | undefined, // undefined initially, computed when user types in all fields models: VoidModelInfo[], } -export type SettingsForProvider = CustomProviderSettings & CommonProviderSettings +export type SettingsAtProvider = CustomProviderSettings & CommonProviderSettings // part of state export type SettingsOfProvider = { - [providerName in ProviderName]: SettingsForProvider + [providerName in ProviderName]: SettingsAtProvider } -export type SettingName = keyof SettingsForProvider +export type SettingName = keyof SettingsAtProvider @@ -231,7 +227,7 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn } else if (providerName === 'deepseek') { return { - title: 'DeepSeek', + title: 'DeepSeek.com API', } } else if (providerName === 'openRouter') { @@ -252,17 +248,17 @@ export const displayInfoOfProviderName = (providerName: ProviderName): DisplayIn } else if (providerName === 'gemini') { return { - title: 'Gemini', + title: 'Gemini API', } } else if (providerName === 'groq') { return { - title: 'Groq', + title: 'Groq API', } } else if (providerName === 'mistral') { return { - title: 'Mistral', + title: 'Mistral API', } } @@ -316,7 +312,7 @@ export const displayInfoOfSettingName = (providerName: ProviderName, settingName undefined, } } - else if (settingName === '_enabled') { + else if (settingName === '_didFillInProviderSettings') { return { title: '(never)', placeholder: '(never)', @@ -380,55 +376,55 @@ export const defaultSettingsOfProvider: SettingsOfProvider = { ...defaultCustomSettings, ...defaultProviderSettings.anthropic, ...voidInitModelOptions.anthropic, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, openAI: { ...defaultCustomSettings, ...defaultProviderSettings.openAI, ...voidInitModelOptions.openAI, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, deepseek: { ...defaultCustomSettings, ...defaultProviderSettings.deepseek, ...voidInitModelOptions.deepseek, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, gemini: { ...defaultCustomSettings, ...defaultProviderSettings.gemini, ...voidInitModelOptions.gemini, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, mistral: { ...defaultCustomSettings, ...defaultProviderSettings.mistral, ...voidInitModelOptions.mistral, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, groq: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.groq, ...voidInitModelOptions.groq, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, openRouter: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openRouter, ...voidInitModelOptions.openRouter, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, openAICompatible: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.openAICompatible, ...voidInitModelOptions.openAICompatible, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, ollama: { // aggregator ...defaultCustomSettings, ...defaultProviderSettings.ollama, ...voidInitModelOptions.ollama, - _enabled: undefined, + _didFillInProviderSettings: undefined, }, } @@ -448,20 +444,16 @@ export const displayInfoOfFeatureName = (featureName: FeatureName) => { if (featureName === 'Autocomplete') return 'Autocomplete' else if (featureName === 'Ctrl+K') - return 'Quick Edit' + return 'Quick-Edit' else if (featureName === 'Ctrl+L') - return 'Sidebar Chat' + return 'Chat' else if (featureName === 'FastApply') - return 'Fast Apply' + return 'Apply' else throw new Error(`Feature Name ${featureName} not allowed`) } - - - - // the models of these can be refreshed (in theory all can, but not all should) export const refreshableProviderNames = localProviderNames export type RefreshableProviderName = typeof refreshableProviderNames[number] @@ -471,6 +463,45 @@ export type RefreshableProviderName = typeof refreshableProviderNames[number] +// use this in isFeatuerNameDissbled +export const isProviderNameDisabled = (providerName: ProviderName, settingsState: VoidSettingsState) => { + + const settingsAtProvider = settingsState.settingsOfProvider[providerName] + const isAutodetected = (refreshableProviderNames as string[]).includes(providerName) + + const isDisabled = settingsAtProvider.models.length === 0 + if (isDisabled) { + return isAutodetected ? 'providerNotAutoDetected' : (!settingsAtProvider._didFillInProviderSettings ? 'notFilledIn' : 'addModel') + } + return false +} + +export const isFeatureNameDisabled = (featureName: FeatureName, settingsState: VoidSettingsState) => { + // if has a selected provider, check if it's enabled + const selectedProvider = settingsState.modelSelectionOfFeature[featureName] + + if (selectedProvider) { + const { providerName } = selectedProvider + return isProviderNameDisabled(providerName, settingsState) + } + + // if there are any models they can turn on, tell them that + const canTurnOnAModel = !!providerNames.find(providerName => settingsState.settingsOfProvider[providerName].models.filter(m => m.isHidden).length !== 0) + if (canTurnOnAModel) return 'needToEnableModel' + + // if there are any providers filled in, then they just need to add a model + const anyFilledIn = !!providerNames.find(providerName => settingsState.settingsOfProvider[providerName]._didFillInProviderSettings) + if (anyFilledIn) return 'addModel' + + return 'addProvider' +} + + + + + + + export type GlobalSettings = { diff --git a/src/vs/platform/void/electron-main/llmMessage/openai.ts b/src/vs/platform/void/electron-main/llmMessage/openai.ts index 8b7afd94..268eadfb 100644 --- a/src/vs/platform/void/electron-main/llmMessage/openai.ts +++ b/src/vs/platform/void/electron-main/llmMessage/openai.ts @@ -99,6 +99,7 @@ export const sendOpenAIFIM: _InternalSendLLMFIMMessageFnType = ({ messages, onTe .create(options) .then(async response => { // TODO!!! + console.log('RESPONSE', response) }) } diff --git a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts index 8b393f1a..7cea9d5a 100644 --- a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts +++ b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts @@ -12,6 +12,7 @@ import { sendOpenAIChat } from './openai.js'; import { sendGeminiChat } from './gemini.js'; import { sendGroqChat } from './groq.js'; import { sendMistralChat } from './mistral.js'; +import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js'; const cleanChatMessages = (messages: LLMChatMessage[]): _InternalLLMChatMessage[] => { @@ -49,7 +50,7 @@ const cleanChatMessages = (messages: LLMChatMessage[]): _InternalLLMChatMessage[ export const sendLLMMessage = ({ - type, + messagesType, aiInstructions, messages: messages_, onText: onText_, @@ -66,20 +67,20 @@ export const sendLLMMessage = ({ ) => { // messages.unshift({ role: 'system', content: aiInstructions }) - const messagesArr = type === 'sendChatMessage' ? cleanChatMessages(messages_) : [] + const messagesArr = messagesType === 'chatMessages' ? cleanChatMessages(messages_) : [] // only captures number of messages and message "shape", no actual code, instructions, prompts, etc const captureLLMEvent = (eventId: string, extras?: object) => { metricsService.capture(eventId, { providerName, modelName, - ...type === 'sendChatMessage' ? { + ...messagesType === 'chatMessages' ? { numMessages: messagesArr?.length, messagesShape: messagesArr?.map(msg => ({ role: msg.role, length: msg.content.length })), origNumMessages: messages_?.length, origMessagesShape: messages_?.map(msg => ({ role: msg.role, length: msg.content.length })), - } : type === 'sendFIMMessage' ? { + } : messagesType === 'FIMMessage' ? { prefixLength: messages_.prefix.length, suffixLength: messages_.suffix.length, } : {}, @@ -109,6 +110,11 @@ export const sendLLMMessage = ({ const onError: OnError = ({ message: error, fullError }) => { if (_didAbort) return console.error('sendLLMMessage onError:', error) + + // handle failed to fetch errors, which give 0 information by design + if (error === 'TypeError: fetch failed') + error = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void Settings, or your local model provider like Ollama is powered off.` + captureLLMEvent(`${loggingName} - Error`, { error }) onError_({ message: error, fullError }) } @@ -139,8 +145,8 @@ export const sendLLMMessage = ({ break; case 'ollama': if ( // TODO @andrew in future we want to use our own templates instead of using ollamaFIM - type === 'sendFIMMessage' - && settingsOfProvider['ollama']._enabled + messagesType === 'FIMMessage' + && settingsOfProvider['ollama']._didFillInProviderSettings && settingsOfProvider['ollama'].models.some(m => !m.isHidden) ) sendOllamaFIM({ messages: messages_, onText, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, providerName }) diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index 64d181f0..6b67c6be 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -785,13 +785,13 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ } console.log('BB') - console.log(predictionType) + console.log('type', predictionType) // set parameters of `newAutocompletion` appropriately newAutocompletion.llmPromise = new Promise((resolve, reject) => { const requestId = this._llmMessageService.sendLLMMessage({ - type: 'sendFIMMessage', + messagesType: 'FIMMessage', messages: { prefix: llmPrefix, suffix: llmSuffix, diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 9a0746ed..03683732 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -80,7 +80,7 @@ export type ThreadsState = { export type ThreadStreamState = { [threadId: string]: undefined | { - error?: { message: string, fullError: Error | null }; + error?: { message: string, fullError: Error | null, }; messageSoFar?: string; streamingToken?: string; } @@ -311,12 +311,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setStreamState(threadId, { error: undefined }) const llmCancelToken = this._llmMessageService.sendLLMMessage({ - type: 'sendChatMessage', + messagesType: 'chatMessages', logging: { loggingName: 'Chat' }, useProviderFor: 'Ctrl+L', messages: [ { role: 'system', content: chat_systemMessage }, - ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(null)' })), + ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(empty model output)' })), ], onText: ({ newText, fullText }) => { this._setStreamState(threadId, { messageSoFar: fullText }) diff --git a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts index 09da6af3..d4d7d065 100644 --- a/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts +++ b/src/vs/workbench/contrib/void/browser/inlineDiffsService.ts @@ -102,13 +102,13 @@ const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number // similar to ServiceLLM export type StartApplyingOpts = { - featureName: 'Ctrl+K'; + from: 'QuickEdit'; diffareaid: number; // id of the CtrlK area (contains text selection) } | { - featureName: 'Ctrl+L'; + from: 'Chat'; applyStr: string; } | { - featureName: 'Autocomplete'; + from: 'Autocomplete'; range: IRange; userMessage: string; } @@ -1209,13 +1209,13 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { private _initializeStartApplying(opts: StartApplyingOpts): DiffZone | undefined { - const { featureName } = opts + const { from } = opts let startLine: number let endLine: number let uri: URI - if (featureName === 'Ctrl+L') { + if (from === 'Chat') { const uri_ = this._getActiveEditorURI() if (!uri_) return @@ -1231,7 +1231,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { endLine = numLines } - else if (featureName === 'Ctrl+K') { + else if (from === 'QuickEdit') { const { diffareaid } = opts const ctrlKZone = this.diffAreaOfId[diffareaid] if (ctrlKZone.type !== 'CtrlKZone') return @@ -1242,7 +1242,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { endLine = endLine_ } else { - throw new Error(`Void: diff.type not recognized on: ${featureName}`) + throw new Error(`Void: diff.type not recognized on: ${from}`) } const currentFileStr = this._readURI(uri) @@ -1278,7 +1278,7 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) this._onDidAddOrDeleteDiffZones.fire({ uri }) - if (featureName === 'Ctrl+K') { + if (from === 'QuickEdit') { const { diffareaid } = opts const ctrlKZone = this.diffAreaOfId[diffareaid] if (ctrlKZone.type !== 'CtrlKZone') return @@ -1289,14 +1289,14 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { // now handle messages let messages: LLMChatMessage[] - if (featureName === 'Ctrl+L') { + if (from === 'Chat') { const userContent = fastApply_userMessage({ originalCode, applyStr: opts.applyStr, uri }) messages = [ { role: 'system', content: fastApply_systemMessage, }, { role: 'user', content: userContent, } ] } - else if (featureName === 'Ctrl+K') { + else if (from === 'QuickEdit') { const { diffareaid } = opts const ctrlKZone = this.diffAreaOfId[diffareaid] if (ctrlKZone.type !== 'CtrlKZone') return @@ -1323,14 +1323,14 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { ] // } } - else { throw new Error(`featureName ${featureName} is invalid`) } + else { throw new Error(`featureName ${from} is invalid`) } const onDone = (hadError: boolean) => { diffZone._streamState = { isStreaming: false, } this._onDidChangeStreaming.fire({ uri, diffareaid: diffZone.diffareaid }) - if (featureName === 'Ctrl+K') { + if (from === 'QuickEdit') { const ctrlKZone = this.diffAreaOfId[opts.diffareaid] as CtrlKZone ctrlKZone._linkedStreamingDiffZone = null @@ -1350,11 +1350,11 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { const extractText = (fullText: string, recentlyAddedTextLen: number) => { - if (featureName === 'Ctrl+K') { + if (from === 'QuickEdit') { if (isOllamaFIM) return fullText return extractCodeFromFIM({ text: fullText, recentlyAddedTextLen, midTag: modelFimTags.midTag }) } - else if (featureName === 'Ctrl+L') { + else if (from === 'Chat') { return extractCodeFromRegular({ text: fullText, recentlyAddedTextLen }) } throw 1 @@ -1367,9 +1367,9 @@ class InlineDiffsService extends Disposable implements IInlineDiffsService { let prevIgnoredSuffix = '' streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - type: 'sendChatMessage', - useProviderFor: opts.featureName === 'Ctrl+L' ? 'FastApply' : 'Ctrl+K', - logging: { loggingName: `startApplying - ${featureName}` }, + messagesType: 'chatMessages', + useProviderFor: opts.from === 'Chat' ? 'FastApply' : 'Ctrl+K', + logging: { loggingName: `startApplying - ${from}` }, messages, onText: ({ newText: newText_ }) => { diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 6d7fc46b..15f67d9a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -45,7 +45,7 @@ const CodeButtonsOnHover = ({ text }: { text: string }) => { const onApply = useCallback(() => { inlineDiffService.startApplying({ - featureName: 'Ctrl+L', + from: 'Chat', applyStr: text, }) metricsService.capture('Apply Code', { length: text.length }) // capture the length only @@ -186,18 +186,18 @@ const RenderToken = ({ token, nested = false, noSpace = false }: { token: Token ))} - if (nested) - return contents - return

{contents}

+ if (nested) return contents + + return

+ {contents} +

} if (t.type === "html") { return ( -
-				{``}
+			

{t.raw} - {``} -

+

) } diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index f1a3456a..7edf1e5b 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -7,11 +7,12 @@ import React, { FormEvent, useCallback, useEffect, useRef, useState } from 'reac import { useSettingsState, useSidebarState, useChatThreadsState, useQuickEditState, useAccessor } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; -import { ButtonStop, ButtonSubmit, IconX, VoidInputForm } from '../sidebar-tsx/SidebarChat.js'; +import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js'; import { ModelDropdown } from '../void-settings-tsx/ModelDropdown.js'; import { VOID_CTRL_K_ACTION_ID } from '../../../actionIDs.js'; import { useRefState } from '../util/helpers.js'; import { useScrollbarStyles } from '../util/useScrollbarStyles.js'; +import { isFeatureNameDisabled } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; export const QuickEditChat = ({ diffareaid, @@ -42,9 +43,11 @@ export const QuickEditChat = ({ }, [onChangeHeight]); + const settingsState = useSettingsState() + // state of current message const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!(initText ?? '')) // the user's instructions - const isDisabled = instructionsAreEmpty + const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Ctrl+K', settingsState) const [currStreamingDiffZoneRef, setCurrentlyStreamingDiffZone] = useRefState(initStreamingDiffZoneId) const isStreaming = currStreamingDiffZoneRef.current !== null @@ -55,7 +58,7 @@ export const QuickEditChat = ({ textAreaFnsRef.current?.disable() const id = inlineDiffsService.startApplying({ - featureName: 'Ctrl+K', + from: 'QuickEdit', diffareaid: diffareaid, }) setCurrentlyStreamingDiffZone(id ?? null) @@ -78,8 +81,10 @@ export const QuickEditChat = ({ const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_K_ACTION_ID)?.getLabel() + const chatAreaRef = useRef(null) return
- { textAreaRef.current?.focus() }} > - +
diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx index 2aa7da49..425ce3c9 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx @@ -23,8 +23,9 @@ export const ErrorDisplay = ({ const [isExpanded, setIsExpanded] = useState(false); const details = errorDetails(fullError) + const isExpandable = !!details - const message = message_ === 'TypeError: fetch failed' ? `TypeError: fetch failed. This likely means you specified the wrong endpoint in Void Settings, or a provider like Ollama is powered off.` : message_ + '' + const message = message_ + '' return (
@@ -45,7 +46,7 @@ export const ErrorDisplay = ({
- {details && ( + {isExpandable && (
} @@ -863,8 +873,8 @@ export const SidebarChat = () => { } }, [onSubmit]) const inputForm =
0 ? 'absolute bottom-0' : ''}`}> - { showProspectiveSelections={prevMessagesHTML.length === 0} staging={staging} setStaging={setStaging} - // onSelectionsChange={chatThreadsService.setStagingSelections.bind(chatThreadsService)} + onClickAnywhere={() => { textAreaRef.current?.focus() }} featureName="Ctrl+L" > { fnsRef={textAreaFnsRef} multiline={true} /> - +
return
diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index a70859eb..e62e7a9e 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -303,9 +303,9 @@ export const VoidCheckBox = ({ label, value, onClick, className }: { label: stri -export const VoidCustomSelectBox = ({ +export const VoidCustomDropdownBox = ({ options, - selectedOption: selectedOption_, + selectedOption, onChangeOption, getOptionDropdownName, getOptionDisplayName, @@ -316,7 +316,7 @@ export const VoidCustomSelectBox = ({ gap = 0, }: { options: T[]; - selectedOption?: T; + selectedOption: T | undefined; onChangeOption: (newValue: T) => void; getOptionDropdownName: (option: T) => string; getOptionDisplayName: (option: T) => string; @@ -375,14 +375,12 @@ export const VoidCustomSelectBox = ({ strategy: 'fixed', }); - // if the selected option is null, use the 0th option + // if the selected option is null, set the selection to the 0th option useEffect(() => { - if (!options[0]) return - if (!selectedOption_) { - onChangeOption(options[0]); - } - }, [selectedOption_, options]) - const selectedOption = !selectedOption_ ? options[0] : selectedOption_ + if (options.length === 0) return + if (selectedOption) return + onChangeOption(options[0]) + }, [selectedOption, onChangeOption, options]) // Handle clicks outside useEffect(() => { @@ -409,6 +407,9 @@ export const VoidCustomSelectBox = ({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen, refs.floating, refs.reference]); + if (!selectedOption) + return null + return (
{/* Hidden measurement div */} diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx index 6b05201f..8016b4b8 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/ModelDropdown.tsx @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------*/ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { FeatureName, featureNames, getProvidersWithoutModels, ModelSelection, modelSelectionsEqual, ProviderName, providerNames, SettingsOfProvider } from '../../../../../../../platform/void/common/voidSettingsTypes.js' +import { FeatureName, featureNames, isFeatureNameDisabled, ModelSelection, modelSelectionsEqual, ProviderName, providerNames, SettingsOfProvider } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import { useSettingsState, useRefreshModelState, useAccessor } from '../util/services.js' -import { _VoidSelectBox, VoidCustomSelectBox } from '../util/inputs.js' +import { _VoidSelectBox, VoidCustomDropdownBox } from '../util/inputs.js' import { SelectBox } from '../../../../../../../base/browser/ui/selectBox/selectBox.js' import { IconWarning } from '../sidebar-tsx/SidebarChat.js' import { VOID_OPEN_SETTINGS_ACTION_ID, VOID_TOGGLE_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js' import { ModelOption } from '../../../../../../../platform/void/common/voidSettingsService.js' +import { WarningBox } from './WarningBox.js' const optionsEqual = (m1: ModelOption[], m2: ModelOption[]) => { if (m1.length !== m2.length) return false @@ -25,13 +26,13 @@ const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], feat const voidSettingsService = accessor.get('IVoidSettingsService') const selection = voidSettingsService.state.modelSelectionOfFeature[featureName] - const selectedOption = selection ? voidSettingsService.state._modelOptions.find(v => modelSelectionsEqual(v.selection, selection)) : options[0] + const selectedOption = selection ? voidSettingsService.state._modelOptions.find(v => modelSelectionsEqual(v.selection, selection))! : options[0] const onChangeOption = useCallback((newOption: ModelOption) => { voidSettingsService.setModelSelectionOfFeature(featureName, newOption.selection) }, [voidSettingsService, featureName]) - return // } -const MemoizedModelSelectBox = ({ featureName }: { featureName: FeatureName }) => { + + +const MemoizedModelDropdown = ({ featureName }: { featureName: FeatureName }) => { const settingsState = useSettingsState() const oldOptionsRef = useRef([]) const [memoizedOptions, setMemoizedOptions] = useState(oldOptionsRef.current) + useEffect(() => { const oldOptions = oldOptionsRef.current const newOptions = settingsState._modelOptions @@ -90,46 +94,22 @@ const MemoizedModelSelectBox = ({ featureName }: { featureName: FeatureName }) = } -export const WarningBox = ({ text, onClick, className }: { text: string; onClick?: () => void; className?: string }) => { - - return
- - {text} -
- // return { }} - // /> -} - export const ModelDropdown = ({ featureName }: { featureName: FeatureName }) => { const settingsState = useSettingsState() - const providersWithMissingModels = getProvidersWithoutModels(settingsState.settingsOfProvider) - const accessor = useAccessor() const commandService = accessor.get('ICommandService') const openSettings = () => { commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID); }; - return <> - {providersWithMissingModels.length !== 0 ? - - : settingsState._modelOptions.length === 0 ? - - : - } - + const isDisabled = isFeatureNameDisabled(featureName, settingsState) + if (isDisabled) + return + + return } diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx index 7c5751d7..0cd796e4 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/Settings.tsx @@ -5,17 +5,18 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js' -import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName } from '../../../../../../../platform/void/common/voidSettingsTypes.js' +import { ProviderName, SettingName, displayInfoOfSettingName, providerNames, VoidModelInfo, globalSettingNames, customSettingNamesOfProvider, RefreshableProviderName, refreshableProviderNames, displayInfoOfProviderName, defaultProviderSettings, nonlocalProviderNames, localProviderNames, GlobalSettingName, featureNames, displayInfoOfFeatureName, isProviderNameDisabled } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js' -import { VoidButton, VoidCheckBox, VoidCustomSelectBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' +import { VoidButton, VoidCheckBox, VoidCustomDropdownBox, VoidInputBox, VoidInputBox2, VoidSwitch } from '../util/inputs.js' import { useAccessor, useIsDark, useRefreshModelListener, useRefreshModelState, useSettingsState } from '../util/services.js' import { X, RefreshCw, Loader2, Check, MoveRight } from 'lucide-react' import { useScrollbarStyles } from '../util/useScrollbarStyles.js' import { isWindows, isLinux, isMacintosh } from '../../../../../../../base/common/platform.js' import { URI } from '../../../../../../../base/common/uri.js' import { env } from '../../../../../../../base/common/process.js' -import { WarningBox, ModelDropdown } from './ModelDropdown.js' +import { ModelDropdown } from './ModelDropdown.js' import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js' +import { WarningBox } from './WarningBox.js' const SubtleButton = ({ onClick, text, icon, disabled }: { onClick: () => void, text: string, icon: React.ReactNode, disabled: boolean }) => { @@ -79,7 +80,7 @@ const RefreshableModels = () => { const buttons = refreshableProviderNames.map(providerName => { - if (!settingsState.settingsOfProvider[providerName]._enabled) return null + if (!settingsState.settingsOfProvider[providerName]._didFillInProviderSettings) return null return
@@ -112,7 +113,7 @@ const AddModelMenu = ({ onSubmit }: { onSubmit: () => void }) => {
{/* provider */} - setProviderName(pn)} @@ -199,7 +200,7 @@ export const ModelDump = () => { for (let providerName of providerNames) { const providerSettings = settingsState.settingsOfProvider[providerName] // if (!providerSettings.enabled) continue - modelDump.push(...providerSettings.models.map(model => ({ ...model, providerName, providerEnabled: !!providerSettings._enabled }))) + modelDump.push(...providerSettings.models.map(model => ({ ...model, providerName, providerEnabled: !!providerSettings._didFillInProviderSettings }))) } // sort by hidden @@ -223,7 +224,6 @@ export const ModelDump = () => {
{isNewProviderName ? displayInfoOfProviderName(providerName).title : ''} {modelName} - {/* {`${modelName} (${providerName})`} */}
{/* right part is anything that fits */}
@@ -260,7 +260,6 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider const accessor = useAccessor() const voidSettingsService = accessor.get('IVoidSettingsService') - const voidMetricsService = accessor.get('IMetricsService') let weChangedTextRef = false @@ -284,25 +283,8 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider weChangedTextRef = true instance.value = stateVal as string weChangedTextRef = false - - const isEverySettingPresent = Object.keys(defaultProviderSettings[providerName]).every(key => { - return !!settingsAtProvider[key as keyof typeof settingsAtProvider] - }) - - const shouldEnable = isEverySettingPresent && !settingsAtProvider._enabled // enable if all settings are present and not already enabled - const shouldDisable = !isEverySettingPresent && settingsAtProvider._enabled - - if (shouldEnable) { - voidSettingsService.setSettingOfProvider(providerName, '_enabled', true) - voidMetricsService.capture('Enable Provider', { providerName }) - } - - if (shouldDisable) { - voidSettingsService.setSettingOfProvider(providerName, '_enabled', false) - voidMetricsService.capture('Disable Provider', { providerName }) - } - } + syncInstance() const disposable = voidSettingsService.onDidChangeState(syncInstance) return [disposable] @@ -318,7 +300,10 @@ const ProviderSetting = ({ providerName, settingName }: { providerName: Provider } const SettingsForProvider = ({ providerName }: { providerName: ProviderName }) => { - // const voidSettingsState = useSettingsState() + const voidSettingsState = useSettingsState() + + const needsModel = isProviderNameDisabled(providerName, voidSettingsState) === 'addModel' + // const accessor = useAccessor() // const voidSettingsService = accessor.get('IVoidSettingsService') @@ -349,6 +334,12 @@ const SettingsForProvider = ({ providerName }: { providerName: ProviderName }) = {settingNames.map((settingName, i) => { return })} + + {needsModel ? + providerName === 'ollama' ? + + : + : null}
} diff --git a/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/WarningBox.tsx b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/WarningBox.tsx new file mode 100644 index 00000000..43faedd8 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/void-settings-tsx/WarningBox.tsx @@ -0,0 +1,26 @@ +import { IconWarning } from '../sidebar-tsx/SidebarChat.js'; + + +export const WarningBox = ({ text, onClick, className }: { text: string; onClick?: () => void; className?: string }) => { + + return
+ + {text} +
+ // return { }} + // /> +} diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index f05db4a8..0b60ae8f 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -140,13 +140,12 @@ registerAction2(class extends Action2 { const setSelections = (s: StagingSelectionItem[]) => setStaging({ ...staging, selections: s }) // if matches with existing selection, overwrite (since text may change) - const currentStagingEltIdx = findMatchingStagingIndex(selections, selection) - - if (currentStagingEltIdx !== undefined && currentStagingEltIdx !== -1) { + const matchingStagingEltIdx = findMatchingStagingIndex(selections, selection) + if (matchingStagingEltIdx !== undefined && matchingStagingEltIdx !== -1) { setSelections([ - ...selections!.slice(0, currentStagingEltIdx), + ...selections!.slice(0, matchingStagingEltIdx), selection, - ...selections!.slice(currentStagingEltIdx + 1, Infinity) + ...selections!.slice(matchingStagingEltIdx + 1, Infinity) ]) } // if no match, add it