diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 95707798..ed24a78f 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -30,7 +30,7 @@ import { IEditCodeService } from './editCodeServiceInterface.js'; import { VoidFileSnapshot } from '../common/editCodeServiceTypes.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { truncate } from '../../../../base/common/strings.js'; -import { THREAD_STORAGE_KEY } from '../common/storageKeys.js'; +import { PINNED_THREADS_STORAGE_KEY, THREAD_STORAGE_KEY } from '../common/storageKeys.js'; import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; import { timeout } from '../../../../base/common/async.js'; import { deepClone } from '../../../../base/common/objects.js'; @@ -124,6 +124,13 @@ export type ThreadType = { // after the user sends a new message). latestUsage?: LLMUsage; + // Model used to send the most recent user message on this thread. Captured + // on send, restored on `switchToThread` (writes to settings' `Chat` model + // selection). `null` means "no message was sent on this thread yet"; if the + // provider/model no longer exists or is hidden, the restore is skipped and + // the user keeps whatever model is currently globally selected. + lastUsedModelSelection?: ModelSelection | null; + // this doesn't need to go in a state object, but feels right state: { currCheckpointIdx: number | null; // the latest checkpoint we're at (null if not at a particular checkpoint, like if the chat is streaming, or chat just finished and we haven't clicked on a checkpt) @@ -156,6 +163,12 @@ type ChatThreads = { export type ThreadsState = { allThreads: ChatThreads; currentThreadId: string; // intended for internal use only + + // Ordered list of thread ids shown as tabs in the chat sidebar header. + // Entirely a UI-pin concept — removing an id from here does NOT delete the + // thread (it remains accessible via the history list). Persisted under + // PINNED_THREADS_STORAGE_KEY (see storageKeys.ts). + pinnedThreadIds: string[]; } export type IsRunningType = @@ -250,6 +263,10 @@ export interface IChatThreadService { deleteThread(threadId: string): void; duplicateThread(threadId: string): void; + // tab-strip pinning (does not affect existence — only the chat-header tab row) + pinThread(threadId: string): void; + unpinThread(threadId: string): void; + // exposed getters/setters // these all apply to current thread getCurrentMessageState: (messageIdx: number) => UserMessageState @@ -336,14 +353,20 @@ class ChatThreadService extends Disposable implements IChatThreadService { @IMCPService private readonly _mcpService: IMCPService, ) { super() - this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state + this.state = { allThreads: {}, currentThreadId: null as unknown as string, pinnedThreadIds: [] } // default state const readThreads = this._readAllThreads() || {} + // Restore pinned ids, filtering any that refer to threads that no longer + // exist (e.g. deleted on another machine, storage corruption, etc.) so + // we never render a "ghost" tab. + const readPinned = (this._readPinnedThreadIds() || []).filter(id => !!readThreads[id]) + const allThreads = readThreads this.state = { allThreads: allThreads, currentThreadId: null as unknown as string, // gets set in startNewThread() + pinnedThreadIds: readPinned, } // hydrate in-memory latestUsage map from the persisted threads so the @@ -356,6 +379,34 @@ class ChatThreadService extends Disposable implements IChatThreadService { // always be in a thread this.openNewThread() + // Capture live dropdown changes onto whichever thread is currently in + // focus, so switching tabs round-trips the chosen model even when no + // message was sent. Without this listener, the field is only written + // at send time (see `_addUserMessageAndStreamResponse`) and an "unsent" + // dropdown change would be lost on tab switch. + // + // Races are benign: `switchToThread` itself calls + // `setModelSelectionOfFeature` which will fire this listener back, but + // by the time it fires `currentThreadId` is already the new thread and + // the equality check in `_setThreadLastUsedModelSelection` skips the + // redundant write. + let lastSeenChatModel = this._settingsService.state.modelSelectionOfFeature['Chat'] + this._register(this._settingsService.onDidChangeState(() => { + const current = this._settingsService.state.modelSelectionOfFeature['Chat'] + const unchanged = ( + (!lastSeenChatModel && !current) || + (!!lastSeenChatModel && !!current + && lastSeenChatModel.providerName === current.providerName + && lastSeenChatModel.modelName === current.modelName) + ) + if (unchanged) return + lastSeenChatModel = current + const threadId = this.state.currentThreadId + if (threadId && this.state.allThreads[threadId]) { + this._setThreadLastUsedModelSelection(threadId, current) + } + })) + // keep track of user-modified files // const disposablesOfModelId: { [modelId: string]: IDisposable[] } = {} @@ -400,7 +451,8 @@ class ChatThreadService extends Disposable implements IChatThreadService { this._onDidChangeCurrentThread.fire() } resetState = () => { - this.state = { allThreads: {}, currentThreadId: null as unknown as string } // see constructor + this.state = { allThreads: {}, currentThreadId: null as unknown as string, pinnedThreadIds: [] } // see constructor + this._storePinnedThreadIds([]) this.openNewThread() this._onDidChangeCurrentThread.fire() } @@ -436,6 +488,26 @@ class ChatThreadService extends Disposable implements IChatThreadService { ); } + private _readPinnedThreadIds(): string[] | null { + const s = this._storageService.get(PINNED_THREADS_STORAGE_KEY, StorageScope.APPLICATION); + if (!s) return null; + try { + const parsed = JSON.parse(s); + return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : null; + } catch { + return null; + } + } + + private _storePinnedThreadIds(ids: string[]) { + this._storageService.store( + PINNED_THREADS_STORAGE_KEY, + JSON.stringify(ids), + StorageScope.APPLICATION, + StorageTarget.USER + ); + } + // this should be the only place this.state = ... appears besides constructor private _setState(state: Partial, doNotRefreshMountInfo?: boolean) { @@ -524,6 +596,45 @@ class ChatThreadService extends Disposable implements IChatThreadService { return { modelSelection, modelSelectionOptions } } + // Persists the given model selection on the thread so that a later + // `switchToThread` can restore the dropdown to whatever the user sent with. + // Writes through `_storeAllThreads` to survive reloads. No state change + // event here — the dropdown state lives on `IVoidSettingsService`, not on + // this service, so there's nothing for chat-UI listeners to re-render. + private _setThreadLastUsedModelSelection(threadId: string, modelSelection: ModelSelection | null) { + const thread = this.state.allThreads[threadId] + if (!thread) return + + // Skip the (persistent) write if the stored value is already identical. + // Without this, every user message would rewrite the whole threads blob + // to storage for no reason. + const prev = thread.lastUsedModelSelection + if ( + prev && modelSelection && + prev.providerName === modelSelection.providerName && + prev.modelName === modelSelection.modelName + ) return + if (!prev && !modelSelection) return + + const newThreads = { + ...this.state.allThreads, + [threadId]: { ...thread, lastUsedModelSelection: modelSelection }, + } + this._storeAllThreads(newThreads) + this._setState({ allThreads: newThreads }) + } + + // Returns true iff `sel` points at a provider+model that still exists in + // settings AND is not currently hidden. Used to decide whether restoring a + // thread's saved model is safe, or if we should silently fall back to the + // current global selection (e.g. the user deleted that model in Settings + // since the thread was last used). + private _isModelSelectionCurrentlyValid(sel: ModelSelection): boolean { + const providerSettings = this._settingsService.state.settingsOfProvider[sel.providerName] + if (!providerSettings) return false + return providerSettings.models.some(m => m.modelName === sel.modelName && !m.isHidden) + } + private _swapOutLatestStreamingToolWithResult = (threadId: string, tool: ChatMessage & { role: 'tool' }) => { @@ -1285,8 +1396,15 @@ We only need to do it for files that were edited since `from`, ie files between this._setThreadState(threadId, { currCheckpointIdx: null }) // no longer at a checkpoint because started streaming + const modelProps = this._currentModelSelectionProps() + // Capture the chosen model at send time so future switches to this + // thread restore this exact dropdown choice. Intentionally NOT captured + // on tool approve/reject/edit — those don't represent a fresh user + // decision about which model to use. + this._setThreadLastUsedModelSelection(threadId, modelProps.modelSelection) + this._wrapRunAgentToNotify( - this._runChatAgent({ threadId, ...this._currentModelSelectionProps(), }), + this._runChatAgent({ threadId, ...modelProps, }), threadId, ) @@ -1650,7 +1768,36 @@ We only need to do it for files that were edited since `from`, ie files between } switchToThread(threadId: string) { - this._setState({ currentThreadId: threadId }) + // Auto-pin on switch so that jumping to a thread from the history list + // surfaces it in the tab strip (user can remove with the × on the tab). + // Silently no-op if already pinned. + const alreadyPinned = this.state.pinnedThreadIds.includes(threadId) + if (alreadyPinned) { + this._setState({ currentThreadId: threadId }) + } else { + const newPinned = [...this.state.pinnedThreadIds, threadId] + this._storePinnedThreadIds(newPinned) + this._setState({ currentThreadId: threadId, pinnedThreadIds: newPinned }) + } + + // Restore the dropdown to the model that was last used to send a + // message on this thread. Fire-and-forget: the switch already took + // effect visually; `setModelSelectionOfFeature` just updates settings + // state which the model-selector component listens to separately. + // Skip when the thread has no saved selection (fresh thread, or + // pre-feature thread) or when the saved model has since been deleted + // or hidden — in both cases we intentionally leave the current global + // selection alone so the user isn't surprised by a blank dropdown. + const saved = this.state.allThreads[threadId]?.lastUsedModelSelection + if (saved && this._isModelSelectionCurrentlyValid(saved)) { + const current = this._settingsService.state.modelSelectionOfFeature['Chat'] + const alreadyMatches = current + && current.providerName === saved.providerName + && current.modelName === saved.modelName + if (!alreadyMatches) { + this._settingsService.setModelSelectionOfFeature('Chat', saved) + } + } } @@ -1667,13 +1814,17 @@ We only need to do it for files that were edited since `from`, ie files between // otherwise, start a new thread const newThread = newThreadObject() - // update state + // update state — also auto-pin so it becomes the active tab const newThreads: ChatThreads = { ...currentThreads, [newThread.id]: newThread } + const newPinned = this.state.pinnedThreadIds.includes(newThread.id) + ? this.state.pinnedThreadIds + : [...this.state.pinnedThreadIds, newThread.id] this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads, currentThreadId: newThread.id }) + this._storePinnedThreadIds(newPinned) + this._setState({ allThreads: newThreads, currentThreadId: newThread.id, pinnedThreadIds: newPinned }) } @@ -1684,9 +1835,13 @@ We only need to do it for files that were edited since `from`, ie files between const newThreads = { ...currentThreads }; delete newThreads[threadId]; + // drop from the tab strip too (no point showing a tab for a deleted thread) + const newPinned = this.state.pinnedThreadIds.filter(id => id !== threadId) + if (newPinned.length !== this.state.pinnedThreadIds.length) this._storePinnedThreadIds(newPinned) + // store the updated threads this._storeAllThreads(newThreads); - this._setState({ ...this.state, allThreads: newThreads }) + this._setState({ ...this.state, allThreads: newThreads, pinnedThreadIds: newPinned }) } duplicateThread(threadId: string) { @@ -1701,8 +1856,45 @@ We only need to do it for files that were edited since `from`, ie files between ...currentThreads, [newThread.id]: newThread, } + // Pin the duplicate right after the original, so the new tab appears + // next to the source tab (natural position). Fall back to append if the + // source wasn't pinned. + const srcIdx = this.state.pinnedThreadIds.indexOf(threadId) + const newPinned = [...this.state.pinnedThreadIds] + if (srcIdx === -1) newPinned.push(newThread.id) + else newPinned.splice(srcIdx + 1, 0, newThread.id) + this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads }) + this._storePinnedThreadIds(newPinned) + this._setState({ allThreads: newThreads, pinnedThreadIds: newPinned }) + } + + pinThread(threadId: string): void { + if (!this.state.allThreads[threadId]) return + if (this.state.pinnedThreadIds.includes(threadId)) return + const newPinned = [...this.state.pinnedThreadIds, threadId] + this._storePinnedThreadIds(newPinned) + this._setState({ pinnedThreadIds: newPinned }) + } + + unpinThread(threadId: string): void { + if (!this.state.pinnedThreadIds.includes(threadId)) return + const newPinned = this.state.pinnedThreadIds.filter(id => id !== threadId) + this._storePinnedThreadIds(newPinned) + + // If the user removed the tab they're currently looking at, jump to a + // neighboring pinned tab so the chat pane doesn't show stale content. + // If no tabs remain, open a fresh thread (which also pins itself). + if (this.state.currentThreadId === threadId) { + if (newPinned.length > 0) { + this._setState({ pinnedThreadIds: newPinned, currentThreadId: newPinned[newPinned.length - 1] }) + } else { + this._setState({ pinnedThreadIds: newPinned }) + this.openNewThread() + } + } else { + this._setState({ pinnedThreadIds: newPinned }) + } } 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 c1a13547..1b711dcd 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 @@ -15,7 +15,7 @@ import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { ErrorDisplay } from './ErrorDisplay.js'; import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js'; import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js'; -import { PastThreadsList } from './SidebarThreadSelector.js'; +import { PastThreadsList, SidebarThreadTabs } 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'; @@ -3268,6 +3268,12 @@ export const SidebarChat = () => { ref={sidebarRef} className='w-full h-full max-h-full flex flex-col overflow-auto px-4' > + {/* Tab strip also rendered on the landing page so users can jump between + existing pinned threads without needing to scroll down to the + PastThreadsList below. Hidden implicitly when there are no pinned tabs. */} + + + {landingPageInput} @@ -3303,7 +3309,9 @@ export const SidebarChat = () => { ref={sidebarRef} className='w-full h-full flex flex-col overflow-hidden' > - + + + {messagesHTML} diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index a6147461..4f3311f9 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -3,11 +3,11 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js'; import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js'; import { IconX } from './SidebarChat.js'; -import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react'; +import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Plus, Trash2, UserCheck, X } from 'lucide-react'; import { IsRunningType, ThreadType } from '../../../chatThreadService.js'; @@ -276,3 +276,117 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni } + + + +// Horizontal scrollable tab strip of pinned chat threads, shown at the top of +// the chat sidebar. Tabs are pure UI pins — `unpinThread` never deletes the +// underlying thread (it stays reachable via PastThreadsList / history). Users +// add tabs implicitly by starting a new thread (`+`) or switching to one from +// history; they remove tabs via the × on the tab itself. +export const SidebarThreadTabs = () => { + const accessor = useAccessor() + const chatThreadsService = accessor.get('IChatThreadService') + + const threadsState = useChatThreadsState() + const streamState = useFullChatThreadsStreamState() + + const { allThreads, currentThreadId, pinnedThreadIds } = threadsState + + // Defensive filter: only render tabs whose thread still exists. Stale ids + // are pruned at load time too (see ChatThreadService constructor), but this + // guards against any in-memory drift between deleteThread and a re-render. + const tabs = (pinnedThreadIds ?? []).filter(id => !!allThreads[id]) + + // Keep the active tab in view when threads are switched from outside the + // strip (e.g. landing-page history click), otherwise long tab rows can + // silently hide the current selection offscreen. + const activeTabRef = useRef(null) + useEffect(() => { + activeTabRef.current?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' }) + }, [currentThreadId]) + + // Nothing meaningful to render if there are no pinned threads. The `+` + // button below still shows so the user can always start a new chat, even + // if they unpinned everything (edge case; unpinThread auto-opens a new + // thread in that situation anyway). + const showNothing = tabs.length === 0 + + return ( +
{ + if (e.deltaY !== 0 && e.deltaX === 0) { + e.currentTarget.scrollLeft += e.deltaY + } + }} + > + {showNothing ? null : tabs.map(id => { + const t = allThreads[id]! + const isActive = id === currentThreadId + const isRunning = streamState[id]?.isRunning + + // Label source of truth matches PastThreadsList: first user + // message's displayContent, truncated. Empty threads get a + // neutral "New Chat" label so the tab isn't blank. + const firstUser = t.messages.find(m => m.role === 'user') + const label = firstUser && firstUser.role === 'user' && firstUser.displayContent + ? firstUser.displayContent + : 'New Chat' + + return ( +
chatThreadsService.switchToThread(id)} + // Middle-click closes, matching conventional tab UX + // (VS Code editor tabs, browsers, etc). + onMouseDown={(e) => { + if (e.button === 1) { + e.preventDefault() + chatThreadsService.unpinThread(id) + } + }} + className={` + group flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer flex-shrink-0 max-w-[110px] min-w-0 select-none + ${isActive + ? 'bg-zinc-700/10 dark:bg-zinc-300/10 text-void-fg-1' + : 'text-void-fg-3 opacity-80 hover:opacity-100 hover:bg-zinc-700/5 dark:hover:bg-zinc-300/5'} + `} + data-tooltip-id='void-tooltip' + data-tooltip-content={label} + data-tooltip-place='bottom' + > + {isRunning === 'LLM' || isRunning === 'tool' || isRunning === 'idle' + ? + : isRunning === 'awaiting_user' + ? + : null} + {label} + +
+ ) + })} + +
+ ) +} diff --git a/src/vs/workbench/contrib/void/common/storageKeys.ts b/src/vs/workbench/contrib/void/common/storageKeys.ts index b23d7ffb..e1f78acd 100644 --- a/src/vs/workbench/contrib/void/common/storageKeys.ts +++ b/src/vs/workbench/contrib/void/common/storageKeys.ts @@ -19,5 +19,11 @@ export const VOID_SETTINGS_STORAGE_KEY = 'void.settingsServiceStorageII' export const THREAD_STORAGE_KEY = 'void.chatThreadStorageII' +// Ordered list of thread ids pinned as tabs in the chat sidebar. Persisted +// separately from THREAD_STORAGE_KEY so evolving tab UX doesn't force a +// thread-storage version bump. +export const PINNED_THREADS_STORAGE_KEY = 'void.chatPinnedThreadsI' + + export const OPT_OUT_KEY = 'void.app.optOutAll'