qwen-code/packages/web-shell/client/components/messages/SystemMessage.tsx
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

146 lines
3.9 KiB
TypeScript

import { memo } from 'react';
import { useI18n } from '../../i18n';
import {
ContextUsageMessage,
parseContextUsageMessage,
} from './ContextUsageMessage';
import { StatsMessage, parseStatsMessage } from './StatsMessage';
import { StatusMessage, parseStatusMessage } from './StatusMessage';
import { McpStatusMessage, parseMcpStatusMessage } from './McpStatusMessage';
import {
TasksStatusMessage,
parseTasksStatusMessage,
} from './TasksStatusMessage';
import { GoalStatusMessage, parseGoalStatusMessage } from './GoalStatusMessage';
import { Markdown } from './Markdown';
import styles from './SystemMessage.module.css';
interface SystemMessageProps {
content: string;
variant: 'info' | 'error' | 'warning';
source?: string;
data?: unknown;
/** Run /context detail, exactly like typing it (context-usage panels). */
onShowContextDetail?: () => void;
isLatest?: boolean;
showRetryHint?: boolean;
onRetryClick?: () => void;
}
export const SystemMessage = memo(function SystemMessage({
content,
variant,
source,
data,
onShowContextDetail,
isLatest = false,
showRetryHint = false,
onRetryClick,
}: SystemMessageProps) {
const { t } = useI18n();
// The user ESC-cancelled a live stream. Render it right-aligned and subtle —
// a user-initiated stop reads as belonging to the user side of the transcript.
if (source === 'prompt_cancelled') {
return (
<div className={styles.cancelled} role="status">
<span>{t('turn.stopped')}</span>
</div>
);
}
const contextUsage =
variant === 'info' ? parseContextUsageMessage(content) : null;
if (contextUsage) {
return (
<div className={styles.flushMessage}>
<ContextUsageMessage
status={contextUsage}
onShowDetail={onShowContextDetail}
/>
</div>
);
}
const statsData = variant === 'info' ? parseStatsMessage(content) : null;
if (statsData) {
return (
<div className={styles.flushMessage}>
<StatsMessage view={statsData.view} status={statsData.status} />
</div>
);
}
const statusInfo = variant === 'info' ? parseStatusMessage(content) : null;
if (statusInfo) {
return (
<div className={styles.flushMessage}>
<StatusMessage info={statusInfo} />
</div>
);
}
const mcpStatus = variant === 'info' ? parseMcpStatusMessage(content) : null;
if (mcpStatus) {
return (
<div className={styles.flushMessage}>
<McpStatusMessage message={mcpStatus} />
</div>
);
}
const tasksStatus =
variant === 'info' ? parseTasksStatusMessage(content) : null;
if (tasksStatus) {
return (
<div className={styles.flushMessage}>
<TasksStatusMessage message={tasksStatus} />
</div>
);
}
const goalStatus =
variant === 'info'
? source === 'goal'
? parseGoalStatusMessage(data)
: parseGoalStatusMessage(content)
: null;
if (goalStatus) {
return (
<div className={styles.flushMessage}>
<GoalStatusMessage status={goalStatus} activateFooter={isLatest} />
</div>
);
}
const preserveWhitespace =
variant === 'info' && source === 'model_switch_summary';
const isRecap = variant === 'info' && source === 'recap';
return (
<div
className={`${styles.message} ${styles[variant]} ${
preserveWhitespace ? styles.modelSwitch : ''
} ${isRecap ? styles.recap : ''}`}
>
<div className={styles.content}>
{preserveWhitespace ? (
<pre>{content}</pre>
) : variant === 'info' ? (
<Markdown content={content} />
) : (
<pre>{content}</pre>
)}
{showRetryHint && onRetryClick && (
<div className={styles.retryHint}>
<button
type="button"
className={styles.retryButton}
onClick={onRetryClick}
>
{t('retry.hint')}
</button>
</div>
)}
</div>
</div>
);
});