Merge branch 'master' into editor-agent-more-tools
This commit is contained in:
@@ -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 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
|
||||
|
||||
|
||||
+269
-4
@@ -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;
|
||||
|
||||
+84
-49
@@ -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" />
|
||||
|
||||
+132
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+262
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user