fix(core): prevent UI shift after vim edit (#5315)

This commit is contained in:
Gal Zahavi 2025-08-05 14:55:54 -07:00 committed by GitHub
parent 8d993156e7
commit aacae1de43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 81 additions and 15 deletions

View File

@ -505,6 +505,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
performMemoryRefresh, performMemoryRefresh,
modelSwitchedFromQuotaError, modelSwitchedFromQuotaError,
setModelSwitchedFromQuotaError, setModelSwitchedFromQuotaError,
refreshStatic,
); );
// Input handling // Input handling

View File

@ -93,6 +93,7 @@ export const useGeminiStream = (
performMemoryRefresh: () => Promise<void>, performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean, modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>, setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onEditorClose: () => void,
) => { ) => {
const [initError, setInitError] = useState<string | null>(null); const [initError, setInitError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -133,6 +134,7 @@ export const useGeminiStream = (
config, config,
setPendingHistoryItem, setPendingHistoryItem,
getPreferredEditor, getPreferredEditor,
onEditorClose,
); );
const pendingToolCallGroupDisplay = useMemo( const pendingToolCallGroupDisplay = useMemo(

View File

@ -70,6 +70,7 @@ export function useReactToolScheduler(
React.SetStateAction<HistoryItemWithoutId | null> React.SetStateAction<HistoryItemWithoutId | null>
>, >,
getPreferredEditor: () => EditorType | undefined, getPreferredEditor: () => EditorType | undefined,
onEditorClose: () => void,
): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] { ): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] {
const [toolCallsForDisplay, setToolCallsForDisplay] = useState< const [toolCallsForDisplay, setToolCallsForDisplay] = useState<
TrackedToolCall[] TrackedToolCall[]
@ -140,6 +141,7 @@ export function useReactToolScheduler(
onToolCallsUpdate: toolCallsUpdateHandler, onToolCallsUpdate: toolCallsUpdateHandler,
getPreferredEditor, getPreferredEditor,
config, config,
onEditorClose,
}), }),
[ [
config, config,
@ -147,6 +149,7 @@ export function useReactToolScheduler(
allToolCallsCompleteHandler, allToolCallsCompleteHandler,
toolCallsUpdateHandler, toolCallsUpdateHandler,
getPreferredEditor, getPreferredEditor,
onEditorClose,
], ],
); );

View File

@ -136,6 +136,7 @@ describe('CoreToolScheduler', () => {
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
}); });
const abortController = new AbortController(); const abortController = new AbortController();
@ -205,6 +206,7 @@ describe('CoreToolScheduler with payload', () => {
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
}); });
const abortController = new AbortController(); const abortController = new AbortController();
@ -482,6 +484,7 @@ describe('CoreToolScheduler edit cancellation', () => {
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
}); });
const abortController = new AbortController(); const abortController = new AbortController();
@ -571,6 +574,7 @@ describe('CoreToolScheduler YOLO mode', () => {
onAllToolCallsComplete, onAllToolCallsComplete,
onToolCallsUpdate, onToolCallsUpdate,
getPreferredEditor: () => 'vscode', getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
}); });
const abortController = new AbortController(); const abortController = new AbortController();

View File

@ -224,6 +224,7 @@ interface CoreToolSchedulerOptions {
onToolCallsUpdate?: ToolCallsUpdateHandler; onToolCallsUpdate?: ToolCallsUpdateHandler;
getPreferredEditor: () => EditorType | undefined; getPreferredEditor: () => EditorType | undefined;
config: Config; config: Config;
onEditorClose: () => void;
} }
export class CoreToolScheduler { export class CoreToolScheduler {
@ -234,6 +235,7 @@ export class CoreToolScheduler {
private onToolCallsUpdate?: ToolCallsUpdateHandler; private onToolCallsUpdate?: ToolCallsUpdateHandler;
private getPreferredEditor: () => EditorType | undefined; private getPreferredEditor: () => EditorType | undefined;
private config: Config; private config: Config;
private onEditorClose: () => void;
constructor(options: CoreToolSchedulerOptions) { constructor(options: CoreToolSchedulerOptions) {
this.config = options.config; this.config = options.config;
@ -242,6 +244,7 @@ export class CoreToolScheduler {
this.onAllToolCallsComplete = options.onAllToolCallsComplete; this.onAllToolCallsComplete = options.onAllToolCallsComplete;
this.onToolCallsUpdate = options.onToolCallsUpdate; this.onToolCallsUpdate = options.onToolCallsUpdate;
this.getPreferredEditor = options.getPreferredEditor; this.getPreferredEditor = options.getPreferredEditor;
this.onEditorClose = options.onEditorClose;
} }
private setStatusInternal( private setStatusInternal(
@ -563,6 +566,7 @@ export class CoreToolScheduler {
modifyContext as ModifyContext<typeof waitingToolCall.request.args>, modifyContext as ModifyContext<typeof waitingToolCall.request.args>,
editorType, editorType,
signal, signal,
this.onEditorClose,
); );
this.setArgsInternal(callId, updatedParams); this.setArgsInternal(callId, updatedParams);
this.setStatusInternal(callId, 'awaiting_approval', { this.setStatusInternal(callId, 'awaiting_approval', {

View File

@ -94,6 +94,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith( expect(mockModifyContext.getCurrentContent).toHaveBeenCalledWith(
@ -148,6 +149,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
const stats = await fsp.stat(diffDir); const stats = await fsp.stat(diffDir);
@ -165,6 +167,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mkdirSpy).not.toHaveBeenCalled(); expect(mkdirSpy).not.toHaveBeenCalled();
@ -183,6 +186,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mockCreatePatch).toHaveBeenCalledWith( expect(mockCreatePatch).toHaveBeenCalledWith(
@ -211,6 +215,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mockCreatePatch).toHaveBeenCalledWith( expect(mockCreatePatch).toHaveBeenCalledWith(
@ -241,6 +246,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
), ),
).rejects.toThrow('Editor failed to open'); ).rejects.toThrow('Editor failed to open');
@ -267,6 +273,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(consoleErrorSpy).toHaveBeenCalledTimes(2); expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
@ -290,6 +297,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mockOpenDiff).toHaveBeenCalledOnce(); expect(mockOpenDiff).toHaveBeenCalledOnce();
@ -311,6 +319,7 @@ describe('modifyWithEditor', () => {
mockModifyContext, mockModifyContext,
'vscode' as EditorType, 'vscode' as EditorType,
abortSignal, abortSignal,
vi.fn(),
); );
expect(mockOpenDiff).toHaveBeenCalledOnce(); expect(mockOpenDiff).toHaveBeenCalledOnce();

View File

@ -138,6 +138,7 @@ export async function modifyWithEditor<ToolParams>(
modifyContext: ModifyContext<ToolParams>, modifyContext: ModifyContext<ToolParams>,
editorType: EditorType, editorType: EditorType,
_abortSignal: AbortSignal, _abortSignal: AbortSignal,
onEditorClose: () => void,
): Promise<ModifyResult<ToolParams>> { ): Promise<ModifyResult<ToolParams>> {
const currentContent = await modifyContext.getCurrentContent(originalParams); const currentContent = await modifyContext.getCurrentContent(originalParams);
const proposedContent = const proposedContent =
@ -150,7 +151,7 @@ export async function modifyWithEditor<ToolParams>(
); );
try { try {
await openDiff(oldPath, newPath, editorType); await openDiff(oldPath, newPath, editorType, onEditorClose);
const result = getUpdatedParams( const result = getUpdatedParams(
oldPath, oldPath,
newPath, newPath,

View File

@ -331,7 +331,7 @@ describe('editor utils', () => {
}), }),
}; };
(spawn as Mock).mockReturnValue(mockSpawn); (spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', editor); await openDiff('old.txt', 'new.txt', editor, () => {});
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
expect(spawn).toHaveBeenCalledWith( expect(spawn).toHaveBeenCalledWith(
diffCommand.command, diffCommand.command,
@ -361,9 +361,9 @@ describe('editor utils', () => {
}), }),
}; };
(spawn as Mock).mockReturnValue(mockSpawn); (spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( await expect(
'spawn error', openDiff('old.txt', 'new.txt', editor, () => {}),
); ).rejects.toThrow('spawn error');
}); });
it(`should reject if ${editor} exits with non-zero code`, async () => { it(`should reject if ${editor} exits with non-zero code`, async () => {
@ -375,9 +375,9 @@ describe('editor utils', () => {
}), }),
}; };
(spawn as Mock).mockReturnValue(mockSpawn); (spawn as Mock).mockReturnValue(mockSpawn);
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow( await expect(
`${editor} exited with code 1`, openDiff('old.txt', 'new.txt', editor, () => {}),
); ).rejects.toThrow(`${editor} exited with code 1`);
}); });
} }
@ -385,7 +385,7 @@ describe('editor utils', () => {
for (const editor of execSyncEditors) { for (const editor of execSyncEditors) {
it(`should call execSync for ${editor} on non-windows`, async () => { it(`should call execSync for ${editor} on non-windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'linux' }); Object.defineProperty(process, 'platform', { value: 'linux' });
await openDiff('old.txt', 'new.txt', editor); await openDiff('old.txt', 'new.txt', editor, () => {});
expect(execSync).toHaveBeenCalledTimes(1); expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${ const expectedCommand = `${
@ -399,7 +399,7 @@ describe('editor utils', () => {
it(`should call execSync for ${editor} on windows`, async () => { it(`should call execSync for ${editor} on windows`, async () => {
Object.defineProperty(process, 'platform', { value: 'win32' }); Object.defineProperty(process, 'platform', { value: 'win32' });
await openDiff('old.txt', 'new.txt', editor); await openDiff('old.txt', 'new.txt', editor, () => {});
expect(execSync).toHaveBeenCalledTimes(1); expect(execSync).toHaveBeenCalledTimes(1);
const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!; const diffCommand = getDiffCommand('old.txt', 'new.txt', editor)!;
const expectedCommand = `${diffCommand.command} ${diffCommand.args.join( const expectedCommand = `${diffCommand.command} ${diffCommand.args.join(
@ -417,11 +417,46 @@ describe('editor utils', () => {
.spyOn(console, 'error') .spyOn(console, 'error')
.mockImplementation(() => {}); .mockImplementation(() => {});
// @ts-expect-error Testing unsupported editor // @ts-expect-error Testing unsupported editor
await openDiff('old.txt', 'new.txt', 'foobar'); await openDiff('old.txt', 'new.txt', 'foobar', () => {});
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
'No diff tool available. Install a supported editor.', 'No diff tool available. Install a supported editor.',
); );
}); });
describe('onEditorClose callback', () => {
it('should call onEditorClose for execSync editors', async () => {
(execSync as Mock).mockReturnValue(Buffer.from(`/usr/bin/`));
const onEditorClose = vi.fn();
await openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
expect(execSync).toHaveBeenCalledTimes(1);
expect(onEditorClose).toHaveBeenCalledTimes(1);
});
it('should call onEditorClose for execSync editors when an error is thrown', async () => {
(execSync as Mock).mockImplementation(() => {
throw new Error('test error');
});
const onEditorClose = vi.fn();
openDiff('old.txt', 'new.txt', 'vim', onEditorClose);
expect(execSync).toHaveBeenCalledTimes(1);
expect(onEditorClose).toHaveBeenCalledTimes(1);
});
it('should not call onEditorClose for spawn editors', async () => {
const onEditorClose = vi.fn();
const mockSpawn = {
on: vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
}),
};
(spawn as Mock).mockReturnValue(mockSpawn);
await openDiff('old.txt', 'new.txt', 'vscode', onEditorClose);
expect(spawn).toHaveBeenCalledTimes(1);
expect(onEditorClose).not.toHaveBeenCalled();
});
});
}); });
describe('allowEditorTypeInSandbox', () => { describe('allowEditorTypeInSandbox', () => {

View File

@ -164,6 +164,7 @@ export async function openDiff(
oldPath: string, oldPath: string,
newPath: string, newPath: string,
editor: EditorType, editor: EditorType,
onEditorClose: () => void,
): Promise<void> { ): Promise<void> {
const diffCommand = getDiffCommand(oldPath, newPath, editor); const diffCommand = getDiffCommand(oldPath, newPath, editor);
if (!diffCommand) { if (!diffCommand) {
@ -206,10 +207,16 @@ export async function openDiff(
process.platform === 'win32' process.platform === 'win32'
? `${diffCommand.command} ${diffCommand.args.join(' ')}` ? `${diffCommand.command} ${diffCommand.args.join(' ')}`
: `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`; : `${diffCommand.command} ${diffCommand.args.map((arg) => `"${arg}"`).join(' ')}`;
try {
execSync(command, { execSync(command, {
stdio: 'inherit', stdio: 'inherit',
encoding: 'utf8', encoding: 'utf8',
}); });
} catch (e) {
console.error('Error in onEditorClose callback:', e);
} finally {
onEditorClose();
}
break; break;
} }