improve checkpoint logic + UI

This commit is contained in:
Andrew Pareles 2025-04-06 03:29:45 -07:00
parent bd0db41f67
commit 033de587f2
5 changed files with 231 additions and 181 deletions

View file

@ -55,6 +55,9 @@ LLM Edit
x
LLM Edit
INVARIANT:
A checkpoint appears before every LLM message, and before every user message (before user really means directly after LLM is done).
*/
@ -99,7 +102,7 @@ type ThreadType = {
// this doesn't need to go in a state object, but feels right
state: {
currCheckpointIdx: number | null; // the latest checkpoint we're at (always defined unless chat is empty so there are no checkpts)
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)
stagingSelections: StagingSelectionItem[];
focusedMessageIdx: number | undefined; // index of the user message that is being edited (undefined if none)
@ -775,8 +778,9 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// if awaiting user approval, keep isRunning true, else end isRunning
this._setStreamState(threadId, { isRunning: isRunningWhenEnd }, 'merge')
// if successful, add checkpoint
this._addUserCheckpoint({ threadId })
// add checkpoint before the next user message
if (!isRunningWhenEnd)
this._addUserCheckpoint({ threadId })
// capture number of messages sent
this._metricsService.capture('Agent Loop Done', { nMessagesSent, chatMode })
@ -785,31 +789,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
private _addCheckpoint(threadId: string, checkpoint: CheckpointEntry) {
this._addMessageToThread(threadId, checkpoint)
// update latest checkpoint idx to the one we just added
const newThread = this.state.allThreads[threadId]
if (!newThread) return // should never happen
const currCheckpointIdx = newThread.messages.length - 1
this._setThreadState(threadId, { currCheckpointIdx })
// // update latest checkpoint idx to the one we just added
// const newThread = this.state.allThreads[threadId]
// if (!newThread) return // should never happen
// const currCheckpointIdx = newThread.messages.length - 1
// this._setThreadState(threadId, { currCheckpointIdx: currCheckpointIdx })
}
// merge any LLM checkpoint before this one (and after a user checkpoint if one exists), and add the checkpoint
// call this right after LLM edits a file
private _addToolEditCheckpoint({ threadId, uri, }: { threadId: string, uri: URI }) {
const thread = this.state.allThreads[threadId]
if (!thread) return
const { model } = this._voidModelService.getModel(uri)
if (!model) return // should never happen
const diffAreasSnapshot = this._editCodeService.getVoidFileSnapshot(uri)
this._addCheckpoint(threadId, {
role: 'checkpoint',
type: 'tool_edit',
voidFileSnapshotOfURI: { [uri.fsPath]: diffAreasSnapshot },
userModifications: { voidFileSnapshotOfURI: {} },
})
}
private _editMessageInThread(threadId: string, messageIdx: number, newMessage: ChatMessage,) {
const { allThreads } = this.state
@ -833,17 +820,25 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
private _getCheckpointInfo = (checkpointMessage: ChatMessage & { role: 'checkpoint' }, fsPath: string, opts: { includeUserModifiedChanges: boolean }) => {
const voidFileSnapshot = checkpointMessage.voidFileSnapshotOfURI ? checkpointMessage.voidFileSnapshotOfURI[fsPath] ?? null : null
if (!opts.includeUserModifiedChanges) { return { voidFileSnapshot, } }
private _computeCheckpointInfo({ threadId }: { threadId: string }) {
const userModifiedVoidFileSnapshot = fsPath in checkpointMessage.userModifications.voidFileSnapshotOfURI ? checkpointMessage.userModifications.voidFileSnapshotOfURI[fsPath] ?? null : null
return { voidFileSnapshot: userModifiedVoidFileSnapshot ?? voidFileSnapshot, }
}
private _computeNewCheckpointInfo({ threadId }: { threadId: string }) {
const thread = this.state.allThreads[threadId]
if (!thread) return
const { currCheckpointIdx } = thread.state
if (currCheckpointIdx === null) return
const lastCheckpointIdx = findLastIdx(thread.messages, (m) => m.role === 'checkpoint') ?? -1
if (lastCheckpointIdx === -1) return
const voidFileSnapshotOfURI: { [fsPath: string]: VoidFileSnapshot | undefined } = {}
// add a change for all the URIs in the checkpoint history
const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: 0, hiIdx: currCheckpointIdx, }) ?? {}
const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: 0, hiIdx: lastCheckpointIdx, }) ?? {}
for (const fsPath in lastIdxOfURI ?? {}) {
const { model } = this._voidModelService.getModelFromFsPath(fsPath)
if (!model) continue
@ -871,44 +866,32 @@ class ChatThreadService extends Disposable implements IChatThreadService {
return { voidFileSnapshotOfURI }
}
// call this right before user sends message or reverts
private _addUserCheckpoint({ threadId }: { threadId: string }) {
const { voidFileSnapshotOfURI } = this._computeCheckpointInfo({ threadId }) ?? {}
const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId }) ?? {}
this._addCheckpoint(threadId, {
role: 'checkpoint',
type: 'user_edit',
voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {},
userModifications: {
voidFileSnapshotOfURI: {},
},
userModifications: { voidFileSnapshotOfURI: {}, },
})
}
private _addUserModificationsToCurrCheckpoint({ threadId }: { threadId: string }) {
const { voidFileSnapshotOfURI } = this._computeCheckpointInfo({ threadId }) ?? {}
const res = this._getCurrentCheckpoint(threadId)
if (!res) return
const [checkpoint, checkpointIdx] = res
this._editMessageInThread(threadId, checkpointIdx, {
...checkpoint,
userModifications: { voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, },
})
}
private _getCurrentCheckpoint(threadId: string): [CheckpointEntry, number] | undefined {
// call this right after LLM edits a file
private _addToolEditCheckpoint({ threadId, uri, }: { threadId: string, uri: URI }) {
const thread = this.state.allThreads[threadId]
if (!thread) return
const { currCheckpointIdx } = thread.state
if (currCheckpointIdx === null) return
const checkpoint = thread.messages[currCheckpointIdx]
if (!checkpoint) return
if (checkpoint.role !== 'checkpoint') return
return [checkpoint, currCheckpointIdx]
const { model } = this._voidModelService.getModel(uri)
if (!model) return // should never happen
const diffAreasSnapshot = this._editCodeService.getVoidFileSnapshot(uri)
this._addCheckpoint(threadId, {
role: 'checkpoint',
type: 'tool_edit',
voidFileSnapshotOfURI: { [uri.fsPath]: diffAreasSnapshot },
userModifications: { voidFileSnapshotOfURI: {} },
})
}
private _getCheckpointBeforeMessage = ({ threadId, messageIdx }: { threadId: string, messageIdx: number }): [CheckpointEntry, number] | undefined => {
const thread = this.state.allThreads[threadId]
if (!thread) return undefined
@ -927,7 +910,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const lastIdxOfURI: { [fsPath: string]: number } = {}
for (let i = loIdx; i <= hiIdx; i += 1) {
const message = thread.messages[i]
if (message.role !== 'checkpoint') continue
if (message?.role !== 'checkpoint') continue
for (const fsPath in message.voidFileSnapshotOfURI) { // do not include userModified.beforeStrOfURI here, jumping should not include those changes
lastIdxOfURI[fsPath] = i
}
@ -935,27 +918,49 @@ class ChatThreadService extends Disposable implements IChatThreadService {
return { lastIdxOfURI }
}
private _getCheckpointInfo = (checkpointMessage: ChatMessage & { role: 'checkpoint' }, fsPath: string, opts: { includeUserModifiedChanges: boolean }) => {
const voidFileSnapshot = checkpointMessage.voidFileSnapshotOfURI ? checkpointMessage.voidFileSnapshotOfURI[fsPath] ?? null : null
if (!opts.includeUserModifiedChanges) { return { voidFileSnapshot, } }
private _readCurrentCheckpoint(threadId: string): [CheckpointEntry, number] | undefined {
const thread = this.state.allThreads[threadId]
if (!thread) return
const userModifiedVoidFileSnapshot = fsPath in checkpointMessage.userModifications.voidFileSnapshotOfURI ? checkpointMessage.userModifications.voidFileSnapshotOfURI[fsPath] ?? null : null
return { voidFileSnapshot: userModifiedVoidFileSnapshot ?? voidFileSnapshot, }
const { currCheckpointIdx } = thread.state
if (currCheckpointIdx === null) return
const checkpoint = thread.messages[currCheckpointIdx]
if (!checkpoint) return
if (checkpoint.role !== 'checkpoint') return
return [checkpoint, currCheckpointIdx]
}
private _addUserModificationsToCurrCheckpoint({ threadId }: { threadId: string }) {
const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId }) ?? {}
const res = this._readCurrentCheckpoint(threadId)
if (!res) return
const [checkpoint, checkpointIdx] = res
this._editMessageInThread(threadId, checkpointIdx, {
...checkpoint,
userModifications: { voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, },
})
}
// private _writeFullFile = ({ fsPath, text }: { fsPath: string, text: string }) => {
// const { model } = this._voidModelService.getModelFromFsPath(fsPath)
// if (!model) return // should never happen
// model.applyEdits([{
// range: { startLineNumber: 1, startColumn: 1, endLineNumber: model.getLineCount(), endColumn: Number.MAX_SAFE_INTEGER }, // whole file
// text
// }])
// }
jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified }: { threadId: string, messageIdx: number, jumpToUserModified: boolean }) {
private _makeUsStandOnCheckpoint({ threadId }: { threadId: string }) {
const thread = this.state.allThreads[threadId]
if (!thread) return
if (thread.state.currCheckpointIdx === null) {
const lastMsg = thread.messages[thread.messages.length - 1]
if (lastMsg?.role !== 'checkpoint')
this._addUserCheckpoint({ threadId })
this._setThreadState(threadId, { currCheckpointIdx: thread.messages.length - 1 })
}
}
jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified }: { threadId: string, messageIdx: number, jumpToUserModified: boolean }) {
// if null, add a new temp checkpoint so user can jump forward again
this._makeUsStandOnCheckpoint({ threadId })
const thread = this.state.allThreads[threadId]
if (!thread) return
if (this.streamState[threadId]?.isRunning) return
const c = this._getCheckpointBeforeMessage({ threadId, messageIdx })
if (c === undefined) return // should never happen
@ -1045,7 +1050,6 @@ We only need to do it for files that were edited since `from`, ie files between
}
this._setThreadState(threadId, { currCheckpointIdx: toIdx })
// TODO!!! add/merge a checkpoint modification if relevant
}
@ -1090,6 +1094,12 @@ We only need to do it for files that were edited since `from`, ie files between
const thread = this.state.allThreads[threadId]
if (!thread) return // should never happen
// add dummy before this message to keep checkpoint before user message idea consistent
if (thread.messages.length === 0) {
this._addUserCheckpoint({ threadId })
}
// if the current thread is already streaming, stop it (this simply resolves the promise to free up space)
const llmCancelToken = this.streamState[threadId]?.streamingToken
if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken)
@ -1109,6 +1119,8 @@ We only need to do it for files that were edited since `from`, ie files between
const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState }
this._addMessageToThread(threadId, userHistoryElt)
this._setThreadState(threadId, { currCheckpointIdx: null }) // no longer at a checkpoint because started streaming
this._wrapRunAgentToNotify(
this._runChatAgent({ prevSelns, currSelns, threadId, userMessageContent, ...this._currentModelSelectionProps(), }),
threadId,

View file

@ -191,7 +191,7 @@ export const StatusIndicatorHTML = ({ applyBoxId, uri }: { applyBoxId: string, u
</div>
}
export const ApplyButtonsHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string, applyBoxId: string, uri: URI | 'current' }) => {
export const ApplyButtonsHTML = ({ codeStr, applyBoxId, reapplyIcon, uri }: { codeStr: string, applyBoxId: string, reapplyIcon: boolean, uri: URI | 'current' }) => {
const accessor = useAccessor()
const editCodeService = accessor.get('IEditCodeService')
const metricsService = accessor.get('IMetricsService')
@ -255,7 +255,7 @@ export const ApplyButtonsHTML = ({ codeStr, applyBoxId, uri }: { codeStr: string
}
if (currStreamState === 'idle-no-changes') {
return <IconShell1 Icon={Play} onClick={onClickSubmit} />
return <IconShell1 Icon={reapplyIcon ? RotateCw : Play} onClick={onClickSubmit} />
}
if (currStreamState === 'idle-has-changes') {
@ -322,7 +322,7 @@ export const BlockCodeApplyWrapper = ({
<div className={`${canApply ? '' : 'hidden'} flex items-center gap-1`}>
<JumpToFileButton uri={uri} />
{currStreamState === 'idle-no-changes' && <CopyButton codeStr={initValue} />}
<ApplyButtonsHTML uri={uri} applyBoxId={applyBoxId} codeStr={initValue} />
<ApplyButtonsHTML uri={uri} applyBoxId={applyBoxId} codeStr={initValue} reapplyIcon={false} />
</div>
</div>

View file

@ -22,7 +22,7 @@ import { ChatMode, FeatureName, isFeatureNameDisabled } from '../../../../../../
import { WarningBox } from '../void-settings-tsx/WarningBox.js';
import { getModelCapabilities, getIsResoningEnabledState } from '../../../../common/modelCapabilities.js';
import { AlertTriangle, Ban, ChevronRight, Dot, Pencil, Undo, Undo2, X } from 'lucide-react';
import { ChatMessage, StagingSelectionItem, ToolMessage, ToolRequestApproval } from '../../../../common/chatThreadServiceTypes.js';
import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage, ToolRequestApproval } from '../../../../common/chatThreadServiceTypes.js';
import { ToolCallParams, ToolName, toolNames, ToolNameWithApproval } from '../../../../common/toolsServiceTypes.js';
import { ApplyButtonsHTML, CopyButton, JumpToFileButton, JumpToTerminalButton, StatusIndicatorHTML, useApplyButtonState } from '../markdown/ApplyBlockHoverButtons.js';
import { IsRunningType } from '../../../chatThreadService.js';
@ -769,7 +769,7 @@ const SimplifiedToolHeader = ({
const UserMessageComponent = ({ chatMessage, messageIdx, isCommitted, _scrollToBottom }: { chatMessage: ChatMessage & { role: 'user' }, messageIdx: number, isCommitted: boolean, _scrollToBottom: (() => void) | null }) => {
const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, _scrollToBottom }: { chatMessage: ChatMessage & { role: 'user' }, messageIdx: number, isCheckpointGhost: boolean, _scrollToBottom: (() => void) | null }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
@ -886,7 +886,7 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCommitted, _scrollToB
}
}
if (!chatMessage.content && isCommitted) { // don't show if empty and not loading (if loading, want to show).
if (!chatMessage.content) { // don't show if empty and not loading (if loading, want to show).
return null
}
@ -929,6 +929,8 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isCommitted, _scrollToB
${mode === 'edit' ? 'w-full max-w-full'
: mode === 'display' ? `self-end w-fit max-w-full whitespace-pre-wrap` : '' // user words should be pre
}
${isCheckpointGhost ? 'opacity-50 pointer-events-none' : ''}
`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@ -1062,12 +1064,11 @@ const ProseWrapper = ({ children }: { children: React.ReactNode }) => {
{children}
</div>
}
const AssistantMessageComponent = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunning, isToolBeingWritten }: { chatMessage: ChatMessage & { role: 'assistant' }, messageIdx: number, isCommitted: boolean, isLast: boolean, chatIsRunning: IsRunningType, isToolBeingWritten: boolean }) => {
const AssistantMessageComponent = ({ chatMessage, isCheckpointGhost, isCommitted, messageIdx }: { chatMessage: ChatMessage & { role: 'assistant' }, isCheckpointGhost: boolean, messageIdx: number, isCommitted: boolean }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
const reasoningStr = chatMessage.reasoning?.trim() || null
const hasReasoning = !!reasoningStr
const isDoneReasoning = !!chatMessage.content
@ -1080,34 +1081,36 @@ const AssistantMessageComponent = ({ chatMessage, isCommitted, messageIdx, isLas
}
const isEmpty = !chatMessage.content && !chatMessage.reasoning
const isLoading = !isCommitted && !isToolBeingWritten && (chatIsRunning === 'message' || chatIsRunning === 'awaiting_user')
const isLastAndLoading = isLast && isLoading
if (isEmpty && !isLastAndLoading) return null
if (isEmpty) return null
return <>
{/* reasoning token */}
{hasReasoning && <ReasoningWrapper isDoneReasoning={isDoneReasoning} isStreaming={!isCommitted}>
<SmallProseWrapper>
<ChatMarkdownRender
string={reasoningStr}
chatMessageLocation={chatMessageLocation}
isApplyEnabled={false}
isLinkDetectionEnabled={true}
/>
</SmallProseWrapper>
</ReasoningWrapper>}
{hasReasoning &&
<div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<ReasoningWrapper isDoneReasoning={isDoneReasoning} isStreaming={!isCommitted}>
<SmallProseWrapper>
<ChatMarkdownRender
string={reasoningStr}
chatMessageLocation={chatMessageLocation}
isApplyEnabled={false}
isLinkDetectionEnabled={true}
/>
</SmallProseWrapper>
</ReasoningWrapper>
</div>
}
{/* assistant message */}
<ProseWrapper>
<ChatMarkdownRender
string={chatMessage.content || ''}
chatMessageLocation={chatMessageLocation}
isApplyEnabled={true}
isLinkDetectionEnabled={true}
/>
{/* loading indicator */}
{isLoading && <IconLoading className='opacity-50 text-sm' />}
</ProseWrapper>
<div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<ProseWrapper>
<ChatMarkdownRender
string={chatMessage.content || ''}
chatMessageLocation={chatMessageLocation}
isApplyEnabled={true}
isLinkDetectionEnabled={true}
/>
</ProseWrapper>
</div>
</>
}
@ -1321,7 +1324,7 @@ const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr }: { applyBoxId: strin
<StatusIndicatorHTML applyBoxId={applyBoxId} uri={uri} />
<JumpToFileButton uri={uri} />
{currStreamState === 'idle-no-changes' && <CopyButton codeStr={codeStr} />}
<ApplyButtonsHTML applyBoxId={applyBoxId} uri={uri} codeStr={codeStr} />
<ApplyButtonsHTML applyBoxId={applyBoxId} uri={uri} codeStr={codeStr} reapplyIcon={true} />
</div>
}
@ -1822,20 +1825,26 @@ const toolNameToComponent: { [T in ToolName]: ToolComponent<T> } = {
};
const Checkpoint = ({ threadId, messageIdx }: { threadId: string; messageIdx: number }) => {
const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIsRunning }: { message: CheckpointEntry, threadId: string; messageIdx: number, isCheckpointGhost: boolean, threadIsRunning: boolean }) => {
const accessor = useAccessor()
const chatThreadService = accessor.get('IChatThreadService')
// const commandBarService = accessor.get('IVoidCommandBarService')
return <div
className='pointer-events-auto cursor-pointer select-none hover:brightness-125 flex items-center justify-center'
onClick={() => {
// reject all current changes and then jump back
// commandBarService.acceptOrRejectAllFiles({ behavior: 'accept' })
chatThreadService.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true })
}}>
<div className='bg-void-border-1 h-[1px] flex-grow'></div>
<div className='px-2'>Checkpoint</div>
<div className='bg-void-border-1 h-[1px] flex-grow'></div>
className={`
flex items-center justify-center
px-2 text-xs text-void-fg-3
${isCheckpointGhost ? 'opacity-50' : ''}
`}
>
<div
className='cursor-pointer select-none hover:brightness-125'
onClick={() => {
if (threadIsRunning) return
chatThreadService.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true })
}}
>
Checkpoint
</div>
</div>
}
@ -1845,45 +1854,57 @@ type ChatBubbleProps = {
chatMessage: ChatMessage,
messageIdx: number,
isCommitted: boolean,
isLast: boolean, // includes the streaming message (if streaming, isLast is false except for the streaming message)
canAcceptReject: boolean,
chatIsRunning: IsRunningType,
threadId: string,
isToolBeingWritten: boolean,
currCheckpointIdx: number,
_scrollToBottom: (() => void) | null,
}
const ChatBubble = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunning, threadId, isToolBeingWritten, _scrollToBottom }: ChatBubbleProps) => {
const ChatBubble = ({ chatMessage, currCheckpointIdx, isCommitted, messageIdx, canAcceptReject, chatIsRunning, threadId, _scrollToBottom }: ChatBubbleProps) => {
const role = chatMessage.role
const isCheckpointGhost = messageIdx > currCheckpointIdx && !chatIsRunning // whether to show as gray (if chat is running, for good measure just dont show any ghosts)
if (role === 'user') {
return <UserMessageComponent
chatMessage={chatMessage}
isCheckpointGhost={isCheckpointGhost}
messageIdx={messageIdx}
isCommitted={isCommitted}
_scrollToBottom={_scrollToBottom}
/>
}
else if (role === 'assistant') {
return <AssistantMessageComponent
chatMessage={chatMessage}
isCheckpointGhost={isCheckpointGhost}
messageIdx={messageIdx}
isCommitted={isCommitted}
chatIsRunning={chatIsRunning}
isLast={isLast}
isToolBeingWritten={isToolBeingWritten}
/>
}
else if (role === 'tool_request') {
const ToolRequestWrapper = toolNameToComponent[chatMessage.name]?.requestWrapper as RequestWrapper<ToolName>
const toolRequestType = (
const toolRequestState = (
chatIsRunning === 'awaiting_user' ? 'awaiting_user'
: chatIsRunning === 'tool' ? 'running'
: null
: chatIsRunning === 'message' ? null
: null
)
if (ToolRequestWrapper && isLast) { // if it's the last message
if (ToolRequestWrapper && canAcceptReject) { // if it's the last message
return <>
{toolRequestType !== null && <ToolRequestWrapper toolRequestState={toolRequestType} toolRequest={chatMessage} messageIdx={messageIdx} threadId={threadId} />}
{chatIsRunning === 'awaiting_user' && <ToolRequestAcceptRejectButtons />}
{toolRequestState !== null &&
<div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<ToolRequestWrapper
toolRequestState={toolRequestState}
toolRequest={chatMessage}
messageIdx={messageIdx}
threadId={threadId}
/>
</div>}
{chatIsRunning === 'awaiting_user' &&
<div className={`${isCheckpointGhost ? 'opacity-50 pointer-events-none' : ''}`}>
<ToolRequestAcceptRejectButtons />
</div>}
</>
}
return null
@ -1891,17 +1912,26 @@ const ChatBubble = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunnin
else if (role === 'tool') {
const ToolResultWrapper = toolNameToComponent[chatMessage.name]?.resultWrapper as ResultWrapper<ToolName>
if (ToolResultWrapper)
return <ToolResultWrapper toolMessage={chatMessage} messageIdx={messageIdx} threadId={threadId} />
return <div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<ToolResultWrapper
toolMessage={chatMessage}
messageIdx={messageIdx}
threadId={threadId}
/>
</div>
return null
}
else if (role === 'checkpoint') {
return <Checkpoint threadId={threadId} messageIdx={messageIdx} />
return <Checkpoint
threadId={threadId}
message={chatMessage}
messageIdx={messageIdx}
isCheckpointGhost={isCheckpointGhost}
threadIsRunning={!!chatIsRunning}
/>
}
else if (role === 'checkpoint_modification') {
return <Checkpoint threadId={threadId} messageIdx={messageIdx} />
}
}
@ -1974,7 +2004,7 @@ export const SidebarChat = () => {
const toolNameSoFar = currThreadStreamState?.toolNameSoFar
const toolParamsSoFar = currThreadStreamState?.toolParamsSoFar
const toolIsLoading = !!toolNameSoFar && toolNameSoFar === 'edit' // show loading for slow tools (right now just edit)
const toolIsGenerating = !!toolNameSoFar && toolNameSoFar === 'edit_file' // show loading for slow tools (right now just edit)
// ----- SIDEBAR CHAT state (local) -----
@ -2024,36 +2054,37 @@ export const SidebarChat = () => {
scrollContainerRef.current?.scrollTo({ top: 0, left: 0 })
}, [isHistoryOpen, currentThread.id])
const numMessages = previousMessages.length
const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint')
const previousMessagesHTML = useMemo(() => {
const threadId = currentThread.id
const currCheckpointIdx = chatThreadsState.allThreads[threadId]?.state?.currCheckpointIdx ?? Infinity // if not exist, treat like checkpoint is last message (infinity)
return previousMessages.map((message, i) => {
const isLast = i === lastMessageIdx && (isRunning === 'tool' || isRunning === 'awaiting_user')
return <div
key={getChatBubbleId(currentThread.id, i)}
className={`${currCheckpointIdx < i ? 'opacity-50 pointer-events-none select-none' : ''}`}>
<ChatBubble
chatMessage={message}
messageIdx={i}
isCommitted={true}
chatIsRunning={isRunning}
isLast={isLast}
threadId={threadId}
isToolBeingWritten={toolIsLoading}
_scrollToBottom={() => scrollToBottom(scrollContainerRef)}
/>
</div>
})
}, [previousMessages, isRunning, currentThread, numMessages])
const threadId = currentThread.id
const currCheckpointIdx = chatThreadsState.allThreads[threadId]?.state?.currCheckpointIdx ?? Infinity // if not exist, treat like checkpoint is last message (infinity)
const previousMessagesHTML = useMemo(() => {
const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint')
console.log('PREVMSGS', previousMessages)
return previousMessages.map((message, i) => {
const canAcceptReject = i === lastMessageIdx && message.role === 'tool_request'
return <ChatBubble
key={getChatBubbleId(threadId, i)}
currCheckpointIdx={currCheckpointIdx}
chatMessage={message}
messageIdx={i}
isCommitted={true}
chatIsRunning={isRunning}
canAcceptReject={canAcceptReject}
threadId={threadId}
_scrollToBottom={() => scrollToBottom(scrollContainerRef)}
/>
})
}, [previousMessages, isRunning, threadId])
const streamingChatIdx = previousMessagesHTML.length
const currStreamingMessageHTML = !!(reasoningSoFar || messageSoFar || isRunning) ?
<ChatBubble key={getChatBubbleId(currentThread.id, streamingChatIdx)}
const currStreamingMessageHTML = reasoningSoFar || messageSoFar || isRunning ?
<ChatBubble
key={getChatBubbleId(threadId, streamingChatIdx)}
currCheckpointIdx={currCheckpointIdx} // if streaming, can't be the case
chatMessage={{
role: 'assistant',
content: messageSoFar ?? '',
@ -2061,28 +2092,17 @@ export const SidebarChat = () => {
anthropicReasoning: null,
}}
messageIdx={streamingChatIdx}
isCommitted={!isRunning}
isCommitted={false}
chatIsRunning={isRunning}
isLast={true}
canAcceptReject={false}
threadId={threadId}
isToolBeingWritten={toolIsLoading}
_scrollToBottom={null}
/> : null
const proposed = toolNameSoFar && toolNames.includes(toolNameSoFar as ToolName) ? titleOfToolName[toolNameSoFar as ToolName]?.proposed : toolNameSoFar
const toolTitle = typeof proposed === 'function' ? proposed(null) : proposed
const currStreamingToolHTML = toolIsLoading ?
<ToolHeaderWrapper key={getChatBubbleId(currentThread.id, streamingChatIdx + 1)} title={toolTitle} desc1={<span className='flex items-center'>Generating<IconLoading /></span>} />
: null
const allMessagesHTML = [...previousMessagesHTML, currStreamingMessageHTML, currStreamingToolHTML]
const threadSelector = <div
className={`w-full ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow ring-inset z-10`}
>
<SidebarThreadSelector />
</div>
const proposedToolTitle = toolNameSoFar && toolNames.includes(toolNameSoFar as ToolName) ? titleOfToolName[toolNameSoFar as ToolName]?.proposed : toolNameSoFar
const generatingToolTitle = typeof proposedToolTitle === 'function' ? proposedToolTitle(null) : proposedToolTitle
const messagesHTML = <ScrollToBottomContainer
key={'messages' + chatThreadsState.currentThreadId} // force rerender on all children if id changes
@ -2097,7 +2117,20 @@ export const SidebarChat = () => {
`}
>
{/* previous messages */}
{allMessagesHTML}
{previousMessagesHTML}
{currStreamingMessageHTML}
{toolIsGenerating ?
<ToolHeaderWrapper key={getChatBubbleId(currentThread.id, streamingChatIdx + 1)} title={generatingToolTitle} desc1={<span className='flex items-center'>Generating<IconLoading /></span>} />
: null}
{isRunning === 'message' && !toolIsGenerating ? <ProseWrapper>
{/* loading indicator */}
{<IconLoading className='opacity-50 text-sm' />}
</ProseWrapper> : null}
{/* error message */}
@ -2158,7 +2191,11 @@ export const SidebarChat = () => {
return (
<div ref={sidebarRef} className='w-full h-full flex flex-col overflow-hidden'>
{threadSelector}
{/* History selector */}
<div className={`w-full ${isHistoryOpen ? '' : 'hidden'} ring-2 ring-widget-shadow ring-inset z-10`}>
<SidebarThreadSelector />
</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' : ''}`}>
{messagesHTML}

View file

@ -72,7 +72,7 @@ export const SidebarThreadSelector = () => {
let firstMsg = null;
// let secondMsg = null;
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role !== 'tool' && msg.role !== 'tool_request');
const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user');
if (firstUserMsgIdx !== -1) {
// firstMsg = truncate(pastThread.messages[firstMsgIdx].displayContent ?? '');

View file

@ -165,7 +165,7 @@ Here's an example of a good description:\n${editToolDescription}.`
export const chat_systemMessage = ({ workspaceFolders, openedURIs, activeURI, runningTerminalIds, directoryStr, chatMode: mode }: { workspaceFolders: string[], directoryStr: string, openedURIs: string[], activeURI: string | undefined, runningTerminalIds: string[], chatMode: ChatMode }) => `\
You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the Void code editor. Your job is \
You are an expert coding ${mode === 'agent' ? 'agent' : 'assistant'} that runs in the user's IDE called Void. Your job is \
${mode === 'agent' ? `to help the user develop, run, deploy, and make changes to their codebase. You should ALWAYS bring user's task to completion to the fullest extent possible, calling tools to make all necessary changes.`
: mode === 'gather' ? `to search and understand the user's codebase. You MUST use tools to read files and help the user understand the codebase, even if you were initially given files.`
: mode === 'normal' ? `to assist the user with their coding tasks.`
@ -224,6 +224,7 @@ Misc:
- Do not be lazy.
- NEVER re-write the entire file.
- Always wrap any code you produce in triple backticks, and specify a language if possible. For example, ${tripleTick[0]}typescript\n...\n${tripleTick[1]}.
- Today's date is ${new Date().toDateString()}
The user's codebase is structured as follows:\n${directoryStr}
\
`