diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 2526044dd..145305da9 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -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 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。 diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 576ce756b..77cf831f6 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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((resolve) => { + resolveCreate = resolve; + }), + ); + + render( + , + ); + + 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((resolve) => { + resolveConversationLoad = resolve; + }), + ); + + render( + , + ); + + 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; diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 17a43090c..8d52c8c51 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -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) => { event.preventDefault(); - if (isWaiting) { - return; - } - if (isPastingAttachment) { + if (isMessageSubmissionBlocked) { return; } const text = draftText.trim(); @@ -164,43 +183,70 @@ export function EditorAgentConversationPanelView({ > - { - const nextConversationId = event.currentTarget.value; - if (nextConversationId) { - void selectConversation(nextConversationId); - } - }} + - {conversations.length ? ( - conversations.map((conversation) => ( - - {conversation.title} - - )) - ) : ( - 新对话 - )} - + + + {currentConversationTitle} + + + + { + const nextConversationId = event.currentTarget.value; + if (nextConversationId) { + void selectConversation(nextConversationId); + } + }} + > + {conversations.length ? ( + conversations.map((conversation) => ( + + {conversation.title} + + )) + ) : ( + 新对话 + )} + + void createConversation()} + onClick={handleCreateConversation} > @@ -312,28 +358,17 @@ export function EditorAgentConversationPanelView({ > - setDraftText(event.currentTarget.value)} + onChange={setDraftText} onPaste={handleInputPaste} - onKeyDown={(event) => { - if (event.key === 'Enter' && !event.shiftKey) { - event.preventDefault(); - event.currentTarget.form?.requestSubmit(); - } - }} /> diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.test.tsx new file mode 100644 index 000000000..717718fc5 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.test.tsx @@ -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(); + + 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( + , + ); + 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( + , + ); + 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) => { + event.preventDefault(); + }); + render( + + + , + ); + + 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); + }); +}); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx new file mode 100644 index 000000000..74d57d75c --- /dev/null +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentDraftTextarea.tsx @@ -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; +}) { + const textareaRef = useRef(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) => { + if ( + event.key === 'Enter' && + !event.shiftKey && + !event.nativeEvent.isComposing && + event.nativeEvent.keyCode !== 229 + ) { + event.preventDefault(); + event.currentTarget.form?.requestSubmit(); + } + }; + + return ( + onChange(event.currentTarget.value)} + onPaste={onPaste} + onKeyDown={handleKeyDown} + /> + ); +} diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index d50e29f2a..03ff1b3c8 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -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( + (resolve) => { + resolveActivation = resolve; + }, + ); + const refreshPromise = new Promise( + (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; + let pendingRefresh!: Promise; + 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( + (resolve) => { + resolveConversation2 = resolve; + }, + ); + const refreshPromise = new Promise( + (resolve) => { + resolveRefresh = resolve; + }, + ); + const conversation3Promise = new Promise( + (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; + let pendingRefresh!: Promise; + let conversation3Load!: Promise; + 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( + (resolve) => { + resolveRefresh = resolve; + }, + ); + const createPromise = new Promise( + (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; + let pendingCreate!: Promise; + 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 = { diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 765ba77bf..e8f610dd5 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -158,7 +158,9 @@ export function useEditorAgentConversation({ const [errorMessage, setErrorMessage] = useState(null); const normalizedProjectIdRef = useRef(normalizedProjectId); const activeConversationIdRef = useRef(null); + const pendingActivationConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(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; diff --git a/src/index.css b/src/index.css index 7941510e9..6c057a37b 100644 --- a/src/index.css +++ b/src/index.css @@ -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;