From 387fd64db0e89b1ecb1490d8fca5870530132393 Mon Sep 17 00:00:00 2001 From: Mathew Pareles Date: Thu, 6 Feb 2025 03:18:48 -0800 Subject: [PATCH] edit ability --- .../contrib/void/browser/chatThreadService.ts | 151 ++++++++++++++++-- .../react/src/sidebar-tsx/SidebarChat.tsx | 104 +++++++++--- .../void/browser/react/src/util/services.tsx | 11 ++ .../contrib/void/browser/sidebarActions.ts | 32 ++-- 4 files changed, 255 insertions(+), 43 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 34b4dad4..0027c42f 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -40,6 +40,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 } | { role: 'assistant'; @@ -59,13 +60,14 @@ export type ChatThreads = { createdAt: string; // ISO string lastModified: string; // ISO string messages: ChatMessage[]; + stagingSelections: StagingSelectionItem[] | null; + focusedMessageIdx?: number | undefined; // index of the message that is being edited (undefined if none) }; } export type ThreadsState = { allThreads: ChatThreads; currentThreadId: string; // intended for internal use only - currentStagingSelections: StagingSelectionItem[] | null; } export type ThreadStreamState = { @@ -84,6 +86,8 @@ const newThreadObject = () => { createdAt: now, lastModified: now, messages: [], + focusedMessageIdx: undefined, + stagingSelections: null, } satisfies ChatThreads[string] } @@ -105,8 +109,12 @@ export interface IChatThreadService { openNewThread(): void; switchToThread(threadId: string): void; - setStaging(stagingSelection: StagingSelectionItem[] | null): void; + getFocusedMessageIdx(): number | undefined; + setFocusedMessageIdx(messageIdx: number | undefined): void; + _useStagingSelectionsState(messageIdx?: number | undefined): readonly [StagingSelectionItem[], (selections: StagingSelectionItem[]) => void]; + + editUserMessageAndStreamResponse(userMessage: string, messageIdx: number): Promise; addUserMessageAndStreamResponse(userMessage: string): Promise; cancelStreaming(threadId: string): void; dismissStreamError(threadId: string): void; @@ -137,7 +145,6 @@ class ChatThreadService extends Disposable implements IChatThreadService { this.state = { allThreads: this._readAllThreads(), currentThreadId: null as unknown as string, // gets set in startNewThread() - currentStagingSelections: null, } // always be in a thread @@ -187,18 +194,50 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setStreamState(threadId, { messageSoFar: undefined, streamingToken: undefined, error }) } - async addUserMessageAndStreamResponse(userMessage: string) { - const threadId = this.getCurrentThread().id - const currSelns = this.state.currentStagingSelections ?? [] + async editUserMessageAndStreamResponse(userMessage: string, messageIdx: number) { + + const thread = this.getCurrentThread() + + const messageToReplace = thread.messages[messageIdx] + if (messageToReplace?.role !== 'user') { + console.log(`Error: tried to edit non-user message. messageIdx=${messageIdx}, numMessages=${thread.messages.length}`) + return + } + + // clear messages up to the index + const slicedMessages = thread.messages.slice(0, messageIdx) + this._setState({ + allThreads: { + ...this.state.allThreads, + [thread.id]: { + ...thread, + messages: slicedMessages + } + } + }, true) + + // stream the edit + this.addUserMessageAndStreamResponse(userMessage, messageToReplace.stagingSelections) + + } + + async addUserMessageAndStreamResponse(userMessage: string, selectionsOverride?: StagingSelectionItem[] | null) { + + + const thread = this.getCurrentThread() + const threadId = thread.id + + let defaultThreadSelections = thread.stagingSelections + + const currSelns = selectionsOverride ?? defaultThreadSelections ?? [] // don't use _useFocusedStagingState to avoid race conditions with focusing // 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 } + const userHistoryElt: ChatMessage = { role: 'user', content: content, displayContent: instructions, selections: currSelns, stagingSelections: [], } this._addMessageToThread(threadId, userHistoryElt) - this._setStreamState(threadId, { error: undefined }) const llmCancelToken = this._llmMessageService.sendLLMMessage({ @@ -210,12 +249,15 @@ class ChatThreadService extends Disposable implements IChatThreadService { ...this.getCurrentThread().messages.map(m => ({ role: m.role, content: m.content || '(null)' })), ], onText: ({ newText, fullText }) => { + console.log('onText', fullText) this._setStreamState(threadId, { messageSoFar: fullText }) }, onFinalMessage: ({ fullText: content }) => { + console.log('finalMessage', JSON.stringify(content)) this.finishStreaming(threadId, content) }, onError: (error) => { + console.log('onError', content) this.finishStreaming(threadId, this.streamState[threadId]?.messageSoFar ?? '', error) }, @@ -241,7 +283,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { getCurrentThread(): ChatThreads[string] { const state = this.state - return state.allThreads[state.currentThreadId]; + return state.allThreads[state.currentThreadId] + } + + getFocusedMessageIdx() { + const thread = this.getCurrentThread() + return thread.focusedMessageIdx } switchToThread(threadId: string) { @@ -291,11 +338,93 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it) } + // sets the currently selected message (must be undefined if no message is selected) + setFocusedMessageIdx(messageIdx: number | undefined) { - setStaging(stagingSelection: StagingSelectionItem[] | null): void { - this._setState({ currentStagingSelections: stagingSelection }, true) // this is a hack for now + const threadId = this.state.currentThreadId + const thread = this.state.allThreads[threadId] + if (!thread) return + + this._setState({ + allThreads: { + ...this.state.allThreads, + [threadId]: { + ...thread, + focusedMessageIdx: messageIdx + } + } + }, true) } + // set thread.messages[messageIdx].stagingSelections + private setEditMessageStagingSelections(stagingSelections: StagingSelectionItem[], messageIdx: number): void { + + const thread = this.getCurrentThread() + const message = thread.messages[messageIdx] + if (message.role !== 'user') return; + + this._setState({ + allThreads: { + ...this.state.allThreads, + [thread.id]: { + ...thread, + messages: thread.messages.map((m, i) => + i === messageIdx ? { ...m, stagingSelections } : m + ) + } + } + }, true) + + } + + // set thread.stagingSelections + private setDefaultStagingSelections(stagingSelections: StagingSelectionItem[]): void { + + + console.log('Default1') + const thread = this.getCurrentThread() + + console.log('Default2') + + this._setState({ + allThreads: { + ...this.state.allThreads, + [thread.id]: { + ...thread, + stagingSelections + } + } + }, true) + + } + + // 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) { + + let staging: StagingSelectionItem[] = [] + let setStaging: (selections: StagingSelectionItem[]) => void = () => { } + + const thread = this.getCurrentThread() + const isFocusingMessage = messageIdx !== undefined + if (isFocusingMessage) { // is editing message + + const message = thread.messages[messageIdx!] + if (message.role === 'user') { + staging = message.stagingSelections || [] + setStaging = (s) => this.setEditMessageStagingSelections(s, messageIdx) + } + + } + else { // is editing the default input box + staging = thread.stagingSelections || [] + setStaging = this.setDefaultStagingSelections.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 7b46422a..f175c300 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 @@ -25,6 +25,7 @@ import { Pencil } from 'lucide-react'; import { FeatureName } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; + export const IconX = ({ size, className = '', ...props }: { size: number, className?: string } & React.SVGProps) => { return ( void; // Optional close button @@ -171,6 +173,7 @@ export const VoidInputForm: React.FC = ({ featureName, showSelections = false, selections = [], + showProspectiveSelections = true, onSelectionsChange, }) => { return ( @@ -191,6 +194,7 @@ export const VoidInputForm: React.FC = ({ type='staging' selections={selections} setSelections={onSelectionsChange} + showProspectiveSelections={showProspectiveSelections} /> )} @@ -528,14 +532,61 @@ export const SelectedFiles = ( type ChatBubbleMode = 'display' | 'edit' -const ChatBubble = ({ chatMessage, isLoading }: { chatMessage: ChatMessage, isLoading?: boolean, }) => { +const ChatBubble = ({ chatMessage, isLoading, messageIdx }: { chatMessage: ChatMessage, messageIdx?: number, isLoading?: boolean, }) => { const role = chatMessage.role + + const accessor = useAccessor() + const chatThreadsService = accessor.get('IChatThreadService') + // edit mode state + const [staging, setStaging] = chatThreadsService._useStagingSelectionsState(messageIdx) const [mode, setMode] = useState('display') - const [editText, setEditText] = useState(chatMessage.displayContent ?? '') const [isHovered, setIsHovered] = useState(false) + const textAreaRef = useRef(null) + const textAreaFnsRef = useRef(null) + const [isDisabled, setIsDisabled] = useState(false) + useEffect(() => { + if (role === 'user') { + setStaging(chatMessage.selections || []) + if (textAreaFnsRef.current) + textAreaFnsRef.current.setValue(chatMessage.displayContent || '') + if (textAreaRef.current) + textAreaRef.current.value = chatMessage.displayContent || '' + } + }, [role]) + + + const onSubmit = async () => { + + if (isDisabled) return; + if (!textAreaRef.current) return; + if (messageIdx === undefined) return; + + // reset visual state + setMode('display'); + + // stream the edit + const userMessage = textAreaRef.current.value; + await chatThreadsService.editUserMessageAndStreamResponse(userMessage, messageIdx) + + textAreaFnsRef.current?.setValue(''); + } + + const onAbort = () => { + const threadId = chatThreadsService.state.currentThreadId + chatThreadsService.cancelStreaming(threadId) + } + + const onKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { + setMode('display') + } + 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 @@ -552,17 +603,27 @@ const ChatBubble = ({ chatMessage, isLoading }: { chatMessage: ChatMessage, isLo } else if (mode === 'edit') { chatbubbleContents = <> - -