💄 style(chat): carousel the OpStatusTray generating phrase every 4s (#15775)

The generating status phrase was picked once per operation and stayed
frozen for the whole run. Rotate it like a carousel — advancing to the
next phrase every 4s with a subtle fade — so a long-running task feels
alive instead of stuck on one line.

- add pickRotatingStatusPhrase: seed keeps the starting phrase stable
  per operation, step advances the carousel; reuses the existing 1s
  elapsed ticker so no extra timer is needed
- fade/slide the phrase on each switch via a keyed wrapper span (keeps
  the shiny-text shimmer animation intact)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-06-13 20:03:07 +08:00
committed by GitHub
parent d9d9f44cb2
commit 09fd6f3411
3 changed files with 81 additions and 4 deletions
@@ -21,7 +21,11 @@ import {
import { contextSelectors, dataSelectors, useConversationStore } from '../store';
import { type ActivityKey, resolveOperationActivity } from '../utils/operationActivity';
import { parseStatusPhrases, pickStableStatusPhrase } from './OpStatusTray/logic';
import { parseStatusPhrases, pickRotatingStatusPhrase } from './OpStatusTray/logic';
// Cycle the generating phrase like a carousel so a long-running task doesn't
// stare back with the same line the whole time.
const STATUS_PHRASE_ROTATION_MS = 4000;
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
@@ -110,6 +114,22 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
text-overflow: ellipsis;
white-space: nowrap;
`,
statusPhrase: css`
@keyframes op-status-tray-phrase-enter {
from {
transform: translateY(3px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
display: inline-block;
animation: op-status-tray-phrase-enter 0.4s ease;
`,
timerValue: css`
flex: none;
color: ${cssVar.colorTextTertiary};
@@ -290,10 +310,12 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
returnObjects: true,
}),
);
const rotationStep = Math.floor(elapsed / STATUS_PHRASE_ROTATION_MS);
const randomGeneratingStatus =
pickStableStatusPhrase(
pickRotatingStatusPhrase(
generatingPhrases,
operationState.statusSeed ?? String(operationState.startTime),
rotationStep,
) ?? t('chat:opStatusTray.status.generating');
const statusText =
operationState.activity === 'generating'
@@ -372,7 +394,11 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
>
<span className={cx(styles.metric, styles.statusMetric)}>
<ActivityGlyph />
<span className={cx(styles.statusText, shinyTextStyles.shinyText)}>{statusText}...</span>
<span className={styles.statusText}>
<span className={styles.statusPhrase} key={statusText}>
<span className={shinyTextStyles.shinyText}>{statusText}...</span>
</span>
</span>
<span className={styles.timerValue}>{formatElapsedClockTime(elapsed)}</span>
</span>
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { parseStatusPhrases, pickStableStatusPhrase } from './logic';
import { parseStatusPhrases, pickRotatingStatusPhrase, pickStableStatusPhrase } from './logic';
describe('OpStatusTray logic', () => {
describe('status phrases', () => {
@@ -24,5 +24,40 @@ describe('OpStatusTray logic', () => {
);
expect(phrases).toContain(pickStableStatusPhrase(phrases, 'op-123'));
});
it('starts the rotation from the stable phrase and advances by step', () => {
const phrases = ['Working', 'Thinking', 'Flambéing'];
const start = pickStableStatusPhrase(phrases, 'op-123');
expect(pickRotatingStatusPhrase(phrases, 'op-123', 0)).toBe(start);
const startIndex = phrases.indexOf(start!);
expect(pickRotatingStatusPhrase(phrases, 'op-123', 1)).toBe(
phrases[(startIndex + 1) % phrases.length],
);
});
it('wraps around the phrase list as the step grows', () => {
const phrases = ['Working', 'Thinking', 'Flambéing'];
expect(pickRotatingStatusPhrase(phrases, 'op-123', 0)).toBe(
pickRotatingStatusPhrase(phrases, 'op-123', phrases.length),
);
});
it('tolerates non-finite or negative steps', () => {
const phrases = ['Working', 'Thinking', 'Flambéing'];
expect(pickRotatingStatusPhrase(phrases, 'op-123', Number.NaN)).toBe(
pickRotatingStatusPhrase(phrases, 'op-123', 0),
);
expect(pickRotatingStatusPhrase(phrases, 'op-123', -5)).toBe(
pickRotatingStatusPhrase(phrases, 'op-123', 0),
);
});
it('returns undefined when there are no phrases', () => {
expect(pickRotatingStatusPhrase([], 'op-123', 3)).toBeUndefined();
});
});
});
@@ -27,3 +27,19 @@ export const pickStableStatusPhrase = (phrases: string[], seed: string): string
if (phrases.length === 0) return undefined;
return phrases[hashString(seed) % phrases.length];
};
/**
* Cycle through phrases over time so the status text reads like a carousel.
* `step` advances once per rotation tick; the seed keeps the starting phrase
* stable per operation so two concurrent operations don't sync up.
*/
export const pickRotatingStatusPhrase = (
phrases: string[],
seed: string,
step: number,
): string | undefined => {
if (phrases.length === 0) return undefined;
const start = hashString(seed) % phrases.length;
const safeStep = Number.isFinite(step) ? Math.max(0, Math.floor(step)) : 0;
return phrases[(start + safeStep) % phrases.length];
};