diff --git a/frontend/src/components/chat/AssistantMessage.test.tsx b/frontend/src/components/chat/AssistantMessage.test.tsx index fae14209..fff4701c 100644 --- a/frontend/src/components/chat/AssistantMessage.test.tsx +++ b/frontend/src/components/chat/AssistantMessage.test.tsx @@ -18,10 +18,15 @@ const { useAssistantMessageViewModelMock: vi.fn(), })); +const streamSlotState = vi.hoisted(() => ({ byConversation: {} as Record })); + vi.mock('@/redux/hooks', () => ({ useAppDispatch: () => dispatchMock, - useAppSelector: (selector: (state: { trajectory: { byConversationId: Record } }) => unknown) => ( - selector({ trajectory: { byConversationId: {} } }) + useAppSelector: (selector: (state: { + trajectory: { byConversationId: Record }; + stream: typeof streamSlotState; + }) => unknown) => ( + selector({ trajectory: { byConversationId: {} }, stream: streamSlotState }) ), })); @@ -610,4 +615,27 @@ describe('AssistantMessage', () => { structuredResultsLoading: true, }); }); + + it('正文显示完、推荐问题生成中时,流未关闭也先渲染推荐区', () => { + const props = { + message: assistantMessage({ suggestedQuestionsStatus: 'pending' }), + isLastMessage: true, + isStreaming: true, + suggestedQuestions: [], + isLoadingQuestions: true, + onSelectQuestion: vi.fn(), + activeChatId: 'chat-1', + modelName: 'Qwen Max', + }; + streamSlotState.byConversation = { 'chat-1': { totalTextLength: 40, displayedTextLength: 12 } }; + const { rerender } = render(); + // 打字机还没追上正文时不提前出现。 + expect(screen.queryByTestId('suggested-questions')).toBeNull(); + + streamSlotState.byConversation = { 'chat-1': { totalTextLength: 40, displayedTextLength: 40 } }; + // 测试里的 selector 不订阅 store,换一个 message 引用触发重渲染。 + rerender(); + expect(screen.getByTestId('suggested-questions')).toBeInTheDocument(); + streamSlotState.byConversation = {}; + }); }); diff --git a/frontend/src/components/chat/AssistantMessage.tsx b/frontend/src/components/chat/AssistantMessage.tsx index 4a742435..0c8b35d0 100644 --- a/frontend/src/components/chat/AssistantMessage.tsx +++ b/frontend/src/components/chat/AssistantMessage.tsx @@ -8,6 +8,7 @@ import type { FileWithPreview } from '@/lib/utils/fileHelpers'; import { useAppDispatch, useAppSelector } from '@/redux/hooks'; import { toggleReasoningVisibility } from '@/redux/slices/conversationSlice'; import { selectTrajectoryRunSummary } from '@/redux/slices/trajectorySlice'; +import { selectStreamSlot } from '@/redux/slices/streamSlice'; import type { AgentRunState } from '@/types/agentRun'; import type { TrajectoryBadgeStatus } from '@/lib/trajectory/TrajectoryCellProjection'; import type { Message } from '@/types/conversation'; @@ -202,6 +203,16 @@ function AssistantMessageFrame({ const { copied, copy } = useMessageCopy({ text: displayText }); const isCurrentMessageStreaming = viewModel.isCurrentlyStreaming || isStreaming; + // 后端在正文结束后才开始生成推荐问题,并等结果送达后才关闭流。这段时间正文已显示完, + // 先渲染推荐区占位,避免回答结束后停顿一下再整块弹出。 + const streamTextCaughtUp = useAppSelector(state => { + if (!conversationId) return false; + const slot = selectStreamSlot(state, conversationId); + return slot.displayedTextLength >= slot.totalTextLength; + }); + const awaitingSuggestedQuestions = isStreaming + && message.suggestedQuestionsStatus === 'pending' + && streamTextCaughtUp; const answerEvidenceSidebar = useMemo( () => deriveAnswerEvidenceSidebar({ @@ -394,7 +405,7 @@ function AssistantMessageFrame({ )} - {isLastMessage && !isStreaming && onSelectQuestion && ( + {isLastMessage && onSelectQuestion && (!isStreaming || awaitingSuggestedQuestions) && ( { ); expect(screen.getByText('正在生成可继续追问的问题...')).toBeTruthy(); + expect(screen.getByText('你可能想问:')).toBeTruthy(); + // 占位条与问题按钮同数同高,结果到达时高度不跳。 + const placeholder = screen.getByTestId('suggested-questions-placeholder'); + expect(placeholder.querySelectorAll('.h-8')).toHaveLength(3); }); it('uses stronger pending affordance after selecting a question', () => { diff --git a/frontend/src/components/chat/SuggestedQuestions.tsx b/frontend/src/components/chat/SuggestedQuestions.tsx index c96f4e98..403dce2c 100644 --- a/frontend/src/components/chat/SuggestedQuestions.tsx +++ b/frontend/src/components/chat/SuggestedQuestions.tsx @@ -15,6 +15,8 @@ interface SuggestedQuestionsProps { className?: string; } +const SKELETON_WIDTHS = ['72%', '58%', '66%']; + const SuggestedQuestions: React.FC = ({ questions, isLoading, @@ -135,7 +137,7 @@ 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", + "h-auto justify-start font-normal disabled:opacity-75 animate-in fade-in duration-300 motion-reduce:animate-none", pendingQuestion === question && "border-info-border bg-info-bg text-info" )} onClick={() => handleQuestionSelect(question)} @@ -152,15 +154,20 @@ const SuggestedQuestions: React.FC = ({ ))} - {/* 加载状态显示 */} + {/* 加载时用与问题按钮等高的占位条先撑开高度,结果到达后原位淡入,避免整块弹出。 */} {isLoading && ( -
-
-
-
-
-
- 正在生成可继续追问的问题... +
+ 正在生成可继续追问的问题... + {SKELETON_WIDTHS.map(width => ( +