mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-26 01:14:34 +00:00
* 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.
45 lines
1.8 KiB
TypeScript
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' };
|
|
}
|