mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
💄 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:
@@ -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];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user