Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/src/app/(app)/chat/[chatId]/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ChatPage />);

await waitFor(() => {
expect(screen.getByTestId('message-list')).toHaveAttribute('data-message-ids', 'message-a');
});

hydrationById.set('chat-a', { view: 'loading' });
rerender(<ChatPage />);

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' });
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/app/(app)/chat/[chatId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/chat/SuggestedQuestions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SuggestedQuestions questions={[]} isLoading={true} onSelectQuestion={vi.fn()} />,
);
rerender(<SuggestedQuestions questions={['后到的问题']} isLoading={false} onSelectQuestion={vi.fn()} />);
expect(screen.getByRole('button', { name: /后到的问题/ }).className).toContain('fade-in');

render(<SuggestedQuestions questions={['已有的问题']} isLoading={false} onSelectQuestion={vi.fn()} />);
expect(screen.getByRole('button', { name: /已有的问题/ }).className).not.toContain('fade-in');
});

it('uses stronger pending affordance after selecting a question', () => {
const onSelectQuestion = vi.fn();

Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/chat/SuggestedQuestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pendingQuestion, setPendingQuestion] = useState<string | null>(null);
// 只有先占位、后到达的问题才淡入;挂载时已有问题(流结束切换渲染分支、刷新页面)直接显示,不重播动画。
const [fadeInArrivals] = useState(() => questions.length === 0);
const { isAuthenticated } = useAppSelector((state) => state.auth);
const { toast } = useToast();
const isBusy = isLoading || isRefreshing || pendingQuestion !== null;
Expand Down Expand Up @@ -137,7 +139,8 @@ const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
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)}
Expand Down
Loading