Merge branch 'master' into editor-agent-more-tools
Project CI / Repository checks (pull_request) Successful in 1m9s
Project CI / Frontend tests (pull_request) Failing after 37s
Project CI / Native shell tests (pull_request) Successful in 2m29s
Project CI / Backend tests (pull_request) Successful in 3m8s

This commit is contained in:
2026-07-28 15:27:44 +08:00
8 changed files with 951 additions and 67 deletions
@@ -60,8 +60,13 @@
- 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。
- 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
- 底部消息输入框随输入内容从单行高度自动增长,最大高度为
128px;输入框及其 Enter 提交、原生自适应和兼容降级统一封装在独立 `EditorAgentDraftTextarea` 组件中。支持 `field-sizing: content` 的浏览器使用原生内容尺寸自适应,不支持该属性的旧 Safari / iOS WebView 使用前端测量降级,并在宽度变化时重新计算换行高度。内容超过最大高度后停止增长并启用内部纵向滚动,内容缩短或清空后同步收缩。内部滚动条使用浅灰窄滑块和透明轨道,上下留白不得溢出输入框圆角边界;输入框在窄屏下允许收缩且不产生横向滚动。
- Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择
- 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿
- 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。
- 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
@@ -11,6 +11,7 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type {
EditorAgentConversationDetail,
EditorAgentMessage,
EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts';
@@ -264,11 +265,13 @@ describe('EditorAgentConversationPanelView', () => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
const newConversationButton = screen.getByRole('button', {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(false);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '应保留到新会话的草稿' },
});
fireEvent.click(screen.getByRole('button', { name: '添加附件' }));
const attachmentDialog = screen.getByRole('dialog', {
name: '选择图片附件',
@@ -294,6 +297,32 @@ describe('EditorAgentConversationPanelView', () => {
screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'),
).toBeTruthy();
fireEvent.click(newConversationButton);
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
await waitFor(() => {
expect(
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
).toBe('conversation-2');
expect(newConversationButton.disabled).toBe(true);
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('应保留到新会话的草稿');
expect(screen.getByText('角色图层')).toBeTruthy();
expect(
screen.getByRole('option', { name: '角色参考' }),
).toBeTruthy();
expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '尚未发送的草稿' },
});
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '参考附件做像素风' },
});
@@ -415,6 +444,242 @@ describe('EditorAgentConversationPanelView', () => {
});
});
it('keeps new conversation disabled when the project has no conversation history', async () => {
const client = createClient();
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(client.listConversations).toHaveBeenCalledWith('project-1');
expect(screen.getByText('暂无消息')).toBeTruthy();
});
const newConversationButton = screen.getByRole('button', {
name: '新建对话',
}) as HTMLButtonElement;
expect(newConversationButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '未发送内容' },
});
expect(newConversationButton.disabled).toBe(true);
fireEvent.click(newConversationButton);
expect(client.createConversation).not.toHaveBeenCalled();
});
it('preserves the current conversation draft when creating a conversation fails', async () => {
const client = createClient();
vi.mocked(client.createConversation).mockRejectedValueOnce(
new Error('创建新会话失败'),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '需要保留的草稿' },
});
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(screen.getByText('创建新会话失败')).toBeTruthy();
});
expect(
(screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value,
).toBe('需要保留的草稿');
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
expect(
(screen.getByLabelText('当前对话') as HTMLSelectElement).value,
).toBe('conversation-1');
});
it('preserves newer draft edits while creating a conversation', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveCreate = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
fireEvent.change(draftInput, {
target: { value: '旧项目草稿' },
});
fireEvent.click(screen.getByRole('button', { name: '新建对话' }));
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
expect(draftInput.disabled).toBe(false);
});
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
expect(conversationSelect.disabled).toBe(true);
expect(sendButton.disabled).toBe(true);
expect(
(screen.getByRole('button', { name: '添加附件' }) as HTMLButtonElement)
.disabled,
).toBe(false);
fireEvent.change(draftInput, {
target: { value: '创建期间更新的草稿' },
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('创建期间更新的草稿');
await act(async () => {
resolveCreate({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await Promise.resolve();
});
expect(draftInput.value).toBe('创建期间更新的草稿');
expect(conversationSelect.value).toBe('conversation-2');
expect(conversationSelect.disabled).toBe(false);
expect(sendButton.disabled).toBe(false);
});
it('preserves the draft when submission is attempted during conversation loading', async () => {
const client = createClient();
let resolveConversationLoad!: (
detail: EditorAgentConversationDetail,
) => void;
vi.mocked(client.listConversations).mockResolvedValueOnce([
{
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
updatedAt: '2026-07-03T00:01:00.000Z',
},
{
conversationId: 'conversation-2',
projectId: 'project-1',
title: '背景参考',
updatedAt: '2026-07-03T00:00:00.000Z',
},
]);
vi.mocked(client.getConversation)
.mockResolvedValueOnce({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '角色参考',
messages: [
{
id: 0,
role: 'assistant',
text: '已经看到画布内容',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:00:10.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:00:10.000Z',
})
.mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveConversationLoad = resolve;
}),
);
render(
<EditorAgentConversationPanelView
open
onToggleOpen={vi.fn()}
client={client}
/>,
);
await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
});
const draftInput = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
const conversationSelect = screen.getByLabelText(
'当前对话',
) as HTMLSelectElement;
fireEvent.change(draftInput, {
target: { value: '切换期间必须保留的草稿' },
});
fireEvent.change(conversationSelect, {
target: { value: 'conversation-2' },
});
const sendButton = screen.getByRole('button', {
name: '发送',
}) as HTMLButtonElement;
await waitFor(() => {
expect(client.getConversation).toHaveBeenCalledWith('conversation-2');
expect(sendButton.disabled).toBe(true);
});
fireEvent.submit(draftInput.closest('form') as HTMLFormElement);
expect(client.sendMessage).not.toHaveBeenCalled();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
await act(async () => {
resolveConversationLoad({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '背景参考',
messages: [
{
id: 1,
role: 'assistant',
text: '背景会话内容',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await Promise.resolve();
});
expect(await screen.findByText('背景会话内容')).toBeTruthy();
expect(draftInput.value).toBe('切换期间必须保留的草稿');
expect(sendButton.disabled).toBe(false);
});
it('disables sending while a message request is pending without showing stop', async () => {
const client = createClient();
let resolveSend!: (response: EditorAgentMessageResponse) => void;
@@ -1,5 +1,6 @@
import {
Bot,
ChevronDown,
Loader2,
MessageCircle,
Paperclip,
@@ -19,6 +20,7 @@ import { PlatformDangerConfirmDialog } from '@/src/components/common/PlatformDan
import AttachmentChip from '@/src/components/image-editor/EditorAgentConversation/AttachmentChip.tsx';
import { AttachmentPicker } from '@/src/components/image-editor/EditorAgentConversation/AttachmentPicker.tsx';
import { attachmentKey } from '@/src/components/image-editor/EditorAgentConversation/common.ts';
import { EditorAgentDraftTextarea } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx';
import {
MessageBubble,
ThinkingBubble,
@@ -69,6 +71,7 @@ export function EditorAgentConversationPanelView({
const effectiveProjectId = hasConversationMounted ? projectId : null;
const {
conversations,
activeConversation,
activeConversationId,
messages,
isLoadingConversations,
@@ -118,13 +121,29 @@ export function EditorAgentConversationPanelView({
const hasProject = Boolean(projectId?.trim());
const isConversationBusy = isWaiting || isToolCallActionPending;
const hasCurrentConversationContent = messages.length > 0;
const isConversationSelectDisabled =
!conversations.length ||
isLoadingConversations ||
isCreatingConversation ||
isConversationBusy;
const isMessageSubmissionBlocked =
isCreatingConversation ||
isLoadingConversations ||
isLoadingMessages ||
isWaiting ||
isToolCallActionPending ||
isPastingAttachment ||
!hasProject;
const currentConversationTitle = activeConversation?.title ?? '新对话';
const handleCreateConversation = () => {
void createConversation().catch(() => undefined);
};
const submitMessage = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (isWaiting) {
return;
}
if (isPastingAttachment) {
if (isMessageSubmissionBlocked) {
return;
}
const text = draftText.trim();
@@ -164,43 +183,70 @@ export function EditorAgentConversationPanelView({
>
<header className="flex items-center gap-2 border-b border-slate-200 bg-white/90 px-3 py-3">
<Bot className="h-5 w-5 text-slate-700" aria-hidden="true" />
<select
className="min-w-0 flex-1 rounded-full border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800"
aria-label="当前对话"
value={activeConversationId ?? ''}
disabled={
!conversations.length ||
isLoadingConversations ||
isConversationBusy
}
onChange={(event) => {
const nextConversationId = event.currentTarget.value;
if (nextConversationId) {
void selectConversation(nextConversationId);
}
}}
<div
className="relative min-w-0 flex-1 rounded-full focus-within:ring-2 focus-within:ring-slate-300"
title={currentConversationTitle}
>
{conversations.length ? (
conversations.map((conversation) => (
<option
key={conversation.conversationId}
value={conversation.conversationId}
>
{conversation.title}
</option>
))
) : (
<option value=""></option>
)}
</select>
<div
className={`pointer-events-none flex h-9 w-full items-center rounded-full border border-slate-200 bg-white px-3 pr-10 text-sm ${
isConversationSelectDisabled
? 'text-slate-400'
: 'text-slate-800'
}`}
aria-hidden="true"
>
<span
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
data-testid="active-conversation-title"
>
{currentConversationTitle}
</span>
<ChevronDown
className="absolute right-[10px] top-1/2 h-4 w-4 -translate-y-1/2"
data-testid="conversation-switch-chevron"
aria-hidden="true"
/>
</div>
<select
className="absolute inset-0 z-10 h-full w-full cursor-pointer appearance-none opacity-0 disabled:cursor-not-allowed"
aria-label="当前对话"
value={activeConversationId ?? ''}
disabled={isConversationSelectDisabled}
onChange={(event) => {
const nextConversationId = event.currentTarget.value;
if (nextConversationId) {
void selectConversation(nextConversationId);
}
}}
>
{conversations.length ? (
conversations.map((conversation) => (
<option
key={conversation.conversationId}
value={conversation.conversationId}
>
{conversation.title}
</option>
))
) : (
<option value=""></option>
)}
</select>
</div>
<button
type="button"
className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-white disabled:opacity-45"
aria-label="新建对话"
disabled={
!hasProject || isCreatingConversation || isConversationBusy
!hasProject ||
!hasCurrentConversationContent ||
isLoadingConversations ||
isLoadingMessages ||
isCreatingConversation ||
isConversationBusy ||
isPastingAttachment
}
onClick={() => void createConversation()}
onClick={handleCreateConversation}
>
<Plus className="h-4 w-4" aria-hidden="true" />
</button>
@@ -312,28 +358,17 @@ export function EditorAgentConversationPanelView({
>
<Paperclip className="h-4 w-4" aria-hidden="true" />
</button>
<textarea
className="max-h-32 min-h-10 flex-1 resize-none overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
aria-label="发送给画布 Agent"
<EditorAgentDraftTextarea
value={draftText}
rows={1}
onChange={(event) => setDraftText(event.currentTarget.value)}
onChange={setDraftText}
onPaste={handleInputPaste}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<button
type="submit"
className="inline-flex h-10 min-w-16 shrink-0 items-center justify-center gap-1.5 rounded-full bg-slate-900 px-3 text-sm font-semibold text-white disabled:opacity-45"
disabled={
isWaiting ||
isToolCallActionPending ||
(!draftText.trim() && !attachments.length) ||
!hasProject
isMessageSubmissionBlocked ||
(!draftText.trim() && !attachments.length)
}
>
<Send className="h-3.5 w-3.5" aria-hidden="true" />
@@ -0,0 +1,132 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { FormEvent } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('EditorAgentDraftTextarea', () => {
it('uses native field sizing when the browser supports it', () => {
const supports = vi.fn().mockReturnValue(true);
const resizeObserver = vi.fn();
const onChange = vi.fn();
vi.stubGlobal('CSS', { supports });
vi.stubGlobal('ResizeObserver', resizeObserver);
render(<EditorAgentDraftTextarea value="" onChange={onChange} />);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
expect(resizeObserver).not.toHaveBeenCalled();
expect(input.className).toContain('[field-sizing:content]');
expect(input.className).toContain('min-h-10');
expect(input.className).toContain('max-h-32');
expect(input.className).toContain('overflow-y-auto');
expect(input.style.height).toBe('');
fireEvent.change(input, { target: { value: '新草稿' } });
expect(onChange).toHaveBeenCalledWith('新草稿');
});
it('falls back to measured sizing and remeasures when width changes', () => {
let resizeObserverCallback!: ResizeObserverCallback;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
vi.stubGlobal(
'ResizeObserver',
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
resizeObserverCallback = callback;
return {
observe,
unobserve: vi.fn(),
disconnect,
};
}),
);
const { rerender, unmount } = render(
<EditorAgentDraftTextarea value="" onChange={vi.fn()} />,
);
const input = screen.getByLabelText(
'发送给画布 Agent',
) as HTMLTextAreaElement;
expect(observe).toHaveBeenCalledWith(input);
let contentHeight = 92;
Object.defineProperty(input, 'offsetHeight', {
configurable: true,
get: () => {
const styleHeight = Number.parseFloat(input.style.height);
return Number.isNaN(styleHeight) ? 42 : styleHeight;
},
});
Object.defineProperty(input, 'clientHeight', {
configurable: true,
get: () => input.offsetHeight - 2,
});
Object.defineProperty(input, 'scrollHeight', {
configurable: true,
get: () => contentHeight,
});
rerender(
<EditorAgentDraftTextarea
value="第一行\n第二行\n第三行\n第四行\n第五行"
onChange={vi.fn()}
/>,
);
expect(input.style.height).toBe('94px');
expect(input.style.overflowY).toBe('hidden');
expect(input.scrollHeight).toBeLessThanOrEqual(input.clientHeight);
contentHeight = 180;
act(() => {
resizeObserverCallback(
[
{
target: input,
contentRect: { width: 180 },
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver,
);
});
expect(input.style.height).toBe('128px');
expect(input.style.overflowY).toBe('auto');
unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
});
render(
<form onSubmit={onSubmit}>
<EditorAgentDraftTextarea value="草稿" onChange={vi.fn()} />
</form>,
);
const input = screen.getByLabelText('发送给画布 Agent');
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(input, {
key: 'Enter',
isComposing: false,
keyCode: 229,
});
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.keyDown(input, { key: 'Enter' });
expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,113 @@
import {
type ClipboardEventHandler,
type KeyboardEvent,
useLayoutEffect,
useRef,
} from 'react';
const DRAFT_MIN_HEIGHT_PX = 40;
const DRAFT_MAX_HEIGHT_PX = 128;
// for fallback on some browser
function supportsNativeFieldSizing() {
return (
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('field-sizing', 'content')
);
}
function resizeFallbackTextarea(textarea: HTMLTextAreaElement) {
textarea.style.height = 'auto';
const contentHeight = textarea.scrollHeight;
const borderHeight = Math.max(
0,
textarea.offsetHeight - textarea.clientHeight,
);
const borderBoxContentHeight = contentHeight + borderHeight;
const nextHeight = Math.min(
Math.max(borderBoxContentHeight, DRAFT_MIN_HEIGHT_PX),
DRAFT_MAX_HEIGHT_PX,
);
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY =
borderBoxContentHeight > DRAFT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
}
export function EditorAgentDraftTextarea({
value,
onChange,
onPaste,
}: {
value: string;
onChange: (value: string) => void;
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return;
}
resizeFallbackTextarea(textarea);
}, [value]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || supportsNativeFieldSizing()) {
return undefined;
}
let previousWidth = textarea.getBoundingClientRect().width;
const resizeWhenWidthChanges = (nextWidth: number) => {
if (Math.abs(nextWidth - previousWidth) < 0.5) {
return;
}
previousWidth = nextWidth;
resizeFallbackTextarea(textarea);
};
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
resizeWhenWidthChanges(entry.contentRect.width);
}
});
observer.observe(textarea);
return () => observer.disconnect();
}
const handleWindowResize = () => {
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
};
window.addEventListener('resize', handleWindowResize);
return () => window.removeEventListener('resize', handleWindowResize);
}, []);
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.nativeEvent.isComposing &&
event.nativeEvent.keyCode !== 229
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
};
return (
<textarea
ref={textareaRef}
className="editor-agent-conversation__draft-input max-h-32 min-h-10 min-w-0 flex-1 resize-none overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none [field-sizing:content] focus:border-slate-400"
aria-label="发送给画布 Agent"
value={value}
rows={1}
onChange={(event) => onChange(event.currentTarget.value)}
onPaste={onPaste}
onKeyDown={handleKeyDown}
/>
);
}
@@ -265,6 +265,268 @@ describe('useEditorAgentConversation', () => {
expect(result.current.isLoadingMessages).toBe(false);
});
it('does not let a refresh supersede an in-flight conversation activation', async () => {
const client = createClient();
let resolveActivation!: (detail: EditorAgentConversationDetail) => void;
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
const activationPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveActivation = resolve;
},
);
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = resolve;
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversationId).toBe('conversation-1');
});
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
if (conversationId === 'conversation-2') {
return activationPromise;
}
if (conversationId === 'conversation-1') {
return refreshPromise;
}
throw new Error(`Unexpected conversation: ${conversationId}`);
});
let pendingActivation!: Promise<EditorAgentConversationDetail | null>;
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
act(() => {
pendingActivation = result.current.selectConversation('conversation-2');
pendingRefresh = result.current.refreshActiveConversation();
});
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧会话刷新结果',
messages: [
{
id: 10,
role: 'assistant',
text: '旧会话刷新消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-1');
expect(result.current.messages).toEqual([]);
await act(async () => {
resolveActivation({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '第二个会话',
messages: [
{
id: 20,
role: 'assistant',
text: '第二个会话消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:03:00.000Z',
},
],
createdAt: '2026-07-03T00:03:00.000Z',
updatedAt: '2026-07-03T00:03:00.000Z',
});
await pendingActivation;
});
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages[0]?.text).toBe('第二个会话消息');
expect(result.current.isLoadingMessages).toBe(false);
});
it('keeps the latest activation loading while an older suppressed refresh finishes', async () => {
const client = createClient();
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
let resolveConversation3!: (detail: EditorAgentConversationDetail) => void;
const conversation2Promise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveConversation2 = resolve;
},
);
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = resolve;
},
);
const conversation3Promise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveConversation3 = resolve;
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversationId).toBe('conversation-1');
});
vi.mocked(client.getConversation).mockImplementation((conversationId) => {
if (conversationId === 'conversation-2') {
return conversation2Promise;
}
if (conversationId === 'conversation-1') {
return refreshPromise;
}
if (conversationId === 'conversation-3') {
return conversation3Promise;
}
throw new Error(`Unexpected conversation: ${conversationId}`);
});
let conversation2Load!: Promise<EditorAgentConversationDetail | null>;
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
let conversation3Load!: Promise<EditorAgentConversationDetail | null>;
act(() => {
conversation2Load = result.current.selectConversation('conversation-2');
pendingRefresh = result.current.refreshActiveConversation();
conversation3Load = result.current.selectConversation('conversation-3');
});
expect(result.current.isLoadingMessages).toBe(true);
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧会话刷新结果',
messages: [],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-1');
expect(result.current.isLoadingMessages).toBe(true);
await act(async () => {
await result.current.sendMessage('切换期间不应发送');
});
expect(client.sendMessage).not.toHaveBeenCalled();
await act(async () => {
resolveConversation3({
conversationId: 'conversation-3',
projectId: 'project-1',
title: '第三个会话',
messages: [
{
id: 30,
role: 'assistant',
text: '第三个会话消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:03:00.000Z',
},
],
createdAt: '2026-07-03T00:03:00.000Z',
updatedAt: '2026-07-03T00:03:00.000Z',
});
await conversation3Load;
});
expect(result.current.activeConversationId).toBe('conversation-3');
expect(result.current.isLoadingMessages).toBe(false);
await act(async () => {
resolveConversation2({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '第二个会话',
messages: [],
createdAt: '2026-07-03T00:02:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await conversation2Load;
});
expect(result.current.activeConversationId).toBe('conversation-3');
});
it('does not let a silent refresh replace a newly created conversation', async () => {
const client = createClient();
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
const refreshPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveRefresh = resolve;
},
);
const createPromise = new Promise<EditorAgentConversationDetail>(
(resolve) => {
resolveCreate = resolve;
},
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversationId).toBe('conversation-1');
});
vi.mocked(client.getConversation).mockReturnValueOnce(refreshPromise);
vi.mocked(client.createConversation).mockReturnValueOnce(createPromise);
let pendingRefresh!: Promise<EditorAgentConversationDetail | null>;
let pendingCreate!: Promise<EditorAgentConversationDetail>;
act(() => {
pendingRefresh = result.current.refreshActiveConversation();
pendingCreate = result.current.createConversation();
});
await act(async () => {
resolveCreate({
conversationId: 'conversation-2',
projectId: 'project-1',
title: '新对话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await pendingCreate;
});
expect(result.current.activeConversationId).toBe('conversation-2');
await act(async () => {
resolveRefresh({
conversationId: 'conversation-1',
projectId: 'project-1',
title: '旧对话刷新结果',
messages: [
{
id: 10,
role: 'assistant',
text: '旧会话消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:00:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
await pendingRefresh;
});
expect(result.current.activeConversationId).toBe('conversation-2');
expect(result.current.messages).toEqual([]);
});
it('appends a lazily reconciled tool message delta', async () => {
const client = createClient();
const pendingMessage: EditorAgentMessage = {
@@ -158,7 +158,9 @@ export function useEditorAgentConversation({
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const normalizedProjectIdRef = useRef(normalizedProjectId);
const activeConversationIdRef = useRef<string | null>(null);
const pendingActivationConversationIdRef = useRef<string | null>(null);
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
const conversationLoadRequestSequenceRef = useRef(0);
const conversationLoadRequestIdRef = useRef(0);
const createConversationRequestIdRef = useRef(0);
const isWaitingRef = useRef(false);
@@ -224,28 +226,57 @@ export function useEditorAgentConversation({
);
const loadConversation = useCallback(
async (conversationId: string, options: { showLoading?: boolean } = {}) => {
const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId;
async (
conversationId: string,
options: {
// `activate` may make conversationId active; `refresh` may apply only
// while that same conversationId is still active.
mode: 'activate' | 'refresh';
showLoading?: boolean;
},
) => {
const requestId = conversationLoadRequestSequenceRef.current + 1;
conversationLoadRequestSequenceRef.current = requestId;
const maySupersedeCurrentLoad =
options.mode === 'activate' ||
pendingActivationConversationIdRef.current === null;
if (maySupersedeCurrentLoad) {
conversationLoadRequestIdRef.current = requestId;
}
if (options.mode === 'activate') {
pendingActivationConversationIdRef.current = conversationId;
}
const showLoading = options.showLoading ?? true;
if (showLoading) {
const canApplyResult = () =>
conversationLoadRequestIdRef.current === requestId &&
(options.mode === 'activate' ||
(pendingActivationConversationIdRef.current === null &&
activeConversationIdRef.current === conversationId));
if (showLoading && maySupersedeCurrentLoad) {
setIsLoadingMessages(true);
}
setErrorMessage(null);
try {
const detail = await client.getConversation(conversationId);
if (conversationLoadRequestIdRef.current === requestId) {
if (canApplyResult()) {
applyConversationDetail(detail);
}
return detail;
} catch (error) {
if (conversationLoadRequestIdRef.current === requestId) {
if (canApplyResult()) {
setErrorMessage(
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
);
}
throw error;
} finally {
if (
options.mode === 'activate' &&
conversationLoadRequestIdRef.current === requestId &&
pendingActivationConversationIdRef.current === conversationId
) {
pendingActivationConversationIdRef.current = null;
}
if (conversationLoadRequestIdRef.current === requestId) {
setIsLoadingMessages(false);
}
@@ -255,7 +286,11 @@ export function useEditorAgentConversation({
);
useEffect(() => {
conversationLoadRequestIdRef.current += 1;
const invalidationRequestId =
conversationLoadRequestSequenceRef.current + 1;
conversationLoadRequestSequenceRef.current = invalidationRequestId;
conversationLoadRequestIdRef.current = invalidationRequestId;
pendingActivationConversationIdRef.current = null;
setIsLoadingMessages(false);
if (!normalizedProjectId) {
setConversations([]);
@@ -326,6 +361,12 @@ export function useEditorAgentConversation({
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
const invalidationRequestId =
conversationLoadRequestSequenceRef.current + 1;
conversationLoadRequestSequenceRef.current = invalidationRequestId;
conversationLoadRequestIdRef.current = invalidationRequestId;
pendingActivationConversationIdRef.current = null;
setIsLoadingMessages(false);
applyConversationDetail(detail);
}
return detail;
@@ -354,7 +395,7 @@ export function useEditorAgentConversation({
if (conversationId === activeConversationId) {
return null;
}
return loadConversation(conversationId);
return loadConversation(conversationId, { mode: 'activate' });
},
[activeConversationId, loadConversation],
);
@@ -364,7 +405,10 @@ export function useEditorAgentConversation({
if (!conversationId) {
return null;
}
return loadConversation(conversationId, { showLoading: false });
return loadConversation(conversationId, {
mode: 'refresh',
showLoading: false,
});
}, [loadConversation]);
const requestCanvasRefreshForMessages = useCallback(
@@ -374,9 +418,9 @@ export function useEditorAgentConversation({
const toolCall = message.toolCall;
return Boolean(
toolCall?.externalJobId &&
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),
);
})
) {
@@ -541,6 +585,7 @@ export function useEditorAgentConversation({
}
const detail = await loadConversation(conversationId, {
mode: 'refresh',
showLoading: false,
});
const updatedMessage = detail.messages.find(
@@ -596,7 +641,9 @@ export function useEditorAgentConversation({
setConversations(remainingConversations);
const nextConversation = remainingConversations[0] ?? null;
if (nextConversation) {
await loadConversation(nextConversation.conversationId);
await loadConversation(nextConversation.conversationId, {
mode: 'activate',
});
return;
}
activeConversationIdRef.current = null;
+25
View File
@@ -16708,6 +16708,31 @@ button {
background: #3f3f46;
}
.editor-agent-conversation__draft-input {
scrollbar-color: #cbd5e1 transparent;
scrollbar-width: thin;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar {
width: 6px;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-track {
margin-block: 10px;
background-color: transparent;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-thumb {
border: 1px solid transparent;
border-radius: 999px;
background-color: #cbd5e1;
background-clip: padding-box;
}
.editor-agent-conversation__draft-input::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.image-canvas-editor__metadata-dialog.platform-modal-shell {
border-color: rgba(226, 232, 240, 0.92) !important;
background: #ffffff !important;