mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-11 09:46:05 +00:00
Split-view panes now render a compact streaming status: the spinner, elapsed time, token count, and cancel hint stay, but the rotating "witty" loading phrase is suppressed. StreamingStatus gains a showPhrase prop (default true, so the main chat is unchanged); when false it also skips the phrase-rotation timer so each pane avoids a needless interval.
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
import { useCallback, useState, useEffect, useRef } from 'react';
|
|
import {
|
|
PHRASE_CHANGE_INTERVAL_MS,
|
|
getLoadingPhrases,
|
|
} from '../constants/loadingPhrases';
|
|
import { useStreamingState } from '@qwen-code/webui/daemon-react-sdk';
|
|
import { useI18n } from '../i18n';
|
|
import { useWebShellCustomization } from '../customization';
|
|
import { useStreamingLoadingMetrics } from '../hooks/useStreamingLoadingMetrics';
|
|
import { formatTokenCount } from '../utils/formatTokenCount';
|
|
import styles from './StreamingStatus.module.css';
|
|
|
|
interface StreamingStatusProps {
|
|
startedAt?: number;
|
|
/**
|
|
* When false, hide the rotating "witty" loading phrase and skip its rotation
|
|
* timer entirely — the spinner, elapsed time, token count, and cancel hint
|
|
* still render. Split-view panes pass false to keep each pane's composer
|
|
* status compact. Defaults to true (the main chat shows the phrase).
|
|
*/
|
|
showPhrase?: boolean;
|
|
}
|
|
|
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
|
|
export function StreamingStatus({
|
|
startedAt,
|
|
showPhrase = true,
|
|
}: StreamingStatusProps) {
|
|
const streamingState = useStreamingState();
|
|
const { estimatedOutputTokens, isReceivingContent } =
|
|
useStreamingLoadingMetrics();
|
|
const { language, t } = useI18n();
|
|
const { loadingPhrases: customLoadingPhrases } = useWebShellCustomization();
|
|
// Read the resolver through a ref so its identity never feeds the rotation
|
|
// effect below. A host that passes an inline `loadingPhrases` arrow hands a
|
|
// fresh reference on every render, and during streaming App re-renders on
|
|
// every transcript delta — so depending on the resolver identity would tear
|
|
// down and recreate the 15s interval each render and flicker the phrase. The
|
|
// ref keeps the latest resolver while `resolvePhrases` stays stable; a changed
|
|
// resolver's output is picked up on the next tick.
|
|
const customLoadingPhrasesRef = useRef(customLoadingPhrases);
|
|
customLoadingPhrasesRef.current = customLoadingPhrases;
|
|
// Prefer the host's custom phrases; fall back to the built-in defaults when
|
|
// the resolver is absent or returns undefined/null. An empty array is honored
|
|
// as an explicit "hide" signal. The resolver is host-supplied, so a throw is
|
|
// contained here (it would otherwise propagate out of the setInterval tick
|
|
// below on every cycle and freeze the phrase) and falls back to defaults.
|
|
const resolvePhrases = useCallback((lang: string): readonly string[] => {
|
|
try {
|
|
return customLoadingPhrasesRef.current?.(lang) ?? getLoadingPhrases(lang);
|
|
} catch (error: unknown) {
|
|
console.warn(
|
|
'[web-shell] loadingPhrases resolver threw; using defaults',
|
|
error,
|
|
);
|
|
return getLoadingPhrases(lang);
|
|
}
|
|
}, []);
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const startTime = useRef(Date.now());
|
|
const [dotFrame, setDotFrame] = useState(0);
|
|
const [loadingPhrase, setLoadingPhrase] = useState(
|
|
() => resolvePhrases(language)[0] ?? '',
|
|
);
|
|
|
|
const isActive = streamingState !== 'idle';
|
|
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
setElapsed(0);
|
|
return;
|
|
}
|
|
|
|
startTime.current = startedAt ?? Date.now();
|
|
setElapsed(elapsedSeconds(startTime.current));
|
|
const interval = setInterval(() => {
|
|
setElapsed(elapsedSeconds(startTime.current));
|
|
}, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [isActive, startedAt]);
|
|
|
|
useEffect(() => {
|
|
// Callers that hide the phrase (e.g. split-view panes) don't need the
|
|
// rotation timer at all — bail before arming it so panes don't each run a
|
|
// needless interval and re-render on every tick.
|
|
if (!showPhrase) return;
|
|
if (streamingState === 'idle') {
|
|
setLoadingPhrase(resolvePhrases(language)[0] ?? '');
|
|
return;
|
|
}
|
|
|
|
// Re-resolve on every tick (not once at effect setup) so a host that swaps
|
|
// its resolver mid-stream is reflected on the next tick — resolvePhrases is
|
|
// kept stable by the ref above, so the effect itself does not re-run on
|
|
// resolver identity changes. An empty result picks index 0 → '' and hides
|
|
// the phrase, and is re-checked on the following tick.
|
|
const pickPhrase = () => {
|
|
const phrases = resolvePhrases(language);
|
|
const idx = Math.floor(Math.random() * phrases.length);
|
|
setLoadingPhrase(phrases[idx] ?? '');
|
|
};
|
|
|
|
pickPhrase();
|
|
const interval = setInterval(pickPhrase, PHRASE_CHANGE_INTERVAL_MS);
|
|
return () => clearInterval(interval);
|
|
}, [language, streamingState, resolvePhrases, showPhrase]);
|
|
|
|
useEffect(() => {
|
|
if (streamingState === 'idle') return;
|
|
const interval = setInterval(() => {
|
|
setDotFrame((f) => (f + 1) % SPINNER_FRAMES.length);
|
|
}, 250);
|
|
return () => clearInterval(interval);
|
|
}, [streamingState]);
|
|
|
|
if (streamingState === 'idle') return null;
|
|
|
|
const spinnerChar = SPINNER_FRAMES[dotFrame % SPINNER_FRAMES.length];
|
|
const arrow = isReceivingContent ? '↓' : '↑';
|
|
const timeStr = elapsed < 60 ? `${elapsed}s` : formatDuration(elapsed * 1000);
|
|
const tokenStr =
|
|
estimatedOutputTokens > 0
|
|
? ` · ${arrow} ${t('stream.tokens', { count: formatTokenCount(estimatedOutputTokens) })}`
|
|
: '';
|
|
|
|
return (
|
|
<div className={styles.status}>
|
|
<span className={styles.spinner}>{spinnerChar}</span>
|
|
{showPhrase && loadingPhrase && (
|
|
<span className={styles.label}>{loadingPhrase}</span>
|
|
)}
|
|
<span className={styles.meta}>
|
|
({timeStr}
|
|
{tokenStr} · {t('stream.cancel')})
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatDuration(milliseconds: number): string {
|
|
if (milliseconds <= 0) return '0s';
|
|
|
|
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
|
|
const hours = Math.floor(totalSeconds / 3600);
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
const seconds = totalSeconds % 60;
|
|
const parts: string[] = [];
|
|
|
|
if (hours > 0) parts.push(`${hours}h`);
|
|
if (minutes > 0) parts.push(`${minutes}m`);
|
|
if (seconds > 0) parts.push(`${seconds}s`);
|
|
|
|
return parts.length > 0 ? parts.join(' ') : '0s';
|
|
}
|
|
|
|
function elapsedSeconds(startedAt: number): number {
|
|
return Math.max(0, Math.ceil((Date.now() - startedAt) / 1000));
|
|
}
|