From ca014daadb8475b3d74544b82ca5e974fec7b075 Mon Sep 17 00:00:00 2001 From: HyxiaoGe <18889592303@163.com> Date: Sun, 11 Oct 2026 20:51:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=9E=E7=AD=94=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E5=90=8E=E6=8E=A8=E8=8D=90=E9=97=AE=E9=A2=98=E5=85=88=E5=8D=A0?= =?UTF-8?q?=E4=BD=8D=E5=86=8D=E6=B7=A1=E5=85=A5=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=81=9C=E9=A1=BF=E5=90=8E=E6=95=B4=E5=9D=97=E5=BC=B9=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../components/chat/AssistantMessage.test.tsx | 32 +++++++++++++++++-- .../src/components/chat/AssistantMessage.tsx | 13 +++++++- .../chat/SuggestedQuestions.test.tsx | 4 +++ .../components/chat/SuggestedQuestions.tsx | 25 +++++++++------ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/chat/AssistantMessage.test.tsx b/frontend/src/components/chat/AssistantMessage.test.tsx index fae14209c..fff4701cb 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 4a7424354..0c8b35d01 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 c96f4e984..403dce2cb 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 => ( +