diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx
index 5a3e495e54..32923397e2 100644
--- a/packages/web-shell/client/components/ChatPane.test.tsx
+++ b/packages/web-shell/client/components/ChatPane.test.tsx
@@ -56,6 +56,7 @@ vi.mock('./StreamingStatus', () => ({
),
}));
@@ -163,6 +164,13 @@ describe('ChatPane', () => {
expect(container!.textContent).toContain('Refactor core');
});
+ it('suppresses the rotating loading phrase in its compact status', () => {
+ render();
+ expect(testid('pane-streaming')?.getAttribute('data-show-phrase')).toBe(
+ 'false',
+ );
+ });
+
it('sends a prompt to its own session on submit', () => {
render();
act(() =>
diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx
index 4ae6c265a8..f7f7e1c139 100644
--- a/packages/web-shell/client/components/ChatPane.tsx
+++ b/packages/web-shell/client/components/ChatPane.tsx
@@ -206,7 +206,9 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
/>
)}
-
+ {/* Panes keep the composer status compact: spinner + elapsed time +
+ token count + cancel hint, but no rotating "witty" loading phrase. */}
+
{
vi.restoreAllMocks();
});
-function render(customization: WebShellCustomization = {}): HTMLElement {
+function render(
+ customization: WebShellCustomization = {},
+ props: { showPhrase?: boolean } = {},
+): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
@@ -43,7 +46,7 @@ function render(customization: WebShellCustomization = {}): HTMLElement {
root.render(
-
+
,
);
@@ -90,6 +93,23 @@ describe('StreamingStatus loading phrases', () => {
);
});
+ it('hides the phrase but keeps the dynamic status when showPhrase is false', () => {
+ pinPhraseSelection();
+ // A resolver that would otherwise supply a phrase must still be suppressed.
+ const container = render({ loadingPhrases: () => ['should not appear'] }, {
+ showPhrase: false,
+ });
+ // The witty phrase (the "废话文学") is gone: no label span.
+ expect(labelText(container)).toBeUndefined();
+ expect(container.textContent).not.toContain('should not appear');
+ // But the dynamic status stays: spinner + meta (elapsed time + cancel hint).
+ const spans = container.firstElementChild?.querySelectorAll('span') ?? [];
+ expect(spans.length).toBe(2);
+ expect(spans[0]?.textContent).not.toBe(''); // spinner frame
+ expect(container.textContent).toContain('esc to cancel'); // meta/cancel hint
+ expect(container.textContent).toMatch(/\ds/); // elapsed time, e.g. "0s"
+ });
+
it('falls back to the built-in defaults when the resolver returns undefined', () => {
pinPhraseSelection();
const container = render({ loadingPhrases: () => undefined });
diff --git a/packages/web-shell/client/components/StreamingStatus.tsx b/packages/web-shell/client/components/StreamingStatus.tsx
index 77dc6cd551..36b48b3ce7 100644
--- a/packages/web-shell/client/components/StreamingStatus.tsx
+++ b/packages/web-shell/client/components/StreamingStatus.tsx
@@ -12,11 +12,21 @@ 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 }: StreamingStatusProps) {
+export function StreamingStatus({
+ startedAt,
+ showPhrase = true,
+}: StreamingStatusProps) {
const streamingState = useStreamingState();
const { estimatedOutputTokens, isReceivingContent } =
useStreamingLoadingMetrics();
@@ -71,6 +81,10 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
}, [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;
@@ -90,7 +104,7 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
pickPhrase();
const interval = setInterval(pickPhrase, PHRASE_CHANGE_INTERVAL_MS);
return () => clearInterval(interval);
- }, [language, streamingState, resolvePhrases]);
+ }, [language, streamingState, resolvePhrases, showPhrase]);
useEffect(() => {
if (streamingState === 'idle') return;
@@ -113,7 +127,9 @@ export function StreamingStatus({ startedAt }: StreamingStatusProps) {
return (
{spinnerChar}
- {loadingPhrase && {loadingPhrase}}
+ {showPhrase && loadingPhrase && (
+ {loadingPhrase}
+ )}
({timeStr}
{tokenStr} · {t('stream.cancel')})