Cmd+Shift+L transfers old text+selections, delete sidebarState

This commit is contained in:
Andrew Pareles 2025-05-05 21:44:40 -07:00
parent e0156803ff
commit a5b43ce146
11 changed files with 245 additions and 447 deletions

View file

@ -100,6 +100,13 @@ const defaultMessageState: UserMessageState = {
// a 'thread' means a chat message history
type WhenMounted = {
textAreaRef: { current: HTMLTextAreaElement | null }; // the textarea that this thread has, gets set in SidebarChat
scrollToBottom: () => void;
}
export type ThreadType = {
id: string; // store the id here too
createdAt: string; // ISO string
@ -120,6 +127,15 @@ export type ThreadType = {
[codespanName: string]: CodespanLocationLink
}
}
mountedInfo?: {
whenMounted: Promise<WhenMounted>
_whenMountedResolver: (res: WhenMounted) => void
mountedIsResolvedRef: { current: boolean };
}
};
}
@ -267,6 +283,9 @@ export interface IChatThreadService {
// jump to history
jumpToCheckpointBeforeMessageIdx(opts: { threadId: string, messageIdx: number, jumpToUserModified: boolean }): void;
focusCurrentChat: () => Promise<void>
blurCurrentChat: () => Promise<void>
}
export const IChatThreadService = createDecorator<IChatThreadService>('voidChatThreadService');
@ -333,6 +352,29 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
async focusCurrentChat() {
const threadId = this.state.currentThreadId
const thread = this.state.allThreads[threadId]
if (!thread) return
console.log('awaiting')
const s = await thread.state.mountedInfo?.whenMounted
console.log('got!', s)
if (!this.isCurrentlyFocusingMessage()) {
console.log('running focus!')
s?.textAreaRef.current?.focus()
}
}
async blurCurrentChat() {
const threadId = this.state.currentThreadId
const thread = this.state.allThreads[threadId]
if (!thread) return
const s = await thread.state.mountedInfo?.whenMounted
if (!this.isCurrentlyFocusingMessage()) {
s?.textAreaRef.current?.blur()
}
}
dangerousSetState = (newState: ThreadsState) => {
this.state = newState
@ -377,7 +419,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// this should be the only place this.state = ... appears besides constructor
private _setState(state: Partial<ThreadsState>, affectsCurrent: boolean) {
private _setState(state: Partial<ThreadsState>, doNotRefreshMountInfo?: boolean) {
const newState = {
...this.state,
...state
@ -385,8 +427,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
this.state = newState
if (affectsCurrent)
this._onDidChangeCurrentThread.fire()
this._onDidChangeCurrentThread.fire()
// if we just switched to a thread, update its current stream state if it's not streaming to possibly streaming
@ -408,6 +449,27 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
// if we did not just set the state to true, set mount info
if (doNotRefreshMountInfo) return
let whenMountedResolver: (w: WhenMounted) => void
const whenMountedPromise = new Promise<WhenMounted>((res) => whenMountedResolver = res)
this._setThreadState(threadId, {
mountedInfo: {
whenMounted: whenMountedPromise,
mountedIsResolvedRef: { current: false },
_whenMountedResolver: (w: WhenMounted) => {
whenMountedResolver(w)
const mountInfo = this.state.allThreads[threadId]?.state.mountedInfo
if (mountInfo) mountInfo.mountedIsResolvedRef.current = true
},
}
}, true) // do not trigger an update
}
@ -724,8 +786,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
this._setStreamState(threadId, { isRunning: 'LLM', llmInfo: { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }, interrupt: Promise.resolve(() => this._llmMessageService.abort(llmCancelToken)) })
const llmRes = await messageIsDonePromise // wait for message to complete
// if something else started running in the meantime
if (this.streamState[threadId]?.isRunning !== 'LLM') {
console.log('Unexpected chat agent state when', this.streamState[threadId]?.isRunning)
// console.log('Chat thread interrupted by a newer chat thread', this.streamState[threadId]?.isRunning)
return
}
@ -823,7 +887,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it)
this._setState({ allThreads: newThreads }) // the current thread just changed (it had a message added to it)
}
@ -1091,7 +1155,10 @@ We only need to do it for files that were edited since `from`, ie files between
class: undefined,
run: () => {
this.switchToThread(threadId)
// TODO!!! scroll to bottom
// scroll to bottom
this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => {
m.scrollToBottom()
})
}
}]
},
@ -1142,6 +1209,11 @@ We only need to do it for files that were edited since `from`, ie files between
this._runChatAgent({ threadId, ...this._currentModelSelectionProps(), }),
threadId,
)
// scroll to bottom
this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => {
m.scrollToBottom()
})
}
@ -1164,7 +1236,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
};
this._storeAllThreads(newThreads);
this._setState({ allThreads: newThreads }, true);
this._setState({ allThreads: newThreads });
}
// Now call the original method to add the user message and stream the response
@ -1194,7 +1266,7 @@ We only need to do it for files that were edited since `from`, ie files between
messages: slicedMessages
}
}
}, true)
})
// re-add the message and stream it
this._addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, threadId })
@ -1467,7 +1539,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}, true)
})
}
@ -1498,7 +1570,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
switchToThread(threadId: string) {
this._setState({ currentThreadId: threadId }, true)
this._setState({ currentThreadId: threadId })
}
@ -1521,7 +1593,7 @@ We only need to do it for files that were edited since `from`, ie files between
[newThread.id]: newThread
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads, currentThreadId: newThread.id }, true)
this._setState({ allThreads: newThreads, currentThreadId: newThread.id })
}
@ -1534,7 +1606,7 @@ We only need to do it for files that were edited since `from`, ie files between
// store the updated threads
this._storeAllThreads(newThreads);
this._setState({ ...this.state, allThreads: newThreads }, true)
this._setState({ ...this.state, allThreads: newThreads })
}
duplicateThread(threadId: string) {
@ -1550,7 +1622,7 @@ We only need to do it for files that were edited since `from`, ie files between
[newThread.id]: newThread,
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true)
this._setState({ allThreads: newThreads })
}
@ -1571,7 +1643,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true) // the current thread just changed (it had a message added to it)
this._setState({ allThreads: newThreads }) // 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)
@ -1592,7 +1664,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}
}, true)
})
// // when change focused message idx, jump - do not jump back when click edit, too confusing.
// if (messageIdx !== undefined)
@ -1680,12 +1752,12 @@ We only need to do it for files that were edited since `from`, ie files between
)
}
}
}, true)
})
}
// set thread.state
private _setThreadState(threadId: string, state: Partial<ThreadType['state']>): void {
private _setThreadState(threadId: string, state: Partial<ThreadType['state']>, doNotRefreshMountInfo?: boolean): void {
const thread = this.state.allThreads[threadId]
if (!thread) return
@ -1700,7 +1772,7 @@ We only need to do it for files that were edited since `from`, ie files between
}
}
}
}, true)
}, doNotRefreshMountInfo)
}

View file

@ -158,7 +158,6 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string
}
return <Codespan
// text={link?.displayText || text}
text={link?.displayText || text}
onClick={onClick}
className={link ? 'underline hover:brightness-90 transition-all duration-200 cursor-pointer' : ''}

View file

@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------*/
import React, { useEffect, useState } from 'react'
import { useIsDark, useSidebarState } from '../util/services.js'
import { useIsDark } from '../util/services.js'
import ErrorBoundary from '../sidebar-tsx/ErrorBoundary.js'
import { QuickEditChat } from './QuickEditChat.js'
import { QuickEditPropsType } from '../../../quickEditActions.js'

View file

@ -3,7 +3,7 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { useIsDark, useSidebarState } from '../util/services.js';
import { useIsDark } from '../util/services.js';
// import { SidebarThreadSelector } from './SidebarThreadSelector.js';
// import { SidebarChat } from './SidebarChat.js';
@ -12,8 +12,6 @@ import { SidebarChat } from './SidebarChat.js';
import ErrorBoundary from './ErrorBoundary.js';
export const Sidebar = ({ className }: { className: string }) => {
const sidebarState = useSidebarState()
const { currentTab: tab } = sidebarState
const isDark = useIsDark()
return <div
@ -29,34 +27,12 @@ export const Sidebar = ({ className }: { className: string }) => {
`}
>
{/* <span onClick={() => {
const tabs = ['chat', 'settings', 'threadSelector']
const index = tabs.indexOf(tab)
sidebarStateService.setState({ currentTab: tabs[(index + 1) % tabs.length] as any })
}}>clickme {tab}</span> */}
{/* <div className={`w-full h-auto mb-2 ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow z-10`}>
<ErrorBoundary>
<SidebarThreadSelector />
</ErrorBoundary>
</div> */}
<div className={`w-full h-full ${tab === 'chat' ? '' : 'hidden'}`}>
<div className={`w-full h-full`}>
<ErrorBoundary>
<SidebarChat />
</ErrorBoundary>
{/* <ErrorBoundary>
<ModelSelectionSettings />
</ErrorBoundary> */}
</div>
{/* <div className={`w-full h-full ${tab === 'settings' ? '' : 'hidden'}`}>
<ErrorBoundary>
<VoidProviderSettings />
</ErrorBoundary>
</div> */}
</div>
</div>

View file

@ -6,7 +6,7 @@
import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState, useFullChatThreadsStreamState } from '../util/services.js';
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState, useFullChatThreadsStreamState } from '../util/services.js';
import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markdown/ChatMarkdownRender.js';
import { URI } from '../../../../../../../base/common/uri.js';
@ -14,7 +14,7 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js';
import { ErrorDisplay } from './ErrorDisplay.js';
import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch } from '../util/inputs.js';
import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js';
import { OldSidebarThreadSelector, PastThreadsList } from './SidebarThreadSelector.js';
import { PastThreadsList } from './SidebarThreadSelector.js';
import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js';
import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js';
import { ChatMode, displayInfoOfProviderName, FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js';
@ -950,7 +950,6 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
// global state
let isBeingEdited = false
@ -1046,7 +1045,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, curr
} catch (e) {
console.error('Error while editing message:', e)
}
sidebarStateService.fireFocusChat()
await chatThreadsService.focusCurrentChat()
requestAnimationFrame(() => _scrollToBottom?.())
}
@ -2724,18 +2723,6 @@ export const SidebarChat = () => {
const settingsState = useSettingsState()
// ----- HIGHER STATE -----
// sidebar state
const sidebarStateService = accessor.get('ISidebarStateService')
useEffect(() => {
const disposables: IDisposable[] = []
disposables.push(
sidebarStateService.onDidFocusChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.focus() }),
sidebarStateService.onDidBlurChat(() => { !chatThreadsService.isCurrentlyFocusingMessage() && textAreaRef.current?.blur() })
)
return () => disposables.forEach(d => d.dispose())
}, [sidebarStateService, textAreaRef])
const { isHistoryOpen } = useSidebarState()
// threads state
const chatThreadsState = useChatThreadsState()
@ -2794,16 +2781,24 @@ export const SidebarChat = () => {
const keybindingString = accessor.get('IKeybindingService').lookupKeybinding(VOID_CTRL_L_ACTION_ID)?.getLabel()
// scroll to top on thread switch
useEffect(() => {
if (isHistoryOpen)
scrollContainerRef.current?.scrollTo({ top: 0, left: 0 })
}, [isHistoryOpen, currentThread.id])
const threadId = currentThread.id
const currCheckpointIdx = chatThreadsState.allThreads[threadId]?.state?.currCheckpointIdx ?? undefined // if not exist, treat like checkpoint is last message (infinity)
// resolve mount info
const isResolved = chatThreadsState.allThreads[threadId]?.state.mountedInfo?.mountedIsResolvedRef.current
useEffect(() => {
if (isResolved) return
chatThreadsState.allThreads[threadId]?.state.mountedInfo?._whenMountedResolver?.({
textAreaRef: textAreaRef,
scrollToBottom: () => scrollToBottom(scrollContainerRef),
})
}, [chatThreadsState, threadId, textAreaRef, scrollContainerRef, isResolved])
const previousMessagesHTML = useMemo(() => {
// const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint')
// tool request shows up as Editing... if in progress
@ -2973,7 +2968,7 @@ export const SidebarChat = () => {
{landingPageInput}
</ErrorBoundary>
{Object.keys(chatThreadsState.allThreads).length > 1 ? // show if there are threads
{Object.keys(chatThreadsState.allThreads).length > 1 ? // show if there are threads
<ErrorBoundary>
<div className='pt-8 mb-2 text-void-fg-3 text-root select-none pointer-events-none'>Previous Threads</div>
<PastThreadsList />

View file

@ -11,138 +11,6 @@ import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserChe
import { IsRunningType, ThreadType } from '../../../chatThreadService.js';
export const OldSidebarThreadSelector = () => {
const accessor = useAccessor()
const sidebarStateService = accessor.get('ISidebarStateService')
return (
<div className="flex p-2 flex-col gap-y-1 max-h-[200px] overflow-y-auto">
<div className="w-full relative flex justify-center items-center">
{/* title */}
<h2 className='font-bold text-lg'>{`History`}</h2>
{/* X button at top right */}
<button
type='button'
className='absolute top-0 right-0'
onClick={() => sidebarStateService.setState({ isHistoryOpen: false })}
>
<IconX
size={16}
className="p-[1px] stroke-[2] opacity-80 text-void-fg-3 hover:brightness-95"
/>
</button>
</div>
{/* a list of all the past threads */}
{/* <OldPastThreadsList /> */}
</div>
)
}
const truncate = (s: string) => {
let len = s.length
const TRUNC_AFTER = 16
if (len >= TRUNC_AFTER)
s = s.substring(0, TRUNC_AFTER) + '...'
return s
}
const OldPastThreadsList = () => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
const threadsState = useChatThreadsState()
const { allThreads } = threadsState
// sorted by most recent to least recent
const sortedThreadIds = Object.keys(allThreads ?? {})
.sort((threadId1, threadId2) => (allThreads[threadId1]?.lastModified ?? 0) > (allThreads[threadId2]?.lastModified ?? 0) ? -1 : 1)
.filter(threadId => (allThreads![threadId]?.messages.length ?? 0) !== 0)
return <div className="px-1">
<ul className="flex flex-col gap-y-0.5 overflow-y-auto list-disc">
{sortedThreadIds.length === 0
? <div key="nothreads" className="text-center text-void-fg-3 brightness-90 text-root">{`There are no chat threads yet.`}</div>
: sortedThreadIds.map((threadId) => {
if (!allThreads) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
const pastThread = allThreads[threadId];
if (!pastThread) {
return <li key="error" className="text-void-warning">{`Error accessing chat history.`}</li>;
}
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx]
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
// const secondMsgIdx = pastThread.messages.findIndex(
// (msg, i) => msg.role !== 'system' && !!msg.displayContent && i > firstMsgIdx
// );
// if (secondMsgIdx !== -1) {
// secondMsg = truncate(pastThread.messages[secondMsgIdx].displayContent ?? '');
// }
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
return (
<li key={pastThread.id}>
<button
type='button'
className={`
hover:bg-void-bg-1
${threadsState.currentThreadId === pastThread.id ? 'bg-void-bg-1' : ''}
rounded-sm px-2 py-1
w-full
text-left
flex items-center
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false })
}}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>
</button>
</li>
);
})
}
</ul>
</div>
}
const numInitialThreads = 3
export const PastThreadsList = ({ className = '' }: { className?: string }) => {
@ -313,7 +181,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
// const settingsState = useSettingsState()
// const convertService = accessor.get('IConvertToLLMMessageService')
@ -369,7 +236,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false });
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}

View file

@ -383,8 +383,21 @@ export const VoidInputBox2 = forwardRef<HTMLTextAreaElement, InputBox2Props>(fun
// Focus the textarea first
textarea.focus();
// Insert the @ to mention text in the editor (we decided not to do this for now)
// document.execCommand('insertText', false, text + ' '); // add space after too
// delete the @ and set the cursor position
// Get cursor position
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
// Get the text before the cursor, excluding the @ symbol that triggered the menu
const textBeforeCursor = textarea.value.substring(0, startPos - 1);
const textAfterCursor = textarea.value.substring(endPos);
// Replace the text including the @ symbol with the selected option
textarea.value = textBeforeCursor + textAfterCursor;
// Set cursor position after the inserted text
const newCursorPos = textBeforeCursor.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
// React's onChange relies on a SyntheticEvent system
// The best way to ensure it runs is to call callbacks directly

View file

@ -6,7 +6,6 @@
import React, { useState, useEffect, useCallback } from 'react'
import { RefreshableProviderName, SettingsOfProvider } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'
import { IDisposable } from '../../../../../../../base/common/lifecycle.js'
import { VoidSidebarState } from '../../../sidebarStateService.js'
import { VoidSettingsState } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js'
import { ColorScheme } from '../../../../../../../platform/theme/common/theme.js'
import { RefreshModelStateOfProvider } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js'
@ -23,7 +22,6 @@ import { ILLMMessageService } from '../../../../common/sendLLMMessageService.js'
import { IRefreshModelService } from '../../../../../../../workbench/contrib/void/common/refreshModelService.js';
import { IVoidSettingsService } from '../../../../../../../workbench/contrib/void/common/voidSettingsService.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'
import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'
import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'
@ -58,9 +56,6 @@ import { ISearchService } from '../../../../../../services/search/common/search.
// even if React hasn't mounted yet, the variables are always updated to the latest state.
// React listens by adding a setState function to these listeners.
let sidebarState: VoidSidebarState
const sidebarStateListeners: Set<(s: VoidSidebarState) => void> = new Set()
let chatThreadsState: ThreadsState
const chatThreadsStateListeners: Set<(s: ThreadsState) => void> = new Set()
@ -91,7 +86,6 @@ export const _registerServices = (accessor: ServicesAccessor) => {
_registerAccessor(accessor)
const stateServices = {
sidebarStateService: accessor.get(ISidebarStateService),
chatThreadsStateService: accessor.get(IChatThreadService),
settingsStateService: accessor.get(IVoidSettingsService),
refreshModelService: accessor.get(IRefreshModelService),
@ -101,15 +95,10 @@ export const _registerServices = (accessor: ServicesAccessor) => {
modelService: accessor.get(IModelService),
}
const { sidebarStateService, settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices
const { settingsStateService, chatThreadsStateService, refreshModelService, themeService, editCodeService, voidCommandBarService, modelService } = stateServices
sidebarState = sidebarStateService.state
disposables.push(
sidebarStateService.onDidChangeState(() => {
sidebarState = sidebarStateService.state
sidebarStateListeners.forEach(l => l(sidebarState))
})
)
chatThreadsState = chatThreadsStateService.state
disposables.push(
@ -193,7 +182,6 @@ const getReactAccessor = (accessor: ServicesAccessor) => {
IRefreshModelService: accessor.get(IRefreshModelService),
IVoidSettingsService: accessor.get(IVoidSettingsService),
IEditCodeService: accessor.get(IEditCodeService),
ISidebarStateService: accessor.get(ISidebarStateService),
IChatThreadService: accessor.get(IChatThreadService),
IInstantiationService: accessor.get(IInstantiationService),
@ -250,16 +238,6 @@ export const useAccessor = () => {
// -- state of services --
export const useSidebarState = () => {
const [s, ss] = useState(sidebarState)
useEffect(() => {
ss(sidebarState)
sidebarStateListeners.add(ss)
return () => { sidebarStateListeners.delete(ss) }
}, [ss])
return s
}
export const useSettingsState = () => {
const [s, ss] = useState(settingsState)
useEffect(() => {

View file

@ -14,23 +14,18 @@ import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextke
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
import { IRange } from '../../../../editor/common/core/range.js';
import { VOID_VIEW_ID } from './sidebarPane.js';
import { VOID_VIEW_CONTAINER_ID, VOID_VIEW_ID } from './sidebarPane.js';
import { IMetricsService } from '../common/metricsService.js';
import { ISidebarStateService } from './sidebarStateService.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { VOID_TOGGLE_SETTINGS_ACTION_ID } from './voidSettingsPane.js';
import { VOID_CTRL_L_ACTION_ID } from './actionIDs.js';
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize2 } from '../../../../nls.js';
import { StagingSelectionItem } from '../common/chatThreadServiceTypes.js';
import { IChatThreadService } from './chatThreadService.js';
import { getActiveWindow } from '../../../../base/browser/dom.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
// ---------- Register commands and keybindings ----------
export const roundRangeToLines = (range: IRange | null | undefined, options: { emptySelectionBehavior: 'null' | 'line' }) => {
if (!range)
return null
@ -72,68 +67,21 @@ registerAction2(class extends Action2 {
super({ id: VOID_OPEN_SIDEBAR_ACTION_ID, title: localize2('voidOpenSidebar', 'Void: Open Sidebar'), f1: true });
}
async run(accessor: ServicesAccessor): Promise<void> {
const stateService = accessor.get(ISidebarStateService)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
stateService.fireFocusChat()
const viewsService = accessor.get(IViewsService)
const chatThreadsService = accessor.get(IChatThreadService)
viewsService.openViewContainer(VOID_VIEW_CONTAINER_ID)
await chatThreadsService.focusCurrentChat()
}
})
// Action: when press ctrl+L, show the sidebar chat and add to the selection
const VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID = 'void.sidebar.select'
registerAction2(class extends Action2 {
constructor() {
super({ id: VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID, title: localize2('voidAddToSidebar', 'Void: Add Selection to Sidebar'), f1: true });
}
async run(accessor: ServicesAccessor): Promise<void> {
const model = accessor.get(ICodeEditorService).getActiveCodeEditor()?.getModel()
if (!model)
return
const metricsService = accessor.get(IMetricsService)
const editorService = accessor.get(ICodeEditorService)
metricsService.capture('Ctrl+L', {})
const editor = editorService.getActiveCodeEditor()
// accessor.get(IEditorService).activeTextEditorControl?.getSelection()
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
// select whole lines
if (selectionRange) {
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
}
const newSelection: StagingSelectionItem = !selectionRange || (selectionRange.startLineNumber > selectionRange.endLineNumber) ? {
type: 'File',
uri: model.uri,
language: model.getLanguageId(),
state: { wasAddedAsCurrentFile: false }
} : {
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false }
}
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.addNewStagingSelection(newSelection)
}
});
// cmd L
registerAction2(class extends Action2 {
constructor() {
super({
id: VOID_CTRL_L_ACTION_ID,
f1: true,
title: localize2('voidCtrlL', 'Void: Add Selection to Chat'),
title: localize2('voidCmdL', 'Void: Add Selection to Chat'),
keybinding: {
primary: KeyMod.CtrlCmd | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension
@ -141,72 +89,112 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
// Get the views service to check if the sidebar is open
// const viewsService = accessor.get(IViewsService)
const commandService = accessor.get(ICommandService)
await commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
// await commandService.executeCommand(VOID_ADD_SELECTION_TO_SIDEBAR_ACTION_ID)
}
})
const openNewThreadAndFireFocus = (accessor: ServicesAccessor) => {
const stateService = accessor.get(ISidebarStateService)
stateService.setState({ isHistoryOpen: false, currentTab: 'chat' })
const chatThreadService = accessor.get(IChatThreadService)
chatThreadService.openNewThread()
// focus
stateService.fireFocusChat()
const window = getActiveWindow()
window.requestAnimationFrame(() => stateService.fireFocusChat())
}
// New chat menu button
registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.newChatAction',
title: 'New Chat',
icon: { id: 'add' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }],
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const viewsService = accessor.get(IViewsService)
const metricsService = accessor.get(IMetricsService)
metricsService.capture('Chat Navigation', { type: 'New Chat' })
const editorService = accessor.get(ICodeEditorService)
const chatThreadService = accessor.get(IChatThreadService)
openNewThreadAndFireFocus(accessor)
metricsService.capture('Ctrl+L', {})
const wasAlreadyOpen = viewsService.isViewContainerVisible(VOID_VIEW_CONTAINER_ID)
if (!wasAlreadyOpen) {
await commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
return
}
// if was already open
const model = accessor.get(ICodeEditorService).getActiveCodeEditor()?.getModel()
if (!model) return
const editor = editorService.getActiveCodeEditor()
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
// if has no selection, close + return
if (!selectionRange) {
viewsService.closeViewContainer(VOID_VIEW_CONTAINER_ID);
return;
}
// if has selection, add it
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
chatThreadService.addNewStagingSelection({
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false }
})
}
})
// New chat keybind
// New chat keybind + menu button
const VOID_CMD_SHIFT_L_ACTION_ID = 'void.cmdShiftL'
registerAction2(class extends Action2 {
constructor() {
super({
id: 'void.newChatKeybindAction',
title: 'New Chat Keybind',
id: VOID_CMD_SHIFT_L_ACTION_ID,
title: 'New Chat',
keybinding: {
primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyL,
weight: KeybindingWeight.VoidExtension,
},
icon: { id: 'add' },
menu: [{ id: MenuId.ViewTitle, group: 'navigation', when: ContextKeyExpr.equals('view', VOID_VIEW_ID), }],
});
}
async run(accessor: ServicesAccessor): Promise<void> {
const metricsService = accessor.get(IMetricsService)
const commandService = accessor.get(ICommandService)
metricsService.capture('Chat Navigation', { type: 'New Chat Keybind' })
const chatThreadsService = accessor.get(IChatThreadService)
const editorService = accessor.get(ICodeEditorService)
metricsService.capture('Chat Navigation', { type: 'Start New Chat' })
openNewThreadAndFireFocus(accessor)
// get current selections and value to transfer
const oldThreadId = chatThreadsService.state.currentThreadId
const oldThread = chatThreadsService.state.allThreads[oldThreadId]
// add user's selection to chat
await commandService.executeCommand(VOID_CTRL_L_ACTION_ID)
const oldUI = await oldThread?.state.mountedInfo?.whenMounted
const oldSelns = oldThread?.state.stagingSelections
const oldVal = oldUI?.textAreaRef.current?.value
// open and focus new thread
chatThreadsService.openNewThread()
await chatThreadsService.focusCurrentChat()
// set new thread values
const newThreadId = chatThreadsService.state.currentThreadId
const newThread = chatThreadsService.state.allThreads[newThreadId]
const newUI = await newThread?.state.mountedInfo?.whenMounted
chatThreadsService.setCurrentThreadState({ stagingSelections: oldSelns, })
if (newUI?.textAreaRef?.current && oldVal) newUI.textAreaRef.current.value = oldVal
// if has selection, add it
const editor = editorService.getActiveCodeEditor()
const model = editor?.getModel()
if (!model) return
const selectionRange = roundRangeToLines(editor?.getSelection(), { emptySelectionBehavior: 'null' })
if (!selectionRange) return
editor?.setSelection({ startLineNumber: selectionRange.startLineNumber, endLineNumber: selectionRange.endLineNumber, startColumn: 1, endColumn: Number.MAX_SAFE_INTEGER })
chatThreadsService.addNewStagingSelection({
type: 'CodeSelection',
uri: model.uri,
language: model.getLanguageId(),
range: [selectionRange.startLineNumber, selectionRange.endLineNumber],
state: { wasAddedAsCurrentFile: false }
})
}
})
@ -229,18 +217,12 @@ registerAction2(class extends Action2 {
return;
}
const stateService = accessor.get(ISidebarStateService)
const metricsService = accessor.get(IMetricsService)
const commandService = accessor.get(ICommandService)
metricsService.capture('Chat Navigation', { type: 'History' })
openNewThreadAndFireFocus(accessor)
// doesnt do anything right now
stateService.setState({ isHistoryOpen: !stateService.state.isHistoryOpen, currentTab: 'chat' })
stateService.fireBlurChat()
commandService.executeCommand(VOID_CMD_SHIFT_L_ACTION_ID)
}
})
@ -265,28 +247,28 @@ registerAction2(class extends Action2 {
export class TabSwitchListener extends Disposable {
// export class TabSwitchListener extends Disposable {
constructor(
onSwitchTab: () => void,
@ICodeEditorService private readonly _editorService: ICodeEditorService,
) {
super()
// constructor(
// onSwitchTab: () => void,
// @ICodeEditorService private readonly _editorService: ICodeEditorService,
// ) {
// super()
// when editor switches tabs (models)
const addTabSwitchListeners = (editor: ICodeEditor) => {
this._register(editor.onDidChangeModel(e => {
if (e.newModelUrl?.scheme !== 'file') return
onSwitchTab()
}))
}
// // when editor switches tabs (models)
// const addTabSwitchListeners = (editor: ICodeEditor) => {
// this._register(editor.onDidChangeModel(e => {
// if (e.newModelUrl?.scheme !== 'file') return
// onSwitchTab()
// }))
// }
const initializeEditor = (editor: ICodeEditor) => {
addTabSwitchListeners(editor)
}
// const initializeEditor = (editor: ICodeEditor) => {
// addTabSwitchListeners(editor)
// }
// initialize current editors + any new editors
for (let editor of this._editorService.listCodeEditors()) initializeEditor(editor)
this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) }))
}
}
// // initialize current editors + any new editors
// for (let editor of this._editorService.listCodeEditors()) initializeEditor(editor)
// this._register(this._editorService.onCodeEditorAdd(editor => { initializeEditor(editor) }))
// }
// }

View file

@ -1,82 +0,0 @@
/*--------------------------------------------------------------------------------------
* Copyright 2025 Glass Devtools, Inc. All rights reserved.
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { VOID_OPEN_SIDEBAR_ACTION_ID } from './sidebarPane.js';
// service that manages sidebar's state
export type VoidSidebarState = {
isHistoryOpen: boolean; // this isn't doing anything right now
currentTab: 'chat';
}
export interface ISidebarStateService {
readonly _serviceBrand: undefined;
readonly state: VoidSidebarState; // readonly to the user
setState(newState: Partial<VoidSidebarState>): void;
onDidChangeState: Event<void>;
onDidFocusChat: Event<void>;
onDidBlurChat: Event<void>;
fireFocusChat(): void;
fireBlurChat(): void;
}
export const ISidebarStateService = createDecorator<ISidebarStateService>('voidSidebarStateService');
class VoidSidebarStateService extends Disposable implements ISidebarStateService {
_serviceBrand: undefined;
static readonly ID = 'voidSidebarStateService';
private readonly _onDidChangeState = new Emitter<void>();
readonly onDidChangeState: Event<void> = this._onDidChangeState.event;
private readonly _onFocusChat = new Emitter<void>();
readonly onDidFocusChat: Event<void> = this._onFocusChat.event;
private readonly _onBlurChat = new Emitter<void>();
readonly onDidBlurChat: Event<void> = this._onBlurChat.event;
// state
state: VoidSidebarState
constructor(
@ICommandService private readonly commandService: ICommandService,
) {
super()
// initial state
this.state = { isHistoryOpen: false, currentTab: 'chat', }
}
setState(newState: Partial<VoidSidebarState>) {
// make sure view is open if the tab changes
if ('currentTab' in newState) {
this.commandService.executeCommand(VOID_OPEN_SIDEBAR_ACTION_ID)
}
this.state = { ...this.state, ...newState }
this._onDidChangeState.fire()
}
fireFocusChat() {
this._onFocusChat.fire()
}
fireBlurChat() {
this._onBlurChat.fire()
}
}
registerSingleton(ISidebarStateService, VoidSidebarStateService, InstantiationType.Eager);

View file

@ -10,7 +10,6 @@ import './editCodeService.js'
// register Sidebar pane, state, actions (keybinds, menus) (Ctrl+L)
import './sidebarActions.js'
import './sidebarPane.js'
import './sidebarStateService.js'
// register quick edit (Ctrl+K)
import './quickEditActions.js'