checkpoint UX and dropdown UI

This commit is contained in:
Andrew Pareles 2025-04-15 23:06:11 -07:00
parent b62943fffd
commit 884548615b
7 changed files with 157 additions and 136 deletions

View file

@ -349,36 +349,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
editUserMessageAndStreamResponse: IChatThreadService['editUserMessageAndStreamResponse'] = async ({ userMessage, messageIdx, threadId }) => {
const thread = this.state.allThreads[threadId]
if (!thread) return // should never happen
if (thread.messages?.[messageIdx]?.role !== 'user') {
throw new Error(`Error: editing a message with role !=='user'`)
}
// get prev and curr selections before clearing the message
const currSelns = thread.messages[messageIdx].state.stagingSelections || [] // staging selections for the edited message
// clear messages up to the index
const slicedMessages = thread.messages.slice(0, messageIdx)
this._setState({
allThreads: {
...this.state.allThreads,
[thread.id]: {
...thread,
messages: slicedMessages
}
}
}, true)
// re-add the message and stream it
this.addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, threadId })
}
private _currentModelSelectionProps = () => { private _currentModelSelectionProps = () => {
// these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools) // these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools)
const featureName: FeatureName = 'Chat' const featureName: FeatureName = 'Chat'
@ -883,7 +853,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const [_, toIdx] = c const [_, toIdx] = c
if (toIdx === fromIdx) return if (toIdx === fromIdx) return
// console.log(`going from ${fromIdx} to ${toIdx}`) console.log(`going from ${fromIdx} to ${toIdx}`)
// update the user's checkpoint // update the user's checkpoint
this._addUserModificationsToCurrCheckpoint({ threadId }) this._addUserModificationsToCurrCheckpoint({ threadId })
@ -895,15 +865,14 @@ A,B,C are all files.
x means a checkpoint where the file changed. x means a checkpoint where the file changed.
A B C D E F G H I A B C D E F G H I
x x x x x x x x x x x x x x x <-- you can't always go up to find the "before" version; sometimes you need to go down
| | | | | | | | | | | | | | | x
x | | | | | | | x --x-|-|-|-x---x-|----- <-- to
---x-|-|-|-x-|-x-|----- <-- to | | | | x x
x | | | | | x | | x x |
| | x x | | | | |
| | | | ----x-|---x-x------- <-- from
-------x-|---x-x------- <-- from x
x
We need to revert anything that happened between to+1 and from. We need to revert anything that happened between to+1 and from.
**We do this by finding the last x from 0...`to` for each file and applying those contents.** **We do this by finding the last x from 0...`to` for each file and applying those contents.**
@ -911,9 +880,19 @@ We only need to do it for files that were edited since `to`, ie files between to
*/ */
if (toIdx < fromIdx) { if (toIdx < fromIdx) {
const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: toIdx + 1, hiIdx: fromIdx }) const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: toIdx + 1, hiIdx: fromIdx })
const idxes = function* () {
for (let k = toIdx; k >= 0; k -= 1) { // first go up
yield k
}
for (let k = toIdx + 1; k < thread.messages.length; k += 1) { // then go down
yield k
}
}
for (const fsPath in lastIdxOfURI) { for (const fsPath in lastIdxOfURI) {
// apply lowest down content for each uri (or original if not found) // find the first instance of this file starting at toIdx (go up to latest file; if there is none, go down)
for (let k = toIdx; k >= 0; k -= 1) { for (const k of idxes()) {
const message = thread.messages[k] const message = thread.messages[k]
if (message.role !== 'checkpoint') continue if (message.role !== 'checkpoint') continue
const res = this._getCheckpointInfo(message, fsPath, { includeUserModifiedChanges: jumpToUserModified }) const res = this._getCheckpointInfo(message, fsPath, { includeUserModifiedChanges: jumpToUserModified })
@ -929,16 +908,16 @@ We only need to do it for files that were edited since `to`, ie files between to
/* /*
if redoing if redoing
A B C D E F G H I A B C D E F G H I J
x x x x x x x x x x x x x x x x
| | | | | | | | | | | | | | | x x x
x | | | | | | | x --x-|-|-|-x---x-|-|--- <-- from
---x-|-|-|-x-|-x-|----- <-- from | | | | x x
x | | | | | x | | x x |
| | x x | | | | |
| | | | ----x-|---x-x-----|--- <-- to
-------x-|---x-x------- <-- to x x
x
We need to apply latest change for anything that happened between from+1 and to. We need to apply latest change for anything that happened between from+1 and to.
We only need to do it for files that were edited since `from`, ie files between from+1...to. We only need to do it for files that were edited since `from`, ie files between from+1...to.
@ -954,7 +933,6 @@ We only need to do it for files that were edited since `from`, ie files between
if (!res) continue if (!res) continue
const { voidFileSnapshot } = res const { voidFileSnapshot } = res
if (!voidFileSnapshot) continue if (!voidFileSnapshot) continue
this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot) this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot)
break break
} }
@ -1003,11 +981,15 @@ We only need to do it for files that were edited since `from`, ie files between
}) })
} }
async addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], threadId: string }) { dismissStreamError(threadId: string): void {
this._setStreamState(threadId, { error: undefined }, 'merge')
}
private async _addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], threadId: string }) {
const thread = this.state.allThreads[threadId] const thread = this.state.allThreads[threadId]
if (!thread) return // should never happen if (!thread) return // should never happen
const llmCancelToken = this.streamState[threadId]?.streamingToken // currently streaming LLM on this thread const llmCancelToken = this.streamState[threadId]?.streamingToken // currently streaming LLM on this thread
if (llmCancelToken === undefined && this.streamState[threadId]?.isRunning === 'LLM') { if (llmCancelToken === undefined && this.streamState[threadId]?.isRunning === 'LLM') {
// if about to call the other LLM, just wait for it by stopping right now // if about to call the other LLM, just wait for it by stopping right now
@ -1016,14 +998,11 @@ We only need to do it for files that were edited since `from`, ie files between
// stop it (this simply resolves the promise to free up space) // stop it (this simply resolves the promise to free up space)
if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken) if (llmCancelToken !== undefined) this._llmMessageService.abort(llmCancelToken)
// add dummy before this message to keep checkpoint before user message idea consistent // add dummy before this message to keep checkpoint before user message idea consistent
if (thread.messages.length === 0) { if (thread.messages.length === 0) {
this._addUserCheckpoint({ threadId }) this._addUserCheckpoint({ threadId })
} }
const { chatMode } = this._settingsService.state.globalSettings const { chatMode } = this._settingsService.state.globalSettings
// add user's message to chat history // add user's message to chat history
@ -1043,11 +1022,61 @@ We only need to do it for files that were edited since `from`, ie files between
) )
} }
dismissStreamError(threadId: string): void {
this._setStreamState(threadId, { error: undefined }, 'merge') async addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], threadId: string }) {
const thread = this.state.allThreads[threadId];
if (!thread) return
// if there's a current checkpoint, delete all messages after it
if (thread.state.currCheckpointIdx !== null) {
const checkpointIdx = thread.state.currCheckpointIdx;
const newMessages = thread.messages.slice(0, checkpointIdx + 1);
// Update the thread with truncated messages
const newThreads = {
...this.state.allThreads,
[threadId]: {
...thread,
lastModified: new Date().toISOString(),
messages: newMessages,
}
};
this._storeAllThreads(newThreads);
this._setState({ allThreads: newThreads }, true);
}
// Now call the original method to add the user message and stream the response
await this._addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId });
} }
editUserMessageAndStreamResponse: IChatThreadService['editUserMessageAndStreamResponse'] = async ({ userMessage, messageIdx, threadId }) => {
const thread = this.state.allThreads[threadId]
if (!thread) return // should never happen
if (thread.messages?.[messageIdx]?.role !== 'user') {
throw new Error(`Error: editing a message with role !=='user'`)
}
// get prev and curr selections before clearing the message
const currSelns = thread.messages[messageIdx].state.stagingSelections || [] // staging selections for the edited message
// clear messages up to the index
const slicedMessages = thread.messages.slice(0, messageIdx)
this._setState({
allThreads: {
...this.state.allThreads,
[thread.id]: {
...thread,
messages: slicedMessages
}
}
}, true)
// re-add the message and stream it
this._addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, threadId })
}
// ---------- the rest ---------- // ---------- the rest ----------

View file

@ -258,6 +258,22 @@ class EditCodeService extends Disposable implements IEditCodeService {
this._realignAllDiffAreasLines(uri, change.text, change.range) this._realignAllDiffAreasLines(uri, change.text, change.range)
} }
this._refreshStylesAndDiffsInURI(uri) this._refreshStylesAndDiffsInURI(uri)
// if diffarea has no diffs after a user edit, delete it
const diffAreasToDelete: DiffZone[] = []
for (const diffareaid of this.diffAreasOfURI[uri.fsPath] ?? []) {
const diffArea = this.diffAreaOfId[diffareaid] ?? null
const shouldDelete = diffArea?.type === 'DiffZone' && Object.keys(diffArea._diffOfId).length === 0
if (shouldDelete) {
diffAreasToDelete.push(diffArea)
}
}
if (diffAreasToDelete.length !== 0) {
const { onFinishEdit } = this._addToHistory(uri)
diffAreasToDelete.forEach(da => this._deleteDiffZone(da))
onFinishEdit()
}
} }

View file

@ -1949,10 +1949,14 @@ const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIs
style={{ position: 'relative', display: 'inline-block' }} // allow absolute icon style={{ position: 'relative', display: 'inline-block' }} // allow absolute icon
onClick={() => { onClick={() => {
if (threadIsRunning) return if (threadIsRunning) return
chatThreadService.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true }) chatThreadService.jumpToCheckpointBeforeMessageIdx({
threadId,
messageIdx,
jumpToUserModified: messageIdx === (chatThreadService.state.allThreads[threadId]?.messages.length ?? 0) - 1
})
}} }}
> >
Checkpoint Checkpoint
</div> </div>
</div> </div>
} }

View file

@ -11,7 +11,7 @@
--void-bg-1: var(--vscode-input-background); --void-bg-1: var(--vscode-input-background);
--void-bg-1-alt: var(--vscode-badge-background); --void-bg-1-alt: var(--vscode-badge-background);
--void-bg-2: var(--vscode-sideBar-background); --void-bg-2: var(--vscode-sideBar-background);
--void-bg-2-alt: color-mix(in srgb, var(--vscode-sideBar-background) 30%, var(--vscode-editor-background) 70%); --void-bg-2-alt: color-mix(in srgb, var(--vscode-editor-background) 30%, var(--vscode-sideBar-background) 70%);
--void-bg-3: var(--vscode-editor-background); --void-bg-3: var(--vscode-editor-background);
--void-fg-0: color-mix(in srgb, var(--vscode-tab-activeForeground) 90%, black 10%); --void-fg-0: color-mix(in srgb, var(--vscode-tab-activeForeground) 90%, black 10%);

View file

@ -664,57 +664,60 @@ export const VoidCustomDropdownBox = <T extends NonNullable<any>>({
{isOpen && ( {isOpen && (
<div <div
ref={refs.setFloating} ref={refs.setFloating}
className="z-10 bg-void-bg-1 border-void-border-1 border overflow-hidden rounded shadow-lg" className="z-10 bg-void-bg-1 border-void-border-3 border rounded shadow-lg"
style={{ style={{
position: strategy, position: strategy,
top: y ?? 0, top: y ?? 0,
left: x ?? 0, left: x ?? 0,
width: matchInputWidth width: (matchInputWidth
? (refs.reference.current instanceof HTMLElement ? refs.reference.current.offsetWidth : 0) ? (refs.reference.current instanceof HTMLElement ? refs.reference.current.offsetWidth : 0)
: Math.max( : Math.max(
(refs.reference.current instanceof HTMLElement ? refs.reference.current.offsetWidth : 0), (refs.reference.current instanceof HTMLElement ? refs.reference.current.offsetWidth : 0),
(measureRef.current instanceof HTMLElement ? measureRef.current.offsetWidth : 0) (measureRef.current instanceof HTMLElement ? measureRef.current.offsetWidth : 0)
), ))
}} }}
> onWheel={(e) => e.stopPropagation()}
{options.map((option) => { ><div className='overflow-auto max-h-80'>
const thisOptionIsSelected = getOptionsEqual(option, selectedOption);
const optionName = getOptionDropdownName(option);
const optionDetail = getOptionDropdownDetail?.(option) || '';
return ( {options.map((option) => {
<div const thisOptionIsSelected = getOptionsEqual(option, selectedOption);
key={optionName} const optionName = getOptionDropdownName(option);
className={`flex items-center px-2 py-1 cursor-pointer whitespace-nowrap const optionDetail = getOptionDropdownDetail?.(option) || '';
return (
<div
key={optionName}
className={`flex items-center px-2 py-1 pr-4 cursor-pointer whitespace-nowrap
transition-all duration-100 transition-all duration-100
bg-void-bg-1 ${thisOptionIsSelected ? 'bg-void-bg-2' : 'bg-void-bg-2-alt hover:bg-void-bg-2'}
${thisOptionIsSelected ? 'bg-void-bg-2' : 'hover:bg-void-bg-2'}
`} `}
onClick={() => { onClick={() => {
onChangeOption(option); onChangeOption(option);
setIsOpen(false); setIsOpen(false);
}} }}
> >
<div className="w-4 flex justify-center flex-shrink-0"> <div className="w-4 flex justify-center flex-shrink-0">
{thisOptionIsSelected && ( {thisOptionIsSelected && (
<svg className="size-3" viewBox="0 0 12 12" fill="none"> <svg className="size-3" viewBox="0 0 12 12" fill="none">
<path <path
d="M10 3L4.5 8.5L2 6" d="M10 3L4.5 8.5L2 6"
stroke="currentColor" stroke="currentColor"
strokeWidth="1.5" strokeWidth="1.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
/> />
</svg> </svg>
)} )}
</div>
<span className="flex justify-between w-full">
<span>{optionName}</span>
<span className='text-void-fg-4 opacity-60'>{optionDetail}</span>
</span>
</div> </div>
<span className="flex justify-between w-full"> );
<span>{optionName}</span> })}
<span className='text-void-fg-4 opacity-60'>{optionDetail}</span> </div>
</span>
</div>
);
})}
</div> </div>
)} )}
</div> </div>

View file

@ -510,7 +510,7 @@ const VoidOnboardingContent = () => {
content={<div className='flex flex-col items-center -translate-y-[20vh]'> content={<div className='flex flex-col items-center -translate-y-[20vh]'>
{/* <div className="text-5xl text-center mb-8">AI Preferences</div> */} {/* <div className="text-5xl text-center mb-8">AI Preferences</div> */}
<div className="text-4xl text-void-fg-2 mb-8 text-center">What are you looking for in an AI model?</div> <div className="text-4xl text-void-fg-2 mb-8 text-center">Model Preferences</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full max-w-[800px] mx-auto mt-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 w-full max-w-[800px] mx-auto mt-8">
@ -520,7 +520,7 @@ const VoidOnboardingContent = () => {
> >
<div className="flex items-center mb-3"> <div className="flex items-center mb-3">
<Brain size={24} className="text-void-fg-2 mr-2" /> <Brain size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Intelligence</div> <div className="text-lg font-medium text-void-fg-1">Intelligent</div>
</div> </div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['smart']}</div> <div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['smart']}</div>
</button> </button>
@ -531,7 +531,7 @@ const VoidOnboardingContent = () => {
> >
<div className="flex items-center mb-3"> <div className="flex items-center mb-3">
<Lock size={24} className="text-void-fg-2 mr-2" /> <Lock size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Privacy</div> <div className="text-lg font-medium text-void-fg-1">Private</div>
</div> </div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['private']}</div> <div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['private']}</div>
</button> </button>
@ -542,7 +542,7 @@ const VoidOnboardingContent = () => {
> >
<div className="flex items-center mb-3"> <div className="flex items-center mb-3">
<DollarSign size={24} className="text-void-fg-2 mr-2" /> <DollarSign size={24} className="text-void-fg-2 mr-2" />
<div className="text-lg font-medium text-void-fg-1">Affordability</div> <div className="text-lg font-medium text-void-fg-1">Affordable</div>
</div> </div>
<div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['cheap']}</div> <div className="text-sm text-void-fg-2 text-left">{basicDescOfWantToUseOption['cheap']}</div>
</button> </button>

View file

@ -44,37 +44,6 @@ const ModelSelectBox = ({ options, featureName, className }: { options: ModelOpt
matchInputWidth={false} matchInputWidth={false}
/> />
} }
// const ModelSelectBox = ({ options, featureName }: { options: ModelOption[], featureName: FeatureName }) => {
// const accessor = useAccessor()
// const voidSettingsService = accessor.get('IVoidSettingsService')
// let weChangedText = false
// return <VoidSelectBox
// className='@@[&_select]:!void-text-xs text-void-fg-3'
// options={options}
// onChangeSelection={useCallback((newVal: ModelSelection) => {
// if (weChangedText) return
// voidSettingsService.setModelSelectionOfFeature(featureName, newVal)
// }, [voidSettingsService, featureName])}
// // we are responsible for setting the initial state here. always sync instance when state changes.
// onCreateInstance={useCallback((instance: SelectBox) => {
// const syncInstance = () => {
// const modelsListRef = voidSettingsService.state._modelOptions // as a ref
// const settingsAtProvider = voidSettingsService.state.modelSelectionOfFeature[featureName]
// const selectionIdx = settingsAtProvider === null ? -1 : modelsListRef.findIndex(v => modelSelectionsEqual(v.value, settingsAtProvider))
// weChangedText = true
// instance.select(selectionIdx === -1 ? 0 : selectionIdx)
// weChangedText = false
// }
// syncInstance()
// const disposable = voidSettingsService.onDidChangeState(syncInstance)
// return [disposable]
// }, [voidSettingsService, featureName])}
// />
// }
const MemoizedModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => { const MemoizedModelDropdown = ({ featureName, className }: { featureName: FeatureName, className: string }) => {