diff --git a/frontend/src/app/(app)/chat/[chatId]/page.test.tsx b/frontend/src/app/(app)/chat/[chatId]/page.test.tsx index a5a65d92..4103138a 100644 --- a/frontend/src/app/(app)/chat/[chatId]/page.test.tsx +++ b/frontend/src/app/(app)/chat/[chatId]/page.test.tsx @@ -2418,6 +2418,26 @@ describe('ChatPage 会话切换体验', () => { expect(lastMessageListProps?.loadingState).toBeUndefined(); }); + it('同一会话回答完成后的权威刷新期间保留推荐问题', async () => { + conversationsById.set('chat-a', createConversation('chat-a', [textMessage('message-a')])); + hydrationById.set('chat-a', { view: 'ready' }); + suggestedQuestionsState.questions = ['继续问']; + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('message-list')).toHaveAttribute('data-message-ids', 'message-a'); + }); + + hydrationById.set('chat-a', { view: 'loading' }); + rerender(); + + const lastMessageListProps = chatMessageListMock.mock.calls.at(-1)?.[0]; + expect(lastMessageListProps?.loadingState).toBeUndefined(); + expect(lastMessageListProps?.suggestedQuestions).toEqual(['继续问']); + expect(lastMessageListProps?.onSelectQuestion).toEqual(expect.any(Function)); + }); + it('动态 page remount 后切到未加载过的 loading 会话时不显示上一段 ready messages', async () => { conversationsById.set('chat-a', createConversation('chat-a', [textMessage('message-a')])); hydrationById.set('chat-a', { view: 'ready' }); diff --git a/frontend/src/app/(app)/chat/[chatId]/page.tsx b/frontend/src/app/(app)/chat/[chatId]/page.tsx index 7d9e2936..33d7420a 100644 --- a/frontend/src/app/(app)/chat/[chatId]/page.tsx +++ b/frontend/src/app/(app)/chat/[chatId]/page.tsx @@ -1356,10 +1356,12 @@ export default function ChatPage() { ? undefined : handleInspectTrajectory } - suggestedQuestions={shouldKeepPreviousContent || isHydratingWithoutContent ? [] : suggestedQuestions} - isLoadingQuestions={shouldKeepPreviousContent || isHydratingWithoutContent ? false : isLoadingQuestions} - onSelectQuestion={shouldKeepPreviousContent || isHydratingWithoutContent ? undefined : handleSelectQuestion} - onRefreshQuestions={shouldKeepPreviousContent || isHydratingWithoutContent ? undefined : handleRefreshQuestions} + // 保留上一屏的一定是同一会话(回答完成后的权威刷新),推荐问题属于它, + // 照常显示,否则刷新的一瞬间推荐区会消失再出现。 + suggestedQuestions={isHydratingWithoutContent ? [] : suggestedQuestions} + isLoadingQuestions={isHydratingWithoutContent ? false : isLoadingQuestions} + onSelectQuestion={isHydratingWithoutContent ? undefined : handleSelectQuestion} + onRefreshQuestions={isHydratingWithoutContent ? undefined : handleRefreshQuestions} completionStateVisible={shouldKeepPreviousContent || isHydratingWithoutContent ? false : showCompletionState} notificationReadEnabled={notificationReadReady && hydrationStatus === 'done' && !shouldKeepPreviousContent && activeSurface === 'chat'} notificationSessionKey={authSessionKey} diff --git a/frontend/src/components/chat/SuggestedQuestions.test.tsx b/frontend/src/components/chat/SuggestedQuestions.test.tsx index 27ff6efb..848aca03 100644 --- a/frontend/src/components/chat/SuggestedQuestions.test.tsx +++ b/frontend/src/components/chat/SuggestedQuestions.test.tsx @@ -174,6 +174,17 @@ describe('SuggestedQuestions', () => { expect(placeholder.querySelectorAll('.h-8')).toHaveLength(3); }); + it('fades in questions only when they arrive after the placeholder', () => { + const { rerender } = render( + , + ); + rerender(); + expect(screen.getByRole('button', { name: /后到的问题/ }).className).toContain('fade-in'); + + render(); + expect(screen.getByRole('button', { name: /已有的问题/ }).className).not.toContain('fade-in'); + }); + it('uses stronger pending affordance after selecting a question', () => { const onSelectQuestion = vi.fn(); diff --git a/frontend/src/components/chat/SuggestedQuestions.tsx b/frontend/src/components/chat/SuggestedQuestions.tsx index 403dce2c..a0e95e49 100644 --- a/frontend/src/components/chat/SuggestedQuestions.tsx +++ b/frontend/src/components/chat/SuggestedQuestions.tsx @@ -28,6 +28,8 @@ const SuggestedQuestions: React.FC = ({ const [hoveredIndex, setHoveredIndex] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [pendingQuestion, setPendingQuestion] = useState(null); + // 只有先占位、后到达的问题才淡入;挂载时已有问题(流结束切换渲染分支、刷新页面)直接显示,不重播动画。 + const [fadeInArrivals] = useState(() => questions.length === 0); const { isAuthenticated } = useAppSelector((state) => state.auth); const { toast } = useToast(); const isBusy = isLoading || isRefreshing || pendingQuestion !== null; @@ -137,7 +139,8 @@ const SuggestedQuestions: React.FC = ({ size="sm" className={cn( "flex items-center gap-2 w-full text-left rounded-md border border-border/50 bg-transparent px-2.5 py-1.5 text-sm text-foreground transition-colors duration-fast hover:bg-muted/30 hover:border-border", - "h-auto justify-start font-normal disabled:opacity-75 animate-in fade-in duration-300 motion-reduce:animate-none", + "h-auto justify-start font-normal disabled:opacity-75", + fadeInArrivals && "animate-in fade-in duration-300 motion-reduce:animate-none", pendingQuestion === question && "border-info-border bg-info-bg text-info" )} onClick={() => handleQuestionSelect(question)}