mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: disable the Interrupt & steer menu row while paused on approval
interruptSteer hard-refuses when the run is paused for tool approval, but the hovercard row was never gated, so it rendered enabled and clicking it did nothing at all — no chip, no queue entry, no toast — at exactly the moment a user is trying to say "stop, don't run that command". The standalone button already gates on pausedOnApproval; the row contradicted it. Gated on pausedOnApproval rather than !canSteer like the steer row above, because canSteer is also false before a conversation exists, where interruptSteer deliberately falls back to interruptAndSend and the row must stay live for the whole first turn. Tests pin both directions and were verified counterfactually: removing the gate fails the paused case, and using !canSteer fails the first-turn case.
This commit is contained in:
parent
a0f4bb6f3c
commit
0a99952cc4
2 changed files with 127 additions and 0 deletions
|
|
@ -92,6 +92,11 @@ const DuringRunSendButton = React.memo(
|
|||
label: localize('com_ui_interrupt_steer'),
|
||||
kbd: modShiftEnter,
|
||||
icon: <ZapOff className="h-4 w-4 text-amber-500" aria-hidden="true" />,
|
||||
// Matches the standalone button's gate, and deliberately NOT
|
||||
// `!canSteer` like the steer row above: `canSteer` is also false before
|
||||
// a conversation exists, where `interruptSteer` falls back to interrupt
|
||||
// & send and this row must stay live for the whole first turn.
|
||||
disabled: steering.pausedOnApproval,
|
||||
onClick: () => runAction((text) => steering.interruptSteer(text)),
|
||||
};
|
||||
const interruptRow: ActionRow = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import type { SteeringControls } from '~/hooks/Chat/useSteering';
|
||||
import DuringRunSendButton from '../DuringRunSendButton';
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const TEXT = 'stop, do not run that command';
|
||||
|
||||
const mockInterruptSteer = jest.fn(() => true);
|
||||
const mockSteerFromComposer = jest.fn(() => true);
|
||||
const mockOnConsumed = jest.fn();
|
||||
|
||||
type StubOptions = {
|
||||
pausedOnApproval?: boolean;
|
||||
canSteer?: boolean;
|
||||
};
|
||||
|
||||
const steeringStub = ({ pausedOnApproval = false, canSteer = true }: StubOptions) =>
|
||||
({
|
||||
effectiveAction: canSteer ? 'steer' : 'queue',
|
||||
canSteer,
|
||||
pausedOnApproval,
|
||||
interruptSteer: mockInterruptSteer,
|
||||
steerFromComposer: mockSteerFromComposer,
|
||||
queueFromComposer: jest.fn(() => true),
|
||||
interruptAndSend: jest.fn(() => true),
|
||||
}) as unknown as SteeringControls;
|
||||
|
||||
function Harness({ steering }: { steering: SteeringControls }) {
|
||||
const methods = useForm<{ text: string }>({ defaultValues: { text: TEXT } });
|
||||
return (
|
||||
<DuringRunSendButton
|
||||
control={methods.control}
|
||||
steering={steering}
|
||||
getText={() => TEXT}
|
||||
onConsumed={mockOnConsumed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The rows only mount once the hovercard clears its show delay. */
|
||||
async function openMenu(options: StubOptions) {
|
||||
render(<Harness steering={steeringStub(options)} />);
|
||||
const anchor = screen.getByTestId('during-run-send-button');
|
||||
fireEvent.mouseEnter(anchor);
|
||||
fireEvent.mouseMove(anchor);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('com_ui_interrupt_steer')).toBeInTheDocument();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('DuringRunSendButton — Interrupt & steer availability', () => {
|
||||
/**
|
||||
* `useSteering.interruptSteer` hard-refuses while a run is paused for tool
|
||||
* approval, so a live row would silently do nothing at exactly the moment a
|
||||
* user is trying to stop a tool call.
|
||||
*/
|
||||
test('disables Interrupt & steer while the run is paused on tool approval', async () => {
|
||||
await openMenu({ pausedOnApproval: true, canSteer: false });
|
||||
|
||||
const row = screen.getByText('com_ui_interrupt_steer').closest('button');
|
||||
expect(row).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
fireEvent.click(row as HTMLButtonElement);
|
||||
expect(mockInterruptSteer).not.toHaveBeenCalled();
|
||||
expect(mockOnConsumed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards the gate against being "simplified" to `!canSteer` like the steer
|
||||
* row above it. `canSteer` is also false before a conversation exists, where
|
||||
* `interruptSteer` deliberately falls back to interrupt & send — disabling
|
||||
* the row there would make it dead for the whole first turn.
|
||||
*/
|
||||
test('keeps Interrupt & steer live before a conversation exists', async () => {
|
||||
await openMenu({ pausedOnApproval: false, canSteer: false });
|
||||
|
||||
const row = screen.getByText('com_ui_interrupt_steer').closest('button');
|
||||
expect(row).toHaveAttribute('aria-disabled', 'false');
|
||||
|
||||
fireEvent.click(row as HTMLButtonElement);
|
||||
expect(mockInterruptSteer).toHaveBeenCalledWith(TEXT);
|
||||
expect(mockOnConsumed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the ordinary Steer row stays gated on canSteer', async () => {
|
||||
await openMenu({ pausedOnApproval: false, canSteer: false });
|
||||
|
||||
const row = screen.getByText('com_ui_steer').closest('button');
|
||||
expect(row).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
fireEvent.click(row as HTMLButtonElement);
|
||||
expect(mockSteerFromComposer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('both actions are available during a normal run', async () => {
|
||||
await openMenu({ pausedOnApproval: false, canSteer: true });
|
||||
|
||||
expect(screen.getByText('com_ui_interrupt_steer').closest('button')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByText('com_ui_steer').closest('button')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue