diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 0027c42f..6e5c6051 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -33,6 +33,13 @@ export type FileSelection = { export type StagingSelectionItem = CodeSelection | FileSelection +export type StagingInfo = { + isBeingEdited: boolean; + selections: StagingSelectionItem[] | null; // staging selections in edit mode + text: string; +} + + // WARNING: changing this format is a big deal!!!!!! need to migrate old format to new format on users' computers so people don't get errors. export type ChatMessage = | { @@ -40,7 +47,7 @@ export type ChatMessage = content: string | null; // content sent to the llm - allowed to be '', will be replaced with (empty) displayContent: string | null; // content displayed to user - allowed to be '', will be ignored selections: StagingSelectionItem[] | null; // the user's selection - stagingSelections: StagingSelectionItem[] | null; // staging selections in edit mode + staging: StagingInfo | null } | { role: 'assistant'; @@ -60,7 +67,7 @@ export type ChatThreads = { createdAt: string; // ISO string lastModified: string; // ISO string messages: ChatMessage[]; - stagingSelections: StagingSelectionItem[] | null; + staging: StagingInfo focusedMessageIdx?: number | undefined; // index of the message that is being edited (undefined if none) }; } @@ -87,7 +94,11 @@ const newThreadObject = () => { lastModified: now, messages: [], focusedMessageIdx: undefined, - stagingSelections: null, + staging: { + isBeingEdited: true, + selections: [], + text: '', + } } satisfies ChatThreads[string] } @@ -110,9 +121,10 @@ export interface IChatThreadService { switchToThread(threadId: string): void; getFocusedMessageIdx(): number | undefined; + isFocusingMessage(): boolean; setFocusedMessageIdx(messageIdx: number | undefined): void; - _useStagingSelectionsState(messageIdx?: number | undefined): readonly [StagingSelectionItem[], (selections: StagingSelectionItem[]) => void]; + _useFocusedStagingState(messageIdx?: number | undefined): readonly [StagingInfo, (selections: StagingInfo) => void]; editUserMessageAndStreamResponse(userMessage: string, messageIdx: number): Promise; addUserMessageAndStreamResponse(userMessage: string): Promise; @@ -152,6 +164,9 @@ class ChatThreadService extends Disposable implements IChatThreadService { // for now just write the version, anticipating bigger changes in the future where we'll want to access this this._storageService.store(THREAD_VERSION_KEY, THREAD_VERSION, StorageScope.APPLICATION, StorageTarget.USER) + + + setInterval(() => { console.log(this.getFocusedMessageIdx()) }, 1000) } @@ -218,24 +233,25 @@ class ChatThreadService extends Disposable implements IChatThreadService { }, true) // stream the edit - this.addUserMessageAndStreamResponse(userMessage, messageToReplace.stagingSelections) + this.addUserMessageAndStreamResponse(userMessage, messageToReplace.staging) } - async addUserMessageAndStreamResponse(userMessage: string, selectionsOverride?: StagingSelectionItem[] | null) { + async addUserMessageAndStreamResponse(userMessage: string, stagingOverride?: StagingInfo | null) { const thread = this.getCurrentThread() const threadId = thread.id - let defaultThreadSelections = thread.stagingSelections + let defaultThreadSelections = thread.staging - const currSelns = selectionsOverride ?? defaultThreadSelections ?? [] // don't use _useFocusedStagingState to avoid race conditions with focusing + const currStaging = stagingOverride ?? defaultThreadSelections ?? [] // don't use _useFocusedStagingState to avoid race conditions with focusing + const { selections: currSelns, } = currStaging // add user's message to chat history const instructions = userMessage const content = await chat_userMessage(instructions, currSelns, this._modelService) - const userHistoryElt: ChatMessage = { role: 'user', content: content, displayContent: instructions, selections: currSelns, stagingSelections: [], } + const userHistoryElt: ChatMessage = { role: 'user', content: content, displayContent: instructions, selections: currSelns, staging: null, } this._addMessageToThread(threadId, userHistoryElt) this._setStreamState(threadId, { error: undefined }) @@ -288,7 +304,21 @@ class ChatThreadService extends Disposable implements IChatThreadService { getFocusedMessageIdx() { const thread = this.getCurrentThread() - return thread.focusedMessageIdx + + // get the focusedMessageIdx + const focusedMessageIdx = thread.focusedMessageIdx + if (focusedMessageIdx === undefined) return; + + // check that the message is actually being edited + const focusedMessage = thread.messages[focusedMessageIdx] + if (focusedMessage.role !== 'user') return; + if (!focusedMessage.staging?.isBeingEdited) return; + + return focusedMessageIdx + } + + isFocusingMessage() { + return this.getFocusedMessageIdx() !== undefined } switchToThread(threadId: string) { @@ -357,7 +387,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } // set thread.messages[messageIdx].stagingSelections - private setEditMessageStagingSelections(stagingSelections: StagingSelectionItem[], messageIdx: number): void { + private setEditMessageStaging(staging: StagingInfo, messageIdx: number): void { const thread = this.getCurrentThread() const message = thread.messages[messageIdx] @@ -369,7 +399,10 @@ class ChatThreadService extends Disposable implements IChatThreadService { [thread.id]: { ...thread, messages: thread.messages.map((m, i) => - i === messageIdx ? { ...m, stagingSelections } : m + i === messageIdx ? { + ...m, + staging, + } : m ) } } @@ -378,8 +411,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { } // set thread.stagingSelections - private setDefaultStagingSelections(stagingSelections: StagingSelectionItem[]): void { - + private setDefaultStaging(staging: StagingInfo): void { console.log('Default1') const thread = this.getCurrentThread() @@ -391,7 +423,7 @@ class ChatThreadService extends Disposable implements IChatThreadService { ...this.state.allThreads, [thread.id]: { ...thread, - stagingSelections + staging, } } }, true) @@ -399,10 +431,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { } // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) - _useStagingSelectionsState(messageIdx?: number | undefined) { + _useFocusedStagingState(messageIdx?: number | undefined) { - let staging: StagingSelectionItem[] = [] - let setStaging: (selections: StagingSelectionItem[]) => void = () => { } + const defaultStaging = { isBeingEdited: false, selections: [], text: '' } + + let staging: StagingInfo = defaultStaging + let setStaging: (selections: StagingInfo) => void = () => { } const thread = this.getCurrentThread() const isFocusingMessage = messageIdx !== undefined @@ -410,21 +444,20 @@ class ChatThreadService extends Disposable implements IChatThreadService { const message = thread.messages[messageIdx!] if (message.role === 'user') { - staging = message.stagingSelections || [] - setStaging = (s) => this.setEditMessageStagingSelections(s, messageIdx) + staging = message.staging || defaultStaging + setStaging = (s) => this.setEditMessageStaging(s, messageIdx) } } else { // is editing the default input box - staging = thread.stagingSelections || [] - setStaging = this.setDefaultStagingSelections.bind(this) + staging = thread.staging || defaultStaging + setStaging = this.setDefaultStaging.bind(this) } return [staging, setStaging] as const } - } registerSingleton(IChatThreadService, ChatThreadService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 9f4ccf9f..243ce679 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -7,7 +7,7 @@ import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, K import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useUriState, useSettingsState } from '../util/services.js'; -import { ChatMessage, StagingSelectionItem } from '../../../chatThreadService.js'; +import { ChatMessage, StagingInfo, StagingSelectionItem } from '../../../chatThreadService.js'; import { BlockCode } from '../markdown/BlockCode.js'; import { ChatMarkdownRender } from '../markdown/ChatMarkdownRender.js'; @@ -152,9 +152,12 @@ interface VoidInputFormProps { className?: string; showModelDropdown?: boolean; showSelections?: boolean; - selections?: any[]; showProspectiveSelections?: boolean; - onSelectionsChange?: (selections: any[]) => void; + + staging?: StagingInfo + setStaging?: (s: StagingInfo) => void + // selections?: any[]; + // onSelectionsChange?: (selections: any[]) => void; // Optional close button onClose?: () => void; @@ -172,9 +175,9 @@ export const VoidInputForm: React.FC = ({ showModelDropdown = true, featureName, showSelections = false, - selections = [], showProspectiveSelections = true, - onSelectionsChange, + staging, + setStaging, }) => { return (
= ({ `} > {/* Selections section */} - {showSelections && onSelectionsChange && ( + {showSelections && staging && setStaging && ( setStaging({ ...staging, selections })} showProspectiveSelections={showProspectiveSelections} /> )} @@ -541,23 +544,36 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM const chatThreadsService = accessor.get('IChatThreadService') // edit mode state - const [staging, setStaging] = chatThreadsService._useStagingSelectionsState(messageIdx) - const [mode, setMode] = useState('display') + const [staging, setStaging] = chatThreadsService._useFocusedStagingState(messageIdx) + const mode: ChatBubbleMode = staging.isBeingEdited ? 'edit' : 'display' const [isHovered, setIsHovered] = useState(false) - const textAreaRef = useRef(null) + const [isDisabled, setIsDisabled] = useState(false) + const [textAreaRefState, setTextAreaRef] = useState(null) const textAreaFnsRef = useRef(null) - const [isEditDisabled, setIsEditDisabled] = useState(false) + // initialize on first render, and when edit was just enabled + const _mustInitialize = useRef(true) + const _justEnabledEdit = useRef(false) useEffect(() => { - if (role === 'user') { - setStaging(chatMessage.selections || []) + const canInitialize = role === 'user' && mode === 'edit' && textAreaRefState + const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current + if (canInitialize && shouldInitialize) { + setStaging({ + ...staging, + selections: chatMessage.selections || [], + text: chatMessage.displayContent || '', + }) if (textAreaFnsRef.current) textAreaFnsRef.current.setValue(chatMessage.displayContent || '') - if (textAreaRef.current) - textAreaRef.current.value = chatMessage.displayContent || '' - } - }, [role]) - const EditSymbol = mode === 'edit' ? Pencil : X + textAreaRefState.focus(); + + _justEnabledEdit.current = false + _mustInitialize.current = false + } + + }, [role, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) + + const EditSymbol = mode === 'display' ? Pencil : X // set chat bubble contents let chatbubbleContents: React.ReactNode @@ -572,18 +588,21 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM const onSubmit = async () => { - if (isEditDisabled) return; - if (!textAreaRef.current) return; + if (isDisabled) return; + if (!textAreaRefState) return; if (messageIdx === undefined) return; - // reset visual state - setMode('display'); + // cancel any streams on this thread + const thread = chatThreadsService.getCurrentThread() + chatThreadsService.cancelStreaming(thread.id) + + // reset state + setStaging({ ...staging, isBeingEdited: false }) + chatThreadsService.setFocusedMessageIdx(undefined) // stream the edit - const userMessage = textAreaRef.current.value; + const userMessage = textAreaRefState.value; await chatThreadsService.editUserMessageAndStreamResponse(userMessage, messageIdx) - - textAreaFnsRef.current?.setValue(''); } const onAbort = () => { @@ -591,14 +610,14 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM chatThreadsService.cancelStreaming(threadId) } - const onKeyDown = useCallback((e: KeyboardEvent) => { + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - setMode('display') + setStaging({ ...staging, isBeingEdited: false }) } if (e.key === 'Enter' && !e.shiftKey) { onSubmit() } - }, [onSubmit, setMode]) + } if (!chatMessage.content && !isLoading) { // don't show if empty and not loading (if loading, want to show) return null @@ -609,19 +628,20 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM onSubmit={onSubmit} onAbort={onAbort} isStreaming={false} - isDisabled={isEditDisabled} + isDisabled={isDisabled} showSelections={true} - selections={staging || []} showProspectiveSelections={false} - onSelectionsChange={setStaging} featureName="Ctrl+L" + staging={staging} + setStaging={setStaging} > setIsEditDisabled(!text)} + onChangeText={(text) => setIsDisabled(!text)} + onFocus={() => { console.log('CHAT FOCUS'); chatThreadsService.setFocusedMessageIdx(messageIdx) }} onKeyDown={onKeyDown} - ref={textAreaRef} fnsRef={textAreaFnsRef} multiline={true} /> @@ -670,8 +690,14 @@ const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatM ${isHovered ? 'opacity-100' : 'opacity-0'} `} onClick={() => { - setMode(m => m === 'display' ? 'edit' : 'display') - chatThreadsService.setFocusedMessageIdx(messageIdx) + if (mode === 'display') { + setStaging({ ...staging, isBeingEdited: true }) + chatThreadsService.setFocusedMessageIdx(messageIdx) + _justEnabledEdit.current = true + } else if (mode === 'edit') { + setStaging({ ...staging, isBeingEdited: false }) + chatThreadsService.setFocusedMessageIdx(undefined) + } }} />} @@ -694,8 +720,8 @@ export const SidebarChat = () => { useEffect(() => { const disposables: IDisposable[] = [] disposables.push( - sidebarStateService.onDidFocusChat(() => { textAreaRef.current?.focus(); chatThreadsService.setFocusedMessageIdx(undefined) }), - sidebarStateService.onDidBlurChat(() => { textAreaRef.current?.blur() }) + sidebarStateService.onDidFocusChat(() => { !chatThreadsService.isFocusingMessage() && textAreaRef.current?.focus() }), + sidebarStateService.onDidBlurChat(() => { !chatThreadsService.isFocusingMessage() && textAreaRef.current?.blur() }) ) return () => disposables.forEach(d => d.dispose()) }, [sidebarStateService, textAreaRef]) @@ -707,7 +733,7 @@ export const SidebarChat = () => { const currentThread = chatThreadsService.getCurrentThread() const previousMessages = currentThread?.messages ?? [] - const [selections, setSelections] = chatThreadsService._useStagingSelectionsState() + const [staging, setStaging] = chatThreadsService._useFocusedStagingState() // stream state const currThreadStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) @@ -743,7 +769,7 @@ export const SidebarChat = () => { await chatThreadsService.addUserMessageAndStreamResponse(userMessage) console.log('done streaming',) - setSelections([]) // clear staging + setStaging({ ...staging, selections: [], text: '' }) // clear staging console.log('set staging',) textAreaFnsRef.current?.setValue('') console.log('set value',) @@ -751,7 +777,7 @@ export const SidebarChat = () => { console.log('textAreaRef', textAreaRef.current) console.log('focus',) - }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, setSelections]) + }, [chatThreadsService, isDisabled, isStreaming, textAreaRef, textAreaFnsRef, staging, setStaging]) const onAbort = () => { const threadId = currentThread.id @@ -772,7 +798,7 @@ export const SidebarChat = () => { const prevMessagesHTML = useMemo(() => { return previousMessages.map((message, i) => - + ) }, [previousMessages]) @@ -836,9 +862,9 @@ export const SidebarChat = () => { isStreaming={isStreaming} isDisabled={isDisabled} showSelections={true} - selections={selections || []} showProspectiveSelections={prevMessagesHTML.length === 0} - onSelectionsChange={setSelections} + staging={staging} + setStaging={setStaging} // onSelectionsChange={chatThreadsService.setStagingSelections.bind(chatThreadsService)} featureName="Ctrl+L" > @@ -847,6 +873,7 @@ export const SidebarChat = () => { placeholder={`${keybindingString ? `${keybindingString} to select. ` : ''}Enter instructions...`} onChangeText={onChangeText} onKeyDown={onKeyDown} + onFocus={() => { chatThreadsService.setFocusedMessageIdx(undefined) }} ref={textAreaRef} fnsRef={textAreaFnsRef} multiline={true} 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 357640c0..a70859eb 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 @@ -58,9 +58,11 @@ type InputBox2Props = { className?: string; onChangeText?: (value: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; + onFocus?: (e: React.FocusEvent) => void; + onBlur?: (e: React.FocusEvent) => void; onChangeHeight?: (newHeight: number) => void; } -export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onChangeText }, ref) { +export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { // mirrors whatever is in ref const textAreaRef = useRef(null) @@ -114,6 +116,9 @@ export const VoidInputBox2 = forwardRef(fun adjustHeight() }, [fnsRef, fns, setEnabled, adjustHeight, ref])} + onFocus={onFocus} + onBlur={onBlur} + disabled={!isEnabled} className={`w-full resize-none max-h-[500px] overflow-y-auto text-void-fg-1 placeholder:text-void-fg-3 ${className}`} @@ -335,7 +340,7 @@ export const VoidCustomSelectBox = ({ } = useFloating({ open: isOpen, onOpenChange: setIsOpen, - placement:'bottom-start', + placement: 'bottom-start', middleware: [ offset(gap), @@ -367,7 +372,7 @@ export const VoidCustomSelectBox = ({ }), ], whileElementsMounted: autoUpdate, - strategy:'fixed', + strategy: 'fixed', }); // if the selected option is null, use the 0th option diff --git a/src/vs/workbench/contrib/void/browser/sidebarActions.ts b/src/vs/workbench/contrib/void/browser/sidebarActions.ts index cbcc333e..f05db4a8 100644 --- a/src/vs/workbench/contrib/void/browser/sidebarActions.ts +++ b/src/vs/workbench/contrib/void/browser/sidebarActions.ts @@ -134,22 +134,24 @@ registerAction2(class extends Action2 { // update the staging selections const chatThreadService = accessor.get(IChatThreadService) - const messageIdx = chatThreadService.getFocusedMessageIdx() - const [staging, setStaging] = chatThreadService._useStagingSelectionsState(messageIdx) + const focusedMessageIdx = chatThreadService.getFocusedMessageIdx() + const [staging, setStaging] = chatThreadService._useFocusedStagingState(focusedMessageIdx) + const selections = staging.selections || [] + const setSelections = (s: StagingSelectionItem[]) => setStaging({ ...staging, selections: s }) // if matches with existing selection, overwrite (since text may change) - const currentStagingEltIdx = findMatchingStagingIndex(staging, selection) + const currentStagingEltIdx = findMatchingStagingIndex(selections, selection) if (currentStagingEltIdx !== undefined && currentStagingEltIdx !== -1) { - setStaging([ - ...staging!.slice(0, currentStagingEltIdx), + setSelections([ + ...selections!.slice(0, currentStagingEltIdx), selection, - ...staging!.slice(currentStagingEltIdx + 1, Infinity) + ...selections!.slice(currentStagingEltIdx + 1, Infinity) ]) } // if no match, add it else { - setStaging([...(staging ?? []), selection]) + setSelections([...(selections ?? []), selection]) } }