From 17138b525f822dd33481c9a60f91060acf7d7423 Mon Sep 17 00:00:00 2001 From: Shaojin Wen Date: Tue, 7 Jul 2026 19:32:59 +0800 Subject: [PATCH] fix(web-shell): hide rotating loading phrase in split-view pane status (#6447) 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. --- .../client/components/ChatPane.test.tsx | 8 +++++++ .../web-shell/client/components/ChatPane.tsx | 4 +++- .../components/StreamingStatus.test.tsx | 24 +++++++++++++++++-- .../client/components/StreamingStatus.tsx | 22 ++++++++++++++--- 4 files changed, 52 insertions(+), 6 deletions(-) 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')})