mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-16 12:14:52 +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.
146 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
});
|