Merge branch 'main' into mingholy/fix/api-key-auth

This commit is contained in:
易良 2026-07-07 19:35:50 +08:00 committed by GitHub
commit 54c899ce82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 52 additions and 6 deletions

View file

@ -56,6 +56,7 @@ vi.mock('./StreamingStatus', () => ({
<div
data-testid="pane-streaming"
data-started-at={props.startedAt === undefined ? 'none' : String(props.startedAt)}
data-show-phrase={String(props.showPhrase)}
/>
),
}));
@ -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(() =>

View file

@ -206,7 +206,9 @@ export function ChatPane({ title, isCurrent, onClose, onError }: ChatPaneProps)
/>
</div>
)}
<StreamingStatus startedAt={activeTurnStartedAt} />
{/* Panes keep the composer status compact: spinner + elapsed time +
token count + cancel hint, but no rotating "witty" loading phrase. */}
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
<ChatEditor
onSubmit={handleSubmit}
onCancel={handleCancel}

View file

@ -35,7 +35,10 @@ afterEach(() => {
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(
<I18nProvider language="en">
<WebShellCustomizationProvider value={customization}>
<StreamingStatus />
<StreamingStatus {...props} />
</WebShellCustomizationProvider>
</I18nProvider>,
);
@ -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 });

View file

@ -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 (
<div className={styles.status}>
<span className={styles.spinner}>{spinnerChar}</span>
{loadingPhrase && <span className={styles.label}>{loadingPhrase}</span>}
{showPhrase && loadingPhrase && (
<span className={styles.label}>{loadingPhrase}</span>
)}
<span className={styles.meta}>
({timeStr}
{tokenStr} · {t('stream.cancel')})