qwen-code/packages/web-shell/client/utils/escapeIntent.ts
carffuca fce79dd10b
feat(web-shell): friendlier Esc interruption + queued-prompt UX (#6025)
* fix(web-shell): prevent queued-prompt loss from drain race

The auto-drain effect popped a queued prompt, called setQueuedPrompts,
then submitted via setTimeout(0). Because the daemon flips streamingState
asynchronously, the setState re-render could re-run the effect and pop a
second prompt before the first registered as streaming — both submitted
back-to-back and the first was lost.

Arm an "awaiting turn start" gate synchronously at pop so the re-run is
blocked until streamingState goes non-idle, released by a dedicated
effect with a safety-net timer for a prompt that never streams (e.g. a
queued slash command). Cleanup no longer cancels/re-queues the pending
submit while the gate is armed.

* feat(web-shell): friendlier Esc interruption + queued-prompt UX

* refactor(web-shell): tidy Esc/queue code per review

Behavior-preserving cleanups addressing review feedback on the Esc-interruption
and queued-prompt changes:

- Remove the now-dead queue.footer i18n key (EN + ZH) and the unreferenced
  .queuedHint CSS, orphaned when the Esc-clears-queue behavior was dropped.
- Co-locate the queued-prompt styles in QueuedPromptDisplay.module.css instead
  of reaching into the parent App.module.css.
- Make the Esc confirm-window constants the single source of truth: export them
  from escapeIntent.ts and drive the countdown-ring duration from one of them
  via a CSS custom property.
- Nudge the queue-drain safety net with a dedicated tick counter instead of
  cloning queuedPrompts, so it no longer re-renders the composer for a no-op.
- Drop a redundant !compact guard in StatusBar left over from flattening a
  ternary.
- Document the pop/gate-arm ordering invariant in the drain effect.
2026-06-30 01:27:16 +00:00

45 lines
1.8 KiB
TypeScript

// Pure decision logic for the composer's two-press Escape behaviour, extracted
// from App's keydown listener so the priority + confirm rules can be tested
// without mounting the whole app. The listener owns the side effects (timers,
// cancel/clear handlers); this module only decides what a press means.
export type EscArmedAction = 'cancel' | 'clear';
export interface EscapeContext {
/** A pending approval or blocking dialog swallows Escape entirely. */
blocked: boolean;
/** A turn is in flight (streamingState !== 'idle'). */
streaming: boolean;
/** The composer currently has text that could be cleared. */
hasInput: boolean;
/** Which action the previous Escape armed, or null when idle. */
armed: EscArmedAction | null;
}
export type EscapeIntent =
| { kind: 'cancel' } // confirmed second press: stop the stream
| { kind: 'clear' } // confirmed second press: clear the composer
| { kind: 'arm'; action: EscArmedAction } // first press: show the affordance
| { kind: 'ignore' }; // nothing to act on
/**
* Decide what an Escape press means. Streaming takes priority over clearing
* text (stopping a live turn is what the user most wants), and each action is a
* two-press confirm: the first press arms, a matching second press confirms. A
* press armed for the wrong action (e.g. clear-armed while now streaming)
* re-arms the action that currently applies rather than confirming.
*/
export function decideEscapeIntent(ctx: EscapeContext): EscapeIntent {
if (ctx.blocked) return { kind: 'ignore' };
if (ctx.streaming) {
return ctx.armed === 'cancel'
? { kind: 'cancel' }
: { kind: 'arm', action: 'cancel' };
}
if (ctx.hasInput) {
return ctx.armed === 'clear'
? { kind: 'clear' }
: { kind: 'arm', action: 'clear' };
}
return { kind: 'ignore' };
}