chore: checkpoint local workspace changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
@@ -20,14 +20,22 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function ensureScrollApis() {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
|
||||
if (!HTMLElement.prototype.scrollTo) {
|
||||
HTMLElement.prototype.scrollTo = () => {};
|
||||
}
|
||||
}
|
||||
|
||||
test('creation agent workspace filters duplicate recommended replies', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
@@ -78,9 +86,7 @@ test('creation agent workspace filters duplicate recommended replies', () => {
|
||||
});
|
||||
|
||||
test('creation agent workspace renders streaming assistant text', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
@@ -114,10 +120,65 @@ test('creation agent workspace renders streaming assistant text', () => {
|
||||
expect(screen.getByText(/那我先顺着这个方向收一下/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace appends streaming assistant message after stable message list', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 2,
|
||||
progressPercent: 40,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-user-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个潮湿压抑的海上世界。',
|
||||
},
|
||||
{
|
||||
id: 'message-assistant-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '我先接住这个方向。',
|
||||
},
|
||||
{
|
||||
id: 'message-user-2',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '开场我想先撞上一场假航灯事故。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText="那我就把开场事故往沉船旧案上收。"
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const bubbles = screen
|
||||
.getByTestId('creation-agent-message-list')
|
||||
.querySelectorAll('.whitespace-pre-wrap');
|
||||
const bubbleTexts = Array.from(bubbles).map((node) => node.textContent?.trim());
|
||||
|
||||
expect(bubbleTexts).toEqual([
|
||||
'我想做一个潮湿压抑的海上世界。',
|
||||
'我先接住这个方向。',
|
||||
'开场我想先撞上一场假航灯事故。',
|
||||
'那我就把开场事故往沉船旧案上收。',
|
||||
]);
|
||||
});
|
||||
|
||||
test('creation agent workspace hides anchors and primary action before completed progress', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
@@ -159,9 +220,7 @@ test('creation agent workspace hides anchors and primary action before completed
|
||||
});
|
||||
|
||||
test('creation agent workspace shows primary and progress actions at completed progress', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
<CreationAgentWorkspace
|
||||
@@ -206,26 +265,26 @@ test('creation agent workspace shows primary and progress actions at completed p
|
||||
expect(screen.getByRole('button', { name: '补全剩余设定' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation agent workspace hides hero copy area when title and summary are absent', () => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
|
||||
ensureScrollApis();
|
||||
|
||||
render(
|
||||
const scrollToSpy = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = scrollToSpy;
|
||||
|
||||
const { rerender } = render(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: null,
|
||||
assistantSummary: null,
|
||||
currentTurn: 2,
|
||||
progressPercent: 60,
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '继续把设定收束到可生成状态。',
|
||||
text: '先确定一下世界方向。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
@@ -239,6 +298,56 @@ test('creation agent workspace hides hero copy area when title and summary are a
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('统一共创')).toBeNull();
|
||||
expect(screen.getByText('创作进度')).toBeTruthy();
|
||||
const messageList = screen.getByTestId('creation-agent-message-list');
|
||||
let scrollTop = 120;
|
||||
|
||||
Object.defineProperty(messageList, 'scrollHeight', {
|
||||
configurable: true,
|
||||
value: 640,
|
||||
});
|
||||
Object.defineProperty(messageList, 'clientHeight', {
|
||||
configurable: true,
|
||||
value: 240,
|
||||
});
|
||||
Object.defineProperty(messageList, 'scrollTop', {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value) => {
|
||||
scrollTop = Number(value);
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.scroll(messageList);
|
||||
scrollToSpy.mockClear();
|
||||
|
||||
rerender(
|
||||
<CreationAgentWorkspace
|
||||
session={{
|
||||
sessionId: 'creation-agent-session-1',
|
||||
title: '统一共创',
|
||||
currentTurn: 1,
|
||||
progressPercent: 20,
|
||||
anchors: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: '先确定一下世界方向。',
|
||||
},
|
||||
],
|
||||
}}
|
||||
theme={testTheme}
|
||||
loadingText="正在准备"
|
||||
composerPlaceholder="输入消息"
|
||||
primaryActionLabel="生成结果页"
|
||||
streamingReplyText="继续往下收束开场冲突。"
|
||||
isStreamingReply
|
||||
onBack={() => {}}
|
||||
onSubmitText={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(scrollToSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ export type CreationAgentOperationView = {
|
||||
|
||||
export type CreationAgentSessionView = {
|
||||
sessionId: string;
|
||||
title?: string | null;
|
||||
title: string;
|
||||
assistantSummary?: string | null;
|
||||
currentTurn: number;
|
||||
progressPercent: number;
|
||||
@@ -79,6 +79,8 @@ type CreationAgentWorkspaceProps = {
|
||||
onQuickAction?: (action: CreationAgentQuickAction) => void;
|
||||
};
|
||||
|
||||
const AUTO_SCROLL_FOLLOW_THRESHOLD_PX = 96;
|
||||
|
||||
function uniqueRecommendedReplies(recommendedReplies: string[] = []) {
|
||||
return [...new Set(recommendedReplies.map((reply) => reply.trim()).filter(Boolean))].slice(
|
||||
0,
|
||||
@@ -165,16 +167,18 @@ function CreationAgentMessageBubble({
|
||||
message,
|
||||
theme,
|
||||
recommendedReplies,
|
||||
isStreaming = false,
|
||||
onRecommendedReply,
|
||||
}: {
|
||||
message: CreationAgentMessageView;
|
||||
theme: CreationAgentTheme;
|
||||
recommendedReplies?: string[];
|
||||
isStreaming?: boolean;
|
||||
onRecommendedReply: (text: string) => void;
|
||||
}) {
|
||||
const isUser = message.role === 'user';
|
||||
const isSystem = message.role === 'system';
|
||||
const visibleRecommendedReplies = isUser
|
||||
const visibleRecommendedReplies = isUser || isStreaming
|
||||
? []
|
||||
: uniqueRecommendedReplies(recommendedReplies);
|
||||
const bubbleToneClass = isUser
|
||||
@@ -188,7 +192,24 @@ function CreationAgentMessageBubble({
|
||||
<div
|
||||
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
|
||||
>
|
||||
<div className="whitespace-pre-wrap">{message.text}</div>
|
||||
{isStreaming ? (
|
||||
message.text ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{message.text}
|
||||
<span
|
||||
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">{message.text}</div>
|
||||
)}
|
||||
{visibleRecommendedReplies.length > 0 ? (
|
||||
<div className="mt-2.5 flex flex-col gap-1.5">
|
||||
{visibleRecommendedReplies.map((reply, replyIndex) => (
|
||||
@@ -228,6 +249,25 @@ function shouldShowQuickAction(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMessageListNearBottom(container: HTMLDivElement) {
|
||||
return (
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight <=
|
||||
AUTO_SCROLL_FOLLOW_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
function scrollMessageListToBottom(container: HTMLDivElement) {
|
||||
if (typeof container.scrollTo === 'function') {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'auto',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
export function CreationAgentWorkspace({
|
||||
session,
|
||||
theme,
|
||||
@@ -247,14 +287,18 @@ export function CreationAgentWorkspace({
|
||||
onQuickAction,
|
||||
}: CreationAgentWorkspaceProps) {
|
||||
const [draftText, setDraftText] = useState('');
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
// 统一聊天区只在用户仍停留在底部附近时跟随新内容,避免流式回复持续抢走阅读位置。
|
||||
const messageListRef = useRef<HTMLDivElement | null>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
});
|
||||
}, [session?.messages, streamingReplyText, isStreamingReply]);
|
||||
const container = messageListRef.current;
|
||||
if (!container || !shouldAutoScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollMessageListToBottom(container);
|
||||
}, [session?.sessionId, session?.messages, streamingReplyText, isStreamingReply]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
@@ -267,23 +311,53 @@ export function CreationAgentWorkspace({
|
||||
}
|
||||
|
||||
const progress = normalizeCreationAgentProgress(session.progressPercent);
|
||||
const hasHeroCopy = Boolean(session.title || session.assistantSummary);
|
||||
const canShowPrimaryAction = progress >= 100;
|
||||
const visibleQuickActions = quickActions.filter((action) =>
|
||||
shouldShowQuickAction(action, session, progress),
|
||||
);
|
||||
const streamingMessageId = `streaming-assistant-${session.sessionId}`;
|
||||
const displayedMessages = isStreamingReply
|
||||
? [
|
||||
...session.messages,
|
||||
{
|
||||
id: streamingMessageId,
|
||||
role: 'assistant',
|
||||
kind: 'chat',
|
||||
text: streamingReplyText,
|
||||
} satisfies CreationAgentMessageView,
|
||||
]
|
||||
: session.messages;
|
||||
const lastAssistantMessageIndex = session.messages.reduce(
|
||||
(lastIndex, message, index) =>
|
||||
message.role === 'assistant' ? index : lastIndex,
|
||||
-1,
|
||||
);
|
||||
|
||||
const armAutoScrollToBottom = () => {
|
||||
shouldAutoScrollRef.current = true;
|
||||
};
|
||||
|
||||
const handleMessageListScroll = () => {
|
||||
const container = messageListRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldAutoScrollRef.current = isMessageListNearBottom(container);
|
||||
};
|
||||
|
||||
const submitRecommendedReply = (text: string) => {
|
||||
armAutoScrollToBottom();
|
||||
onSubmitText(text);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const text = draftText.trim();
|
||||
if (!text || isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
armAutoScrollToBottom();
|
||||
onSubmitText(text);
|
||||
setDraftText('');
|
||||
};
|
||||
@@ -314,22 +388,18 @@ export function CreationAgentWorkspace({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasHeroCopy ? (
|
||||
<div className="mt-6">
|
||||
{session.title ? (
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{session.title}
|
||||
</div>
|
||||
) : null}
|
||||
{session.assistantSummary ? (
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
||||
{session.assistantSummary}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
{session.title}
|
||||
</div>
|
||||
) : null}
|
||||
{session.assistantSummary ? (
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
|
||||
{session.assistantSummary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
|
||||
创作进度
|
||||
@@ -370,48 +440,33 @@ export function CreationAgentWorkspace({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-[1.6rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4">
|
||||
{session.messages.length === 0 ? (
|
||||
<div
|
||||
ref={messageListRef}
|
||||
data-testid="creation-agent-message-list"
|
||||
onScroll={handleMessageListScroll}
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4"
|
||||
>
|
||||
{displayedMessages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
|
||||
暂无消息
|
||||
</div>
|
||||
) : (
|
||||
session.messages.map((message, index) => (
|
||||
displayedMessages.map((message, index) => (
|
||||
<CreationAgentMessageBubble
|
||||
key={message.id || `message-${index}`}
|
||||
message={message}
|
||||
theme={theme}
|
||||
isStreaming={message.id === streamingMessageId}
|
||||
recommendedReplies={
|
||||
message.id !== streamingMessageId &&
|
||||
index === lastAssistantMessageIndex
|
||||
? session.recommendedReplies
|
||||
: []
|
||||
}
|
||||
onRecommendedReply={(text) => onSubmitText(text)}
|
||||
onRecommendedReply={submitRecommendedReply}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{isStreamingReply ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
|
||||
{streamingReplyText ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{streamingReplyText}
|
||||
<span
|
||||
className={`ml-1 inline-block h-4 w-1 animate-pulse rounded-full align-[-2px] ${theme.accentBgClass}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 py-1">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
|
||||
Reference in New Issue
Block a user