app: use SyncedLoader for chat thinking indicator
This commit is contained in:
@@ -1,27 +1,24 @@
|
|||||||
import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react";
|
import React, { memo, useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import Animated, {
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { TurnTiming } from "@/timeline/turn-time";
|
import type { TurnTiming } from "@/timeline/turn-time";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import {
|
|
||||||
getWorkingIndicatorDotStrength,
|
|
||||||
WORKING_INDICATOR_CYCLE_MS,
|
|
||||||
WORKING_INDICATOR_OFFSETS,
|
|
||||||
} from "@/utils/working-indicator";
|
|
||||||
import {
|
import {
|
||||||
collectAssistantTurnContentForStreamRenderStrategy,
|
collectAssistantTurnContentForStreamRenderStrategy,
|
||||||
type StreamStrategy,
|
type StreamStrategy,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
|
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
|
||||||
|
import { SyncedLoader } from "./synced-loader";
|
||||||
|
|
||||||
|
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
|
||||||
|
const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||||
|
color:
|
||||||
|
theme.colorScheme === "light"
|
||||||
|
? theme.colors.palette.amber[700]
|
||||||
|
: theme.colors.palette.amber[500],
|
||||||
|
});
|
||||||
|
|
||||||
export type TurnContentStrategy = StreamStrategy;
|
export type TurnContentStrategy = StreamStrategy;
|
||||||
|
|
||||||
@@ -130,63 +127,10 @@ const WorkingIndicator = memo(function WorkingIndicator({
|
|||||||
}: {
|
}: {
|
||||||
inFlightTurnStartedAt?: Date | null;
|
inFlightTurnStartedAt?: Date | null;
|
||||||
}) {
|
}) {
|
||||||
const progress = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
progress.value = 0;
|
|
||||||
progress.value = withRepeat(
|
|
||||||
withTiming(1, {
|
|
||||||
duration: WORKING_INDICATOR_CYCLE_MS,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimation(progress);
|
|
||||||
progress.value = 0;
|
|
||||||
};
|
|
||||||
}, [progress]);
|
|
||||||
|
|
||||||
const translateDistance = -2;
|
|
||||||
const dotOneStyle = useAnimatedStyle(() => {
|
|
||||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
|
|
||||||
return {
|
|
||||||
opacity: 0.3 + strength * 0.7,
|
|
||||||
transform: [{ translateY: strength * translateDistance }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotTwoStyle = useAnimatedStyle(() => {
|
|
||||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
|
|
||||||
return {
|
|
||||||
opacity: 0.3 + strength * 0.7,
|
|
||||||
transform: [{ translateY: strength * translateDistance }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotThreeStyle = useAnimatedStyle(() => {
|
|
||||||
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
|
|
||||||
return {
|
|
||||||
opacity: 0.3 + strength * 0.7,
|
|
||||||
transform: [{ translateY: strength * translateDistance }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
|
|
||||||
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
|
|
||||||
const dotThreeCombinedStyle = useMemo(
|
|
||||||
() => [stylesheet.workingDot, dotThreeStyle],
|
|
||||||
[dotThreeStyle],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.turnFooterContent}>
|
<View style={stylesheet.turnFooterContent}>
|
||||||
<View style={stylesheet.workingDotsRow}>
|
<View style={stylesheet.workingLoader}>
|
||||||
<Animated.View style={dotOneCombinedStyle} />
|
<ThemedSyncedLoader size={14} uniProps={workingIndicatorColorMapping} />
|
||||||
<Animated.View style={dotTwoCombinedStyle} />
|
|
||||||
<Animated.View style={dotThreeCombinedStyle} />
|
|
||||||
</View>
|
</View>
|
||||||
{inFlightTurnStartedAt ? (
|
{inFlightTurnStartedAt ? (
|
||||||
<LiveElapsed
|
<LiveElapsed
|
||||||
@@ -272,16 +216,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
fontSize: STREAM_METADATA_FONT_SIZE,
|
fontSize: STREAM_METADATA_FONT_SIZE,
|
||||||
fontVariant: ["tabular-nums"],
|
fontVariant: ["tabular-nums"],
|
||||||
},
|
},
|
||||||
workingDotsRow: {
|
workingLoader: {
|
||||||
flexDirection: "row",
|
marginLeft: -2,
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[1],
|
|
||||||
transform: [{ translateY: 1 }],
|
|
||||||
},
|
|
||||||
workingDot: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 3,
|
|
||||||
backgroundColor: theme.colors.foregroundMuted,
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_OFFSETS } from "./working-indicator";
|
|
||||||
|
|
||||||
describe("getWorkingIndicatorDotStrength", () => {
|
|
||||||
it("returns a stable triangular pulse over one cycle", () => {
|
|
||||||
expect(getWorkingIndicatorDotStrength(0, 0)).toBe(0);
|
|
||||||
expect(getWorkingIndicatorDotStrength(0.125, 0)).toBe(0.25);
|
|
||||||
expect(getWorkingIndicatorDotStrength(0.25, 0)).toBe(0.5);
|
|
||||||
expect(getWorkingIndicatorDotStrength(0.5, 0)).toBe(1);
|
|
||||||
expect(getWorkingIndicatorDotStrength(0.75, 0)).toBe(0.5);
|
|
||||||
expect(getWorkingIndicatorDotStrength(1, 0)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the dots phase-shifted instead of identical", () => {
|
|
||||||
const strengths = WORKING_INDICATOR_OFFSETS.map((offset) =>
|
|
||||||
getWorkingIndicatorDotStrength(0, offset),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(strengths).toEqual([0, 0.26666666666666666, 0.5333333333333333]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("wraps progress cleanly across loop boundaries", () => {
|
|
||||||
expect(getWorkingIndicatorDotStrength(1.1, 0)).toBeCloseTo(
|
|
||||||
getWorkingIndicatorDotStrength(0.1, 0),
|
|
||||||
);
|
|
||||||
expect(getWorkingIndicatorDotStrength(-0.1, 0)).toBeCloseTo(
|
|
||||||
getWorkingIndicatorDotStrength(0.9, 0),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export const WORKING_INDICATOR_CYCLE_MS = 1200;
|
|
||||||
export const WORKING_INDICATOR_OFFSETS = [
|
|
||||||
0,
|
|
||||||
160 / WORKING_INDICATOR_CYCLE_MS,
|
|
||||||
320 / WORKING_INDICATOR_CYCLE_MS,
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function normalizePhase(value: number): number {
|
|
||||||
"worklet";
|
|
||||||
const wrapped = value % 1;
|
|
||||||
return wrapped < 0 ? wrapped + 1 : wrapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkingIndicatorDotStrength(progress: number, offset: number): number {
|
|
||||||
"worklet";
|
|
||||||
const phase = normalizePhase(progress + offset);
|
|
||||||
if (phase <= 0.5) {
|
|
||||||
return phase * 2;
|
|
||||||
}
|
|
||||||
return (1 - phase) * 2;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user