thread history ux

This commit is contained in:
Mathew Pareles 2025-04-17 21:27:19 -07:00
parent cee6dcc524
commit c921059690
4 changed files with 406 additions and 139 deletions

View file

@ -91,7 +91,7 @@ const defaultMessageState: UserMessageState = {
// a 'thread' means a chat message history
type ThreadType = {
export type ThreadType = {
id: string; // store the id here too
createdAt: string; // ISO string
lastModified: string; // ISO string
@ -177,6 +177,7 @@ export interface IChatThreadService {
getCurrentThread(): ThreadType;
openNewThread(): void;
deleteThread(threadId: string): void;
switchToThread(threadId: string): void;
// exposed getters/setters
@ -1389,6 +1390,19 @@ We only need to do it for files that were edited since `from`, ie files between
}
deleteThread(threadId: string): void {
const { allThreads: currentThreads } = this.state
// delete the thread
const newThreads = { ...currentThreads };
delete newThreads[threadId];
// store the updated threads
this._storeAllThreads(newThreads);
this._setState({ ...this.state, allThreads: newThreads }, true)
}
private _addMessageToThread(threadId: string, message: ChatMessage) {
const { allThreads } = this.state
const oldThread = allThreads[threadId]

View file

@ -37,7 +37,7 @@ export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: Ico
size-[18px]
p-[2px]
flex items-center justify-center
text-sm bg-void-bg-3 text-void-fg-3
text-sm text-void-fg-3
hover:brightness-110
disabled:opacity-50 disabled:cursor-not-allowed
${className}

View file

@ -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 { SidebarThreadSelector } from './SidebarThreadSelector.js';
import { OldSidebarThreadSelector, 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';
@ -2631,61 +2631,101 @@ export const SidebarChat = () => {
}
}, [onSubmit, onAbort, isRunning])
const inputForm = <div key={'input' + chatThreadsState.currentThreadId}>
<div className='px-4'>
{previousMessages.length > 0 &&
<CommandBarInChat />
}
</div>
<div
className='px-2 pb-2'
>
<VoidChatArea
featureName='Chat'
onSubmit={onSubmit}
onAbort={onAbort}
isStreaming={!!isRunning}
isDisabled={isDisabled}
showSelections={true}
showProspectiveSelections={previousMessagesHTML.length === 0}
selections={selections}
setSelections={setSelections}
onClickAnywhere={() => { textAreaRef.current?.focus() }}
>
<VoidInputBox2
className={`min-h-[81px] px-0.5 py-0.5`}
placeholder={`${keybindingString ? `${keybindingString} to add a file. ` : ''}Enter instructions...`}
onChangeText={onChangeText}
onKeyDown={onKeyDown}
onFocus={() => { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }}
ref={textAreaRef}
fnsRef={textAreaFnsRef}
multiline={true}
/>
</VoidChatArea>
const inputChatArea = <VoidChatArea
featureName='Chat'
onSubmit={onSubmit}
onAbort={onAbort}
isStreaming={!!isRunning}
isDisabled={isDisabled}
showSelections={true}
showProspectiveSelections={previousMessagesHTML.length === 0}
selections={selections}
setSelections={setSelections}
onClickAnywhere={() => { textAreaRef.current?.focus() }}
>
<VoidInputBox2
className={`min-h-[81px] px-0.5 py-0.5`}
placeholder={`${keybindingString ? `${keybindingString} to add a file. ` : ''}Enter instructions...`}
onChangeText={onChangeText}
onKeyDown={onKeyDown}
onFocus={() => { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }}
ref={textAreaRef}
fnsRef={textAreaFnsRef}
multiline={true}
/>
</VoidChatArea>
const isLandingPage = previousMessages.length === 0
const threadPageInput = <div key={'input' + chatThreadsState.currentThreadId}>
<div className='px-4'>
<CommandBarInChat />
</div>
<div className='px-2 pb-2'>
{inputChatArea}
</div>
</div>
return (
<div ref={sidebarRef} className='w-full h-full flex flex-col overflow-hidden'>
{/* History selector */}
<div className={`w-full ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow ring-inset z-10`}>
<ErrorBoundary>
<SidebarThreadSelector />
</ErrorBoundary>
</div>
<div className='flex-1 flex flex-col overflow-hidden'>
<div className={`flex-1 overflow-hidden ${previousMessages.length === 0 ? 'h-0 max-h-0 pb-2' : ''}`}>
<ErrorBoundary>
{messagesHTML}
</ErrorBoundary>
</div>
<ErrorBoundary>
{inputForm}
</ErrorBoundary>
</div>
const landingPageInput = <div>
<div className='pt-8'>
{inputChatArea}
</div>
</div>
const landingPageContent = <div
ref={sidebarRef}
className='w-full h-full max-h-full flex flex-col overflow-auto px-4'
>
<ErrorBoundary>
{landingPageInput}
</ErrorBoundary>
<ErrorBoundary>
<div className='pt-8 mb-2 text-void-fg-1 text-root'>Previous Threads</div>
<PastThreadsList />
</ErrorBoundary>
</div>
// const threadPageContent = <div>
// {/* Thread content */}
// <div className='flex flex-col overflow-hidden'>
// <div className={`overflow-hidden ${previousMessages.length === 0 ? 'h-0 max-h-0 pb-2' : ''}`}>
// <ErrorBoundary>
// {messagesHTML}
// </ErrorBoundary>
// </div>
// <ErrorBoundary>
// {inputForm}
// </ErrorBoundary>
// </div>
// </div>
const threadPageContent = <div
ref={sidebarRef}
className='w-full h-full flex flex-col overflow-hidden'
>
<ErrorBoundary>
{messagesHTML}
</ErrorBoundary>
<ErrorBoundary>
{threadPageInput}
</ErrorBoundary>
</div>
return (isLandingPage ?
landingPageContent
: threadPageContent
)
}

View file

@ -3,35 +3,20 @@
* Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information.
*--------------------------------------------------------------------------------------*/
import React from "react";
import { useState } from 'react';
import { IconShell1 } from '../markdown/ApplyBlockHoverButtons.js';
import { useAccessor, useChatThreadsState } from '../util/services.js';
import { ISidebarStateService } from '../../../sidebarStateService.js';
import { IconX } from './SidebarChat.js';
import { Check, Trash2, X } from 'lucide-react';
import { ThreadType } from '../../../chatThreadService.js';
const truncate = (s: string) => {
let len = s.length
const TRUNC_AFTER = 16
if (len >= TRUNC_AFTER)
s = s.substring(0, TRUNC_AFTER) + '...'
return s
}
export const OldSidebarThreadSelector = () => {
export const SidebarThreadSelector = () => {
const threadsState = useChatThreadsState()
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
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="flex p-2 flex-col gap-y-1 max-h-[200px] overflow-y-auto">
@ -52,72 +37,300 @@ export const SidebarThreadSelector = () => {
</div>
{/* a list of all the past threads */}
<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-sm">{`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)}
onDoubleClick={() => sidebarStateService.setState({ isHistoryOpen: false })}
title={new Date(pastThread.lastModified).toLocaleString()}
>
<div className='truncate'>{`${firstMsg}`}</div>
<div>{`\u00A0(${numMessages})`}</div>
</button>
</li>
);
})
}
</ul>
</div>
{/* <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 }) => {
const [showAll, setShowAll] = useState(false);
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
const threadsState = useChatThreadsState()
const { allThreads } = threadsState
if (!allThreads) {
return <div key="error" className="p-1">{`Error accessing chat history.`}</div>;
}
// 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)
// Get only first 5 threads if not showing all
const hasMoreThreads = sortedThreadIds.length > numInitialThreads;
const displayThreads = showAll ? sortedThreadIds : sortedThreadIds.slice(0, numInitialThreads);
return (
<div className={`flex flex-col mb-2 gap-2 w-full text-nowrap text-void-fg-3 select-none relative ${className}`}>
{displayThreads.length === 0
? <></> // No chats yet... Suggestion: Tell me about my codebase Suggestion: Create a new .voidrules file in the root of my repo
: displayThreads.map((threadId, i) => {
const pastThread = allThreads[threadId];
if (!pastThread) {
return <div key={i} className="p-1">{`Error accessing chat history.`}</div>;
}
return (
<PastThreadElement
key={pastThread.id}
pastThread={pastThread}
idx={i}
hoveredIdx={hoveredIdx}
setHoveredIdx={setHoveredIdx}
/>
);
})
}
{hasMoreThreads && !showAll && (
<div
className="text-void-fg-3 opacity-60 hover:opacity-100 hover:brightness-115 cursor-pointer p-1 text-xs"
onClick={() => setShowAll(true)}
>
Show {sortedThreadIds.length - numInitialThreads} more...
</div>
)}
{hasMoreThreads && showAll && (
<div
className="text-void-fg-3 opacity-60 hover:opacity-100 hover:brightness-115 cursor-pointer p-1 text-xs"
onClick={() => setShowAll(false)}
>
Show less
</div>
)}
</div>
);
};
// Format date to display as today, yesterday, or date
const formatDate = (date: Date) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date >= today) {
return 'Today';
} else if (date >= yesterday) {
return 'Yesterday';
} else {
return `${date.toLocaleString('default', { month: 'short' })} ${date.getDate()}`;
}
};
// Format time to 12-hour format
const formatTime = (date: Date) => {
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
};
const TrashButton = ({ threadId }: { threadId: string }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const [isTrashPressed, setIsTrashPressed] = useState(false)
return (isTrashPressed ?
<div className='flex flex-nowrap text-nowrap gap-1'>
<IconShell1
Icon={X}
className='size-[11px]'
onClick={() => { setIsTrashPressed(false); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Cancel'
/>
<IconShell1
Icon={Check}
className='size-[11px]'
onClick={() => { chatThreadsService.deleteThread(threadId); setIsTrashPressed(false); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Confirm'
/>
</div>
: <IconShell1
Icon={Trash2}
className='size-[11px]'
onClick={() => { setIsTrashPressed(true); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Delete thread?'
/>
)
}
const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx }: { pastThread: ThreadType, idx: number, hoveredIdx: number | null, setHoveredIdx: (idx: number | null) => void }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const sidebarStateService = accessor.get('ISidebarStateService')
let firstMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
const firsUsertMsgObj = pastThread.messages[firstUserMsgIdx];
firstMsg = firsUsertMsgObj.role === 'user' && firsUsertMsgObj.displayContent || '';
} else {
firstMsg = '""';
}
const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length;
const dateHTML = <span
className='inline-flex items-center'
data-tooltip-id='void-tooltip'
data-tooltip-content={`Last modified ${formatTime(new Date(pastThread.lastModified))}`}
data-tooltip-place='top'
>
{formatDate(new Date(pastThread.lastModified))}
</span>
return <div
key={pastThread.id}
className={`
py-1 px-2 rounded text-sm bg-zinc-800/5 hover:bg-zinc-800/10 dark:bg-zinc-200/5 dark:hover:bg-zinc-200/10 cursor-pointer
`}
onClick={() => {
chatThreadsService.switchToThread(pastThread.id);
sidebarStateService.setState({ isHistoryOpen: false });
}}
onMouseEnter={() => setHoveredIdx(idx)}
onMouseLeave={() => setHoveredIdx(null)}
data-tooltip-id='void-tooltip'
data-tooltip-content={`${numMessages} messages`}
data-tooltip-place='top'
data-tooltip-delay-show={500}
>
<div className="flex items-center justify-between gap-1">
<span className="flex items-center gap-2 min-w-0 overflow-hidden">
<span className="truncate overflow-hidden text-ellipsis">{firstMsg}</span>
</span>
<div className="flex items-center gap-2 opacity-60">
{idx === hoveredIdx ?
<TrashButton threadId={pastThread.id} />
: dateHTML
}
</div>
</div>
</div>
}