mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
fix: stop advertising during-run chords the run cannot answer yet
isSubmitting flips the moment the user sends, but the start POST installs the generation epoch a beat later. Through that window steering, send-now and interrupt all refuse, while the composer advertised them anyway: the hint read "Enter queues · Ctrl+Enter send now · Alt+Enter interrupt & send" when only the queue half worked, and the send slot showed the ordinary disabled send button because it keyed off showStopButton, which the epoch also gates. Give the hint canControlGeneration and let it name only the default action until the run is reachable; queueing is local, so that part is honest throughout. Key the send slot on whether the during-run slot rendered anything instead of on showStopButton, so the control that queues is present exactly when Enter queues. The slot already decides between the during-run button, Stop, and nothing, so an empty one still falls through to send. The queue rail's escalation control had the same shape of problem: it was gated on canSteer, so it vanished for the window after the drain starts the next run and reappeared once that epoch landed. Show it for the whole run and disable it whenever steering cannot reach one, which also covers the approval pause it already handled. Found by watching a real run: the window is normally ~250ms, but any failure that keeps the epoch from arriving strands the composer in it.
This commit is contained in:
parent
22018b8a01
commit
718eec3fbb
5 changed files with 82 additions and 15 deletions
|
|
@ -365,9 +365,14 @@ const ChatForm = memo(function ChatForm({
|
|||
|
||||
/* Memoized for `memo(Bar)`: an inline element is a new identity every render,
|
||||
and this component re-renders on every keystroke. */
|
||||
/* Gated on the slot having something to show rather than on `showStopButton`:
|
||||
that flag only flips once the start POST installs the generation epoch, and
|
||||
until then Enter already queues while this slot still offered the ordinary
|
||||
send button, disabled. The slot decides for itself between the during-run
|
||||
control, Stop, and nothing, so an empty one falls through to send. */
|
||||
const actionSlot = useMemo(
|
||||
() =>
|
||||
isSubmitting && showStopButton && !answerMode.active
|
||||
isSubmitting && !answerMode.active && duringRunSlot != null
|
||||
? duringRunSlot
|
||||
: endpoint && (
|
||||
<SendButton
|
||||
|
|
@ -388,7 +393,6 @@ const ChatForm = memo(function ChatForm({
|
|||
disableInputs,
|
||||
isNotAppendable,
|
||||
isSubmitting,
|
||||
showStopButton,
|
||||
answerMode.active,
|
||||
methods.control,
|
||||
],
|
||||
|
|
@ -624,6 +628,7 @@ const ChatForm = memo(function ChatForm({
|
|||
hasText={(textValue?.trim() ?? '') !== ''}
|
||||
isSubmitting={isSubmitting}
|
||||
duringRunActive={steering.duringRunActive}
|
||||
canControlGeneration={steering.canControlGeneration}
|
||||
duringRunAction={steering.effectiveAction}
|
||||
answerModeActive={answerMode.active}
|
||||
uploadingCount={uploadingCount}
|
||||
|
|
|
|||
|
|
@ -150,11 +150,13 @@ function QueueRow({
|
|||
/* A recovered item is consumed atomically only when it starts a normal
|
||||
generation. Escalating it would leave or duplicate the parked source. */
|
||||
const isRecovered = message.recoverySteerId != null;
|
||||
/** `canSteer` is false while paused on approval, but the escalation control
|
||||
* stays visible-and-disabled there: hiding it during the pause is exactly
|
||||
* the discoverability gap this button closes. */
|
||||
const showEscalate =
|
||||
!isRecovered && (steering.pausedOnApproval || (steering.duringRunActive && steering.canSteer));
|
||||
/** Shown for the whole run, disabled whenever steering cannot reach it —
|
||||
* an approval pause, or the window before the start POST installs the
|
||||
* generation epoch. Both are states the control must sit out, and hiding it
|
||||
* through them is the discoverability gap this button closes: the pause is
|
||||
* exactly when cutting the reply short is wanted, and the epoch window is
|
||||
* short enough that appearing and vanishing again just reads as a flicker. */
|
||||
const showEscalate = !isRecovered && (steering.pausedOnApproval || steering.duringRunActive);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -232,7 +234,7 @@ function QueueRow({
|
|||
<EscalateNowButton
|
||||
surface="queued"
|
||||
messageText={message.text}
|
||||
disabled={steering.pausedOnApproval || interruptPending}
|
||||
disabled={!steering.canSteer || interruptPending}
|
||||
onClick={() => steering.sendQueuedNow(message, { preempt: true })}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -363,6 +363,13 @@ describe('Queue', () => {
|
|||
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
|
||||
});
|
||||
|
||||
/* The drain starts the next run before its epoch lands, so a control that
|
||||
hid itself here would appear and vanish between queued sends. */
|
||||
it('stays visible but disabled before the generation epoch lands', () => {
|
||||
renderQueue([queued()], steeringWith({ canSteer: false }));
|
||||
expect(screen.getByTestId('queued-interrupt-now')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers nothing once the run is over', () => {
|
||||
renderQueue([queued()], steeringWith({ duringRunActive: false, canSteer: false }));
|
||||
expect(screen.queryByTestId('queued-interrupt-now')).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ const baseState: ComposerHintState = {
|
|||
hasText: false,
|
||||
isSubmitting: false,
|
||||
duringRunActive: false,
|
||||
/** The common case: the epoch has landed, so the run is reachable. The
|
||||
* pre-epoch window is exercised explicitly below. */
|
||||
canControlGeneration: true,
|
||||
duringRunAction: 'queue' as const,
|
||||
answerModeActive: false,
|
||||
uploadingCount: 0,
|
||||
|
|
@ -80,6 +83,42 @@ describe('composeHint', () => {
|
|||
'⌘ ⇧ X com_ui_composer_hint_stop',
|
||||
);
|
||||
});
|
||||
|
||||
/* `isSubmitting` flips the moment the user sends, but the start POST
|
||||
installs the generation epoch a beat later. Through that window every
|
||||
chord that reaches the live run refuses, so naming them would point at
|
||||
keys that do nothing. Queueing is local and keeps working. */
|
||||
describe('before the generation epoch lands', () => {
|
||||
const preEpoch = {
|
||||
duringRunActive: true,
|
||||
hasText: true,
|
||||
isSubmitting: true,
|
||||
canControlGeneration: false,
|
||||
};
|
||||
|
||||
it('promises only the queue, which is the one action that still works', () => {
|
||||
expect(hint(preEpoch)).toBe('com_ui_composer_hint_queue_default');
|
||||
});
|
||||
|
||||
it('names no chord that would refuse', () => {
|
||||
const result = hint(preEpoch);
|
||||
expect(result).not.toContain('com_ui_composer_hint_send_now');
|
||||
expect(result).not.toContain('com_ui_composer_hint_interrupt');
|
||||
expect(result).not.toContain('⌥⏎');
|
||||
});
|
||||
|
||||
it('still names the chord when it IS the queue action', () => {
|
||||
expect(hint({ ...preEpoch, enterToSend: false })).toBe(
|
||||
'⌘⏎ com_ui_composer_hint_queue_verb',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores the full line once the epoch arrives', () => {
|
||||
const result = hint({ ...preEpoch, canControlGeneration: true });
|
||||
expect(result).toContain('com_ui_composer_hint_send_now');
|
||||
expect(result).toContain('com_ui_composer_hint_interrupt');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with Enter bound to a newline', () => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export interface ComposerHintState {
|
|||
isSubmitting: boolean;
|
||||
/** Enter steers or queues instead of starting a turn. */
|
||||
duringRunActive: boolean;
|
||||
/** Whether the run can be reached yet. `isSubmitting` flips as soon as the
|
||||
* user sends, but the start POST installs the generation epoch a moment
|
||||
* later, and until it lands every chord that touches the live run refuses.
|
||||
* Queueing is local, so it works throughout. */
|
||||
canControlGeneration: boolean;
|
||||
/** Which action Enter takes during a run, per the effective setting. */
|
||||
duringRunAction: 'steer' | 'queue';
|
||||
/** The composer is the answer box for a paused `ask_user_question`. */
|
||||
|
|
@ -72,16 +77,25 @@ export function composeHint(
|
|||
/* With plain Enter bound to a newline, the chord IS the default action and
|
||||
there is no second chord left to reach the alternate one, so the hint
|
||||
names only what the composer will actually do. */
|
||||
const defaultParts = isSteer
|
||||
? [localize('com_ui_composer_hint_steer'), `${mod} ${localize('com_ui_composer_hint_queue')}`]
|
||||
: [
|
||||
localize('com_ui_composer_hint_queue_default'),
|
||||
`${mod} ${localize('com_ui_composer_hint_send_now')}`,
|
||||
];
|
||||
const defaultAction = isSteer
|
||||
? localize('com_ui_composer_hint_steer')
|
||||
: localize('com_ui_composer_hint_queue_default');
|
||||
const alternateAction = isSteer
|
||||
? `${mod} ${localize('com_ui_composer_hint_queue')}`
|
||||
: `${mod} ${localize('com_ui_composer_hint_send_now')}`;
|
||||
const chordVerb = isSteer
|
||||
? 'com_ui_composer_hint_steer_verb'
|
||||
: 'com_ui_composer_hint_queue_verb';
|
||||
const parts = state.enterToSend ? defaultParts : [`${mod} ${localize(chordVerb)}`];
|
||||
const parts = state.enterToSend
|
||||
? [defaultAction, alternateAction]
|
||||
: [`${mod} ${localize(chordVerb)}`];
|
||||
/* Until the start POST installs the generation epoch, every chord that
|
||||
reaches the live run refuses — only the default action survives, because
|
||||
queueing is local. Naming the others through that window advertises keys
|
||||
that do nothing, the same failure as pointing at an unbound shortcut. */
|
||||
if (!state.canControlGeneration) {
|
||||
return { text: parts[0], kind: 'state' };
|
||||
}
|
||||
return {
|
||||
text: [...parts, `${alt} ${localize('com_ui_composer_hint_interrupt')}`].join(SEPARATOR),
|
||||
kind: 'state',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue