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 { contextSelectors, dataSelectors, useConversationStore } from '../store';
|
||||||
import { type ActivityKey, resolveOperationActivity } from '../utils/operationActivity';
|
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 }) => ({
|
const styles = createStaticStyles(({ css, cssVar }) => ({
|
||||||
container: css`
|
container: css`
|
||||||
@@ -110,6 +114,22 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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`
|
timerValue: css`
|
||||||
flex: none;
|
flex: none;
|
||||||
color: ${cssVar.colorTextTertiary};
|
color: ${cssVar.colorTextTertiary};
|
||||||
@@ -290,10 +310,12 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
|
|||||||
returnObjects: true,
|
returnObjects: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const rotationStep = Math.floor(elapsed / STATUS_PHRASE_ROTATION_MS);
|
||||||
const randomGeneratingStatus =
|
const randomGeneratingStatus =
|
||||||
pickStableStatusPhrase(
|
pickRotatingStatusPhrase(
|
||||||
generatingPhrases,
|
generatingPhrases,
|
||||||
operationState.statusSeed ?? String(operationState.startTime),
|
operationState.statusSeed ?? String(operationState.startTime),
|
||||||
|
rotationStep,
|
||||||
) ?? t('chat:opStatusTray.status.generating');
|
) ?? t('chat:opStatusTray.status.generating');
|
||||||
const statusText =
|
const statusText =
|
||||||
operationState.activity === 'generating'
|
operationState.activity === 'generating'
|
||||||
@@ -372,7 +394,11 @@ const OpStatusTray = memo<OpStatusTrayProps>(({ topAttached }) => {
|
|||||||
>
|
>
|
||||||
<span className={cx(styles.metric, styles.statusMetric)}>
|
<span className={cx(styles.metric, styles.statusMetric)}>
|
||||||
<ActivityGlyph />
|
<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 className={styles.timerValue}>{formatElapsedClockTime(elapsed)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { parseStatusPhrases, pickStableStatusPhrase } from './logic';
|
import { parseStatusPhrases, pickRotatingStatusPhrase, pickStableStatusPhrase } from './logic';
|
||||||
|
|
||||||
describe('OpStatusTray logic', () => {
|
describe('OpStatusTray logic', () => {
|
||||||
describe('status phrases', () => {
|
describe('status phrases', () => {
|
||||||
@@ -24,5 +24,40 @@ describe('OpStatusTray logic', () => {
|
|||||||
);
|
);
|
||||||
expect(phrases).toContain(pickStableStatusPhrase(phrases, 'op-123'));
|
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;
|
if (phrases.length === 0) return undefined;
|
||||||
return phrases[hashString(seed) % phrases.length];
|
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