command bar state + accept/reject all

This commit is contained in:
Andrew Pareles 2025-03-21 19:23:04 -07:00
parent 159d52a716
commit f3451d5077
6 changed files with 184 additions and 82 deletions

View file

@ -1226,8 +1226,6 @@ class EditCodeService extends Disposable implements IEditCodeService {
// treat like full file, unless linkedCtrlKZone was provided in which case use its diff's range
const startLine = linkedCtrlKZone ? linkedCtrlKZone.startLine : 1
const endLine = linkedCtrlKZone ? linkedCtrlKZone.endLine : model.getLineCount()
const range = { startLineNumber: startLine, startColumn: 1, endLineNumber: endLine, endColumn: Number.MAX_SAFE_INTEGER }
@ -1291,7 +1289,16 @@ class EditCodeService extends Disposable implements IEditCodeService {
private _uriIsStreaming(uri: URI) {
const diffAreas = this.diffAreasOfURI[uri.fsPath]
if (!diffAreas) return false
for (const diffareaid of diffAreas) {
const diffArea = this.diffAreaOfId[diffareaid]
if (diffArea?.type !== 'DiffZone') continue
if (diffArea._streamState.isStreaming) return true
}
return false
}
private async _initializeWriteoverStream(opts: StartApplyingOpts): Promise<[DiffZone, Promise<void>] | undefined> {
@ -1358,7 +1365,8 @@ class EditCodeService extends Disposable implements IEditCodeService {
}
else { throw new Error(`featureName ${from} is invalid`) }
// if URI is already streaming, return (should never happen, caller is responsible for checking)
if (this._uriIsStreaming(uri)) return
// start diffzone
const res = this._startStreamingDiffZone({
@ -1526,6 +1534,9 @@ class EditCodeService extends Disposable implements IEditCodeService {
{ role: 'user', content: userMessageContent },
]
// if URI is already streaming, return (should never happen, caller is responsible for checking)
if (this._uriIsStreaming(uri)) return
// start diffzone
const res = this._startStreamingDiffZone({
uri,

View file

@ -10,7 +10,7 @@
import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI } from '../util/services.js';
import { useAccessor, useSidebarState, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState } from '../util/services.js';
import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markdown/ChatMarkdownRender.js';
import { URI } from '../../../../../../../base/common/uri.js';
@ -762,6 +762,50 @@ const ToolHeaderWrapper = ({
};
const SimplifiedToolHeader = ({
title,
children,
}: {
title: string;
children?: React.ReactNode;
}) => {
const [isOpen, setIsOpen] = useState(false);
const isDropdown = children !== undefined;
return (
<div>
<div className="w-full">
{/* header */}
<div
className={`select-none flex items-center min-h-[24px] ${isDropdown ? 'cursor-pointer' : ''}`}
onClick={() => {
if (isDropdown) { setIsOpen(v => !v); }
}}
>
{isDropdown && (
<ChevronRight
className={`text-void-fg-3 mr-0.5 h-4 w-4 flex-shrink-0 transition-transform duration-100 ease-[cubic-bezier(0.4,0,0.2,1)] ${isOpen ? 'rotate-90' : ''}`}
/>
)}
<div className="flex items-center w-full overflow-hidden">
<span className="text-void-fg-3">{title}</span>
</div>
</div>
{/* children */}
{<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${isOpen ? 'opacity-100' : 'max-h-0 opacity-0'} text-void-fg-4`}
>
{children}
</div>}
</div>
</div>
);
};
const UserMessageComponent = ({ chatMessage, messageIdx, isCommitted }: { chatMessage: ChatMessage & { role: 'user' }, messageIdx: number, isCommitted: boolean, }) => {
const accessor = useAccessor()
@ -1812,6 +1856,34 @@ const ChatBubble = ({ chatMessage, isCommitted, messageIdx, isLast, chatIsRunnin
}
const CommandBarInChat = () => {
const { state: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState()
const [isExpanded, setIsExpanded] = useState(false)
const accessor = useAccessor()
const commandService = accessor.get('ICommandService')
if (!sortedCommandBarURIs || sortedCommandBarURIs.length === 0) {
return null
}
return (
<SimplifiedToolHeader title={'Changes'}>
{sortedCommandBarURIs.map((uri, i) => (
<ListableToolItem
key={i}
name={getBasename(uri.fsPath)}
onClick={() => { commandService.executeCommand('vscode.open', uri, { preview: true }) }}
/>
))}
</SimplifiedToolHeader>
)
}
export const SidebarChat = () => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null)
const textAreaFnsRef = useRef<TextAreaFns | null>(null)

View file

@ -45,13 +45,22 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const { state: commandBarState, sortedURIs: sortedCommandBarURIs } = useCommandBarState()
// latestUriIdx is used to remember place in leftRight
const _latestValidUriIdxRef = useRef<number | null>(null)
// changes if the user clicks left/right or if the user goes on a uri with changes
const [currUriIdx, setUriIdx] = useState<number | null>(null)
// i is the current index of the URI in sortedCommandBarURIs
const i_ = sortedCommandBarURIs.findIndex(e => e.fsPath === uri?.fsPath)
const currFileIdx = i_ === -1 ? null : i_
useEffect(() => {
const i = sortedCommandBarURIs.findIndex(e => e.fsPath === uri?.fsPath)
if (i !== -1) { setUriIdx(i) }
}, [sortedCommandBarURIs, uri])
if (currFileIdx !== null) _latestValidUriIdxRef.current = currFileIdx
}, [currFileIdx])
const uriIdxInStepper = currFileIdx !== null ? currFileIdx // use currFileIdx if it exists, else use latestNotNullUriIdxRef
: _latestValidUriIdxRef.current === null ? null
: _latestValidUriIdxRef.current < sortedCommandBarURIs.length ? _latestValidUriIdxRef.current
: null
const getNextDiffIdx = (step: 1 | -1) => {
// check undefined
@ -74,14 +83,12 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const diffid = sortedDiffIds[idx]
const diff = editCodeService.diffOfId[diffid]
const range = { startLineNumber: diff.startLine, endLineNumber: diff.startLine, startColumn: 1, endColumn: 1 };
editor.revealRange(range, ScrollType.Immediate)
editor.revealRangeInCenter(range, ScrollType.Immediate)
commandBarService.setDiffIdx(uri, idx)
}
}
const getNextUriIdx = (step: 1 | -1) => {
return stepIdx(currUriIdx, sortedCommandBarURIs.length, step)
return stepIdx(uriIdxInStepper, sortedCommandBarURIs.length, step)
}
const goToURIIdx = async (idx: number | null) => {
if (idx === null) return
@ -101,13 +108,12 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
setTimeout(() => {
// check undefined
if (!uri) return
const s = commandBarState[uri.fsPath]
const s = commandBarService.stateOfURI[uri.fsPath]
if (!s) return
const { diffIdx } = s
goToDiffIdx(diffIdx)
}, 50)
}, [uri])
}, [uri, commandBarService])
const currDiffIdx = uri ? commandBarState[uri.fsPath]?.diffIdx ?? null : null
@ -115,11 +121,6 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const sortedDiffZoneIds = uri ? commandBarState[uri.fsPath]?.sortedDiffZoneIds ?? [] : []
const nextDiffIdx = getNextDiffIdx(1)
const prevDiffIdx = getNextDiffIdx(-1)
const nextURIIdx = getNextUriIdx(1)
const prevURIIdx = getNextUriIdx(-1)
const isAChangeInThisFile = sortedDiffIds.length !== 0
const isADiffZoneInThisFile = sortedDiffZoneIds.length !== 0
const isADiffZoneInAnyFile = sortedCommandBarURIs.length !== 0
@ -127,14 +128,13 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
const streamState = uri ? commandBarService.getStreamState(uri) : null
const showAcceptRejectAll = streamState === 'idle-has-changes'
if (!isADiffZoneInAnyFile) { // no changes for the user to accept
return null
}
const nextDiffIdx = getNextDiffIdx(1)
const prevDiffIdx = getNextDiffIdx(-1)
const nextURIIdx = getNextUriIdx(1)
const prevURIIdx = getNextUriIdx(-1)
const upDownDisabled = prevDiffIdx === null || nextDiffIdx === null
const leftRightDisabled = prevURIIdx === null || currUriIdx === null
const leftRightDisabled = prevURIIdx === null || nextURIIdx === null
const upButton = <button
className={`
@ -205,18 +205,6 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
></button>
const filesDescription = (isADiffZoneInThisFile ?
currUriIdx !== null && sortedCommandBarURIs.length !== 0 &&
`File ${currUriIdx + 1} of ${sortedCommandBarURIs.length}`
: `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed`
);
const changesDescription = (isADiffZoneInThisFile ?
isAChangeInThisFile ?
`Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}`
: `No changes`
: ''
);
// accept/reject if current URI has changes
const onAcceptAll = () => {
@ -231,6 +219,8 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
}
if (!isADiffZoneInAnyFile) return null
const acceptAllButton = <button
className='pointer-events-auto text-nowrap'
onClick={onAcceptAll}
@ -264,6 +254,10 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
Reject File
</button>
const acceptRejectAllButtons = <div className="flex items-center gap-1 text-sm">
{acceptAllButton}
{rejectAllButton}
</div>
// const closeCommandBar = useCallback(() => {
// commandService.executeCommand('void.hideCommandBar');
@ -283,41 +277,43 @@ const VoidCommandBar = ({ uri, editor }: VoidCommandBarProps) => {
// >x
// </button>
const gridLayout = <div className="flex flex-col gap-1">
{/* First row */}
{filesDescription &&
<div className={`flex items-center ${leftRightDisabled ? 'opacity-50' : ''}`}>
{leftButton}
<div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> {/* Divider */}
{rightButton}
<div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> {/* Divider */}
<div className="text-xs mx-2">{filesDescription}</div>
</div>
}
const leftRightUpDownButtons = <div className='p-1 gap-1 flex flex-col items-start bg-void-bg-2 rounded shadow-md border border-void-border-2'>
<div className="flex flex-col gap-1">
{/* Changes in file */}
{isADiffZoneInThisFile &&
<div className={`flex items-center ${upDownDisabled ? 'opacity-50' : ''}`}>
{downButton}
{upButton}
<div className="text-xs mx-1 w-fit">
{isAChangeInThisFile ?
`Diff ${(currDiffIdx ?? 0) + 1} of ${sortedDiffIds.length}`
: `No changes`
}
</div>
</div>
}
{/* Second row */}
{changesDescription &&
<div className={`flex items-center ${upDownDisabled ? 'opacity-50' : ''}`}>
{upButton}
<div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> {/* Divider */}
{downButton}
<div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> {/* Divider */}
<div className="text-xs mx-2">{changesDescription}</div>
</div>
}
{/* Files */}
{isADiffZoneInAnyFile &&
<div className={`flex items-center ${leftRightDisabled ? 'opacity-50' : ''}`}>
{leftButton}
{/* <div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> */}
{rightButton}
{/* <div className="w-px h-3 bg-void-border-3 mx-0.5 shadow-sm"></div> */}
<div className="text-xs mx-1 w-fit">
{currFileIdx !== null ?
`File ${currFileIdx + 1} of ${sortedCommandBarURIs.length}`
: `${sortedCommandBarURIs.length} file${sortedCommandBarURIs.length === 1 ? '' : 's'} changed`
}
</div>
</div>
}
</div>
</div>
return <div className='pointer-events-auto flex flex-col gap-2 mx-2'>
{showAcceptRejectAll &&
<div className="flex gap-1 text-sm">
{acceptAllButton}
{rejectAllButton}
</div>
}
<div className='px-2 pt-1 pb-1 gap-1 flex flex-col items-start bg-void-bg-1 rounded shadow-md border border-void-border-1'>
{gridLayout}
{/* {oldLayout} */}
</div>
return <div className={`flex flex-col gap-y-2 mx-2 pointer-events-auto`}>
{showAcceptRejectAll && acceptRejectAllButtons}
{leftRightUpDownButtons}
</div>
}

View file

@ -12,6 +12,7 @@ import { ToolCallParams, ToolDirectoryItem, ToolName, ToolResultType } from '../
import { IVoidModelService } from '../common/voidModelService.js'
import { EndOfLinePreference } from '../../../../editor/common/model.js'
import { basename } from '../../../../base/common/path.js'
import { IVoidCommandBarService } from './voidCommandBarService.js'
// tool use for AI
@ -193,6 +194,7 @@ export class ToolsService implements IToolsService {
@IVoidModelService voidModelService: IVoidModelService,
@IEditCodeService editCodeService: IEditCodeService,
@ITerminalToolService private readonly terminalToolService: ITerminalToolService,
@IVoidCommandBarService private readonly commandBarService: IVoidCommandBarService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
@ -348,6 +350,9 @@ export class ToolsService implements IToolsService {
edit: async ({ uri, changeDescription }) => {
await voidModelService.initializeModel(uri)
if (this.commandBarService.getStreamState(uri) === 'streaming') {
throw new Error(`The Apply model was already running. This can happen if two agents try editing the same file at the same time. Please try again in a moment.`)
}
const res = await editCodeService.startApplying({
uri,
applyStr: changeDescription,

View file

@ -31,7 +31,11 @@ export interface IVoidCommandBarService {
onDidChangeActiveURI: Event<{ uri: URI | null }>;
getStreamState: (uri: URI) => 'streaming' | 'idle-has-changes' | 'idle-no-changes';
setDiffIdx(uri: URI, newIdx: number | null): void
setDiffIdx(uri: URI, newIdx: number | null): void;
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }): void;
anyFileIsStreaming(): boolean;
}
@ -64,7 +68,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
// depends on uri -> diffZone -> {streaming, diffs}
public stateOfURI: { [uri: string]: CommandBarStateType } = {}
public sortedURIs: URI[] = [] // keys of state (depends on diffZones in the uri)
private readonly _hooks = new Set<URI>() // uriFsPaths
private readonly _listenToTheseURIs = new Set<URI>() // uriFsPaths
// Emits when a URI's stream state changes between idle, streaming, and acceptRejectAll
private readonly _onDidChangeState = new Emitter<{ uri: URI }>();
@ -76,7 +80,6 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
private readonly _onDidChangeActiveURI = new Emitter<{ uri: URI | null }>();
readonly onDidChangeActiveURI = this._onDidChangeActiveURI.event;
constructor(
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
@ -91,7 +94,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
// do not add listeners to the same model twice - important, or will see duplicates
if (registeredModelURIs.has(model.uri.fsPath)) return
registeredModelURIs.add(model.uri.fsPath)
this._hooks.add(model.uri)
this._listenToTheseURIs.add(model.uri)
}
// initialize all existing models + initialize when a new model mounts
this._modelService.getModels().forEach(model => { initializeModel(model) })
@ -117,7 +120,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
// mount the command bar
const d1 = this._instantiationService.createInstance(AcceptRejectAllFloatingWidget, { editor });
disposablesOfEditorId[id].push(d1);
const d2 = editor.onDidChangeModel((e) => { if (e?.newModelUrl?.scheme === 'file') updateActiveURI() })
const d2 = editor.onWillChangeModel((e) => { if (e?.newModelUrl?.scheme === 'file') updateActiveURI() })
disposablesOfEditorId[id].push(d2);
}
const onCodeEditorRemove = (editor: ICodeEditor) => {
@ -133,7 +136,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
// state updaters
this._register(this._editCodeService.onDidAddOrDeleteDiffZones(e => {
for (const uri of this._hooks) {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: delete if empty, add if not in state yet
const diffZones = this._getDiffZonesOnURI(uri)
@ -173,7 +176,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
}))
this._register(this._editCodeService.onDidChangeDiffsInDiffZone(e => {
for (const uri of this._hooks) {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: no change
// --- state:
@ -191,7 +194,7 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
}
}))
this._register(this._editCodeService.onDidChangeStreamingInDiffZone(e => {
for (const uri of this._hooks) {
for (const uri of this._listenToTheseURIs) {
if (e.uri.fsPath !== uri.fsPath) continue
// --- sortedURIs: no change
// --- state:
@ -342,6 +345,21 @@ export class VoidCommandBarService extends Disposable implements IVoidCommandBar
}
anyFileIsStreaming() {
return this.sortedURIs.some(uri => this.getStreamState(uri) === 'streaming')
}
acceptOrRejectAllFiles(opts: { behavior: 'reject' | 'accept' }) {
const { behavior } = opts
// if anything is streaming, do nothing
const anyIsStreaming = this.anyFileIsStreaming()
if (anyIsStreaming) return
for (const uri of this.sortedURIs) {
this._editCodeService.acceptOrRejectAllDiffAreas({ uri, behavior, removeCtrlKs: false })
}
}
}
registerSingleton(IVoidCommandBarService, VoidCommandBarService, InstantiationType.Delayed); // delayed is needed here :(

View file

@ -19,9 +19,9 @@ export const tripleTick = ['```', '```']
const changesExampleContent = `\
// ... existing code ...
// {{change 1}}
// // ... existing code ...
// ... existing code ...
// {{change 2}}
// // ... existing code ...
// ... existing code ...
// {{change 3}}
// ... existing code ...`