From c92105969048202e9a026503157bd40bcad74494 Mon Sep 17 00:00:00 2001 From: Mathew Pareles Date: Thu, 17 Apr 2025 21:27:19 -0700 Subject: [PATCH] thread history ux --- .../contrib/void/browser/chatThreadService.ts | 16 +- .../src/markdown/ApplyBlockHoverButtons.tsx | 2 +- .../react/src/sidebar-tsx/SidebarChat.tsx | 144 ++++--- .../src/sidebar-tsx/SidebarThreadSelector.tsx | 383 ++++++++++++++---- 4 files changed, 406 insertions(+), 139 deletions(-) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 7a206021..a72070c7 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -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] diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx index 1b1d7e44..a6858032 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -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} 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 45f59403..85e2aa10 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 @@ -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 =
-
- {previousMessages.length > 0 && - - } -
-
- { textAreaRef.current?.focus() }} - > - { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }} - ref={textAreaRef} - fnsRef={textAreaFnsRef} - multiline={true} - /> - + + + const inputChatArea = { textAreaRef.current?.focus() }} + > + { chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) }} + ref={textAreaRef} + fnsRef={textAreaFnsRef} + multiline={true} + /> + + + + + const isLandingPage = previousMessages.length === 0 + + + const threadPageInput =
+
+ +
+
+ {inputChatArea}
- return ( -
- {/* History selector */} -
- - - -
- -
-
- - {messagesHTML} - -
- - {inputForm} - -
+ const landingPageInput =
+
+ {inputChatArea}
+
+ + const landingPageContent =
+ + {landingPageInput} + + + +
Previous Threads
+ + +
+
+ + + // const threadPageContent =
+ // {/* Thread content */} + //
+ //
+ // + // {messagesHTML} + // + //
+ // + // {inputForm} + // + //
+ //
+ const threadPageContent =
+ + + {messagesHTML} + + + {threadPageInput} + +
+ + + return (isLandingPage ? + landingPageContent + : threadPageContent ) } + + + 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 96909236..42d2fa6d 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,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 (
@@ -52,72 +37,300 @@ export const SidebarThreadSelector = () => {
{/* a list of all the past threads */} -
-
    - - {sortedThreadIds.length === 0 - - ?
    {`There are no chat threads yet.`}
    - - : sortedThreadIds.map((threadId) => { - if (!allThreads) { - return
  • {`Error accessing chat history.`}
  • ; - } - const pastThread = allThreads[threadId]; - if (!pastThread) { - return
  • {`Error accessing chat history.`}
  • ; - } - - - 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 ( -
  • - -
  • - ); - }) - } -
-
+ {/* */}
) } + + + + + + +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
+
    + + {sortedThreadIds.length === 0 + + ?
    {`There are no chat threads yet.`}
    + + : sortedThreadIds.map((threadId) => { + if (!allThreads) { + return
  • {`Error accessing chat history.`}
  • ; + } + const pastThread = allThreads[threadId]; + if (!pastThread) { + return
  • {`Error accessing chat history.`}
  • ; + } + + + 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 ( +
  • + +
  • + ); + }) + } +
+
+} + + +const numInitialThreads = 3 + +export const PastThreadsList = ({ className = '' }: { className?: string }) => { + const [showAll, setShowAll] = useState(false); + + const [hoveredIdx, setHoveredIdx] = useState(null) + + const threadsState = useChatThreadsState() + const { allThreads } = threadsState + + if (!allThreads) { + return
{`Error accessing chat history.`}
; + } + + // 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 ( +
+ {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
{`Error accessing chat history.`}
; + } + + return ( + + ); + }) + } + + {hasMoreThreads && !showAll && ( +
setShowAll(true)} + > + Show {sortedThreadIds.length - numInitialThreads} more... +
+ )} + {hasMoreThreads && showAll && ( +
setShowAll(false)} + > + Show less +
+ )} +
+ ); +}; + + + + + +// 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 ? +
+ { setIsTrashPressed(false); }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Cancel' + /> + { chatThreadsService.deleteThread(threadId); setIsTrashPressed(false); }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Confirm' + /> +
+ : { 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 = + {formatDate(new Date(pastThread.lastModified))} + + + return
{ + 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} + > +
+ + {firstMsg} + + +
+ {idx === hoveredIdx ? + + : dateHTML + } +
+
+
+}