mirror of
https://github.com/voideditor/void
synced 2026-05-22 08:58:26 +00:00
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:
parent
e5baa8d169
commit
dbe29d0f41
4 changed files with 333 additions and 13 deletions
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue