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
32 changes: 30 additions & 2 deletions frontend/src/components/chat/AssistantMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ const {
useAssistantMessageViewModelMock: vi.fn(),
}));

const streamSlotState = vi.hoisted(() => ({ byConversation: {} as Record<string, unknown> }));

vi.mock('@/redux/hooks', () => ({
useAppDispatch: () => dispatchMock,
useAppSelector: (selector: (state: { trajectory: { byConversationId: Record<string, never> } }) => unknown) => (
selector({ trajectory: { byConversationId: {} } })
useAppSelector: (selector: (state: {
trajectory: { byConversationId: Record<string, never> };
stream: typeof streamSlotState;
}) => unknown) => (
selector({ trajectory: { byConversationId: {} }, stream: streamSlotState })
),
}));

Expand Down Expand Up @@ -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(<AssistantMessage {...props} />);
// 打字机还没追上正文时不提前出现。
expect(screen.queryByTestId('suggested-questions')).toBeNull();

streamSlotState.byConversation = { 'chat-1': { totalTextLength: 40, displayedTextLength: 40 } };
// 测试里的 selector 不订阅 store,换一个 message 引用触发重渲染。
rerender(<AssistantMessage {...props} message={{ ...props.message }} />);
expect(screen.getByTestId('suggested-questions')).toBeInTheDocument();
streamSlotState.byConversation = {};
});
});
13 changes: 12 additions & 1 deletion frontend/src/components/chat/AssistantMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -394,7 +405,7 @@ function AssistantMessageFrame({
)}
</div>

{isLastMessage && !isStreaming && onSelectQuestion && (
{isLastMessage && onSelectQuestion && (!isStreaming || awaitingSuggestedQuestions) && (
<SuggestedQuestions
questions={suggestedQuestions}
isLoading={isLoadingQuestions}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/chat/SuggestedQuestions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ describe('SuggestedQuestions', () => {
);

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', () => {
Expand Down
25 changes: 16 additions & 9 deletions frontend/src/components/chat/SuggestedQuestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ interface SuggestedQuestionsProps {
className?: string;
}

const SKELETON_WIDTHS = ['72%', '58%', '66%'];

const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
questions,
isLoading,
Expand Down Expand Up @@ -135,7 +137,7 @@ 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",
"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)}
Expand All @@ -152,15 +154,20 @@ const SuggestedQuestions: React.FC<SuggestedQuestionsProps> = ({
</Button>
))}

{/* 加载状态显示 */}
{/* 加载时用与问题按钮等高的占位条先撑开高度,结果到达后原位淡入,避免整块弹出。 */}
{isLoading && (
<div role="status" className="text-xs text-muted-foreground flex items-center py-3">
<div className="flex space-x-1 mr-2">
<div className="h-1.5 w-1.5 bg-primary/40 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="h-1.5 w-1.5 bg-primary/40 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="h-1.5 w-1.5 bg-primary/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
<span>正在生成可继续追问的问题...</span>
<div role="status" aria-busy="true" className="flex flex-col space-y-2" data-testid="suggested-questions-placeholder">
<span className="sr-only">正在生成可继续追问的问题...</span>
{SKELETON_WIDTHS.map(width => (
<div
key={width}
aria-hidden="true"
className="flex h-8 items-center gap-2 rounded-md border border-border/40 px-2.5"
>
<div className="h-3.5 w-3.5 flex-shrink-0 rounded-sm bg-muted animate-pulse motion-reduce:animate-none" />
<div className="h-3 rounded bg-muted animate-pulse motion-reduce:animate-none" style={{ width }} />
</div>
))}
</div>
)}
</div>
Expand Down
Loading