Feature/multitab support (#5)

* support pinning multiple tabs at the top

* persist the last model used on every conversation tab
This commit is contained in:
davi0015 2026-04-21 01:47:12 +08:00 committed by GitHub
parent e5baa8d169
commit dbe29d0f41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 333 additions and 13 deletions

View file

@ -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<ThreadsState>, 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 })
}
}

View file

@ -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. */}
<ErrorBoundary>
<SidebarThreadTabs />
</ErrorBoundary>
<ErrorBoundary>
{landingPageInput}
</ErrorBoundary>
@ -3303,7 +3309,9 @@ export const SidebarChat = () => {
ref={sidebarRef}
className='w-full h-full flex flex-col overflow-hidden'
>
<ErrorBoundary>
<SidebarThreadTabs />
</ErrorBoundary>
<ErrorBoundary>
{messagesHTML}
</ErrorBoundary>

View file

@ -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
</div>
</div>
}
// 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<HTMLDivElement | null>(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 (
<div
className='flex items-center gap-0.5 px-1 py-1 border-b border-void-border-2 overflow-x-auto overflow-y-hidden flex-shrink-0'
// Translate vertical wheel events to horizontal scroll so the strip
// is usable with a regular mouse wheel (touchpads already scroll
// horizontally natively).
onWheel={(e) => {
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 (
<div
key={id}
ref={isActive ? activeTabRef : undefined}
onClick={() => 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'
? <LoaderCircle className='animate-spin shrink-0' size={10} />
: isRunning === 'awaiting_user'
? <MessageCircleQuestion className='shrink-0' size={10} />
: null}
<span className='truncate min-w-0'>{label}</span>
<button
onClick={(e) => { e.stopPropagation(); chatThreadsService.unpinThread(id); }}
className='ml-0.5 opacity-0 group-hover:opacity-100 shrink-0 rounded hover:bg-black/10 dark:hover:bg-white/10 flex items-center justify-center'
data-tooltip-id='void-tooltip'
data-tooltip-content='Remove from tabs (thread stays in history)'
data-tooltip-place='bottom'
>
<X size={10} />
</button>
</div>
)
})}
<button
onClick={() => chatThreadsService.openNewThread()}
className='shrink-0 ml-0.5 p-1 rounded text-void-fg-3 opacity-70 hover:opacity-100 hover:bg-zinc-700/10 dark:hover:bg-zinc-300/10'
data-tooltip-id='void-tooltip'
data-tooltip-content='New chat'
data-tooltip-place='bottom'
>
<Plus size={12} />
</button>
</div>
)
}

View file

@ -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'