chore: checkpoint local workspace changes

This commit is contained in:
2026-04-23 12:45:15 +08:00
parent 3eb9390e8f
commit a6cd9afcbb
47 changed files with 2154 additions and 529 deletions

View File

@@ -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();
});

View File

@@ -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 ? (