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:
Danny Avila 2026-07-29 22:52:12 -04:00
parent a0f4bb6f3c
commit 0a99952cc4
2 changed files with 127 additions and 0 deletions

View file

@ -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 = {

View file

@ -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',
);
});
});