diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df6c0e6d3..099e0052d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -804,6 +804,14 @@ This release closes that milestone with **439 merged pull requests**. ### Fixed +- **frontend:** A failed side-chat send no longer clears the composer. The side + chat's submit handler showed the error toast and then resolved, which the + composer treats as success, so the typed text and attachments were lost when + creating the side chat or uploading an attachment failed. The first message to + a new side chat was also cleared as soon as it was queued, before it was sent. + The handler now rejects after the toast, and the queued first send settles the + submit with its own outcome, so the draft stays for a retry and clears only + once the message is sent. ([#6407]) - **frontend:** A failed reconnect after a page refresh is now retried in the same tab. The SDK reconnects once from the tab's `lg:stream` pointer and keeps that pointer on error, and active-run recovery skipped any run with a matching @@ -9100,3 +9108,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6393]: https://github.com/bytedance/deer-flow/pull/6393 [#6400]: https://github.com/bytedance/deer-flow/pull/6400 [#6401]: https://github.com/bytedance/deer-flow/pull/6401 +[#6407]: https://github.com/bytedance/deer-flow/pull/6407 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 0c696a73f2..3d2e3d1c7d 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -694,6 +694,10 @@ ### 修复 +- **前端:** 侧边对话发送失败时不再清空输入框。侧边对话的提交处理在弹出错误提示后仍以成功返回,输入框据此视为成功, + 因此在创建侧边对话或上传附件失败时,已输入的文字和附件都会丢失;发往新侧边对话的第一条消息也会在排队时(实际发送前) + 就被清空。现在提交处理会在提示后抛出错误,排队的首条发送也以其自身结果完成提交,因此草稿会保留以便重试,只有消息 + 实际发出后才会清空。([#6407]) - **前端:** 刷新页面后的首次重连失败时,现在会在同一标签页中重试。SDK 只根据标签页的 `lg:stream` 指针重连一次,出错时保留该指针;而活动运行恢复会跳过指针匹配的运行, 因此实时流在再次刷新前一直无法恢复。现在该重连失败时(包括流中途断开),恢复逻辑会释放指针; @@ -7486,3 +7490,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6393]: https://github.com/bytedance/deer-flow/pull/6393 [#6400]: https://github.com/bytedance/deer-flow/pull/6400 [#6401]: https://github.com/bytedance/deer-flow/pull/6401 +[#6407]: https://github.com/bytedance/deer-flow/pull/6407 diff --git a/frontend/src/AGENTS.md b/frontend/src/AGENTS.md index 9e5336140d..db050006b0 100644 --- a/frontend/src/AGENTS.md +++ b/frontend/src/AGENTS.md @@ -141,7 +141,7 @@ Run duration is run-scoped UI metadata even though the compatibility field `addi The workspace-change card follows the same rule: it is resolved from `(threadId, runId)` alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so `core/messages/workspace-change-anchor.ts` picks the run's last assistant bubble and `MessageListItem` renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by `MessageList` around every group, so it accepts any type, while the workspace-change card comes from `MessageListItem` and so restricts to `assistant`. Keep a new helper's candidate set matched to its own render site rather than unifying them. -Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores text with inline reference tokens and conversation metadata in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write `skillName: null`; `InputBox` waits for the enabled agent skill scope before converting an older draft selection into an inline `@` reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. +Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores text with inline reference tokens and conversation metadata in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. New drafts write `skillName: null`; `InputBox` waits for the enabled agent skill scope before converting an older draft selection into an inline `@` reference, and degrades a missing/disabled skill back to editable slash text. The stored draft clears through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted. `PromptInput` clears only when `onSubmit` resolves, so the sidecar's `handleSubmit` rethrows a failed send after its toast, and its first send to a new side chat (queued until the thread reaches the stream hook) settles the submit with that send's outcome. Conversation references (`read_conversation`, opt-in on the backend) are attached from the composer. `ReferenceConversationsButton` (`components/workspace/conversation-references/`) renders only while `/api/features` reports `conversation_references.enabled`, opens a picker over the same `useThreads()` list the sidebar uses (the current thread excluded, capped at `max_references`), and shows removable chips in the composer header. On submit the thread IDs ride `InputBoxSubmitOptions.conversationReferences` → `SendMessageOptions.conversationReferences` → run `context.conversation_references`, which the Gateway consumes at admission; the LangGraph SDK drops unknown top-level body fields, so the top-level request field is not reachable from the web UI. `core/conversation-references` also writes display-only `additional_kwargs.conversation_references` (`{thread_id, title, agent_name?}`) on the visible human message so `message-list-item.tsx` can render read-only chips linking to the source — through `pathOfThread`, so custom-agent sources route to `/workspace/agents/{agent}/chats/{id}`; that metadata grants nothing. References are per message: they are not persisted with the draft, clear on send or thread switch, and regenerate/edit of a turn runs without them unless attached again. diff --git a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx index 6efac7e01f..8d77c3607a 100644 --- a/frontend/src/components/workspace/sidecar/sidecar-panel.tsx +++ b/frontend/src/components/workspace/sidecar/sidecar-panel.tsx @@ -142,6 +142,9 @@ export function SidecarPanel({ className }: { className?: string }) { const [queuedSubmit, setQueuedSubmit] = useState<{ message: PromptInputMessage; references: SidecarReference[]; + // Settles the composer's submit with the queued send's outcome. + resolve: () => void; + reject: (error: unknown) => void; } | null>(null); const { data: uploadLimits } = useUploadLimits( sidecar.sidecarThreadId ?? sidecar.parentThreadId, @@ -408,17 +411,12 @@ export function SidecarPanel({ className }: { className?: string }) { sidecar.clearActiveReferences(); } }, - ).catch((error) => { - toast.error( - error instanceof Error ? error.message : t.sidecar.sendFailed, - ); - }); + ).then(nextSubmit.resolve, nextSubmit.reject); }, [ queuedSubmit, sidecar, sidecar.sidecarThreadId, submitToSidecarThread, - t.sidecar.sendFailed, thread.isLoading, ]); @@ -439,7 +437,16 @@ export function SidecarPanel({ className }: { className?: string }) { try { if (!sidecar.sidecarThreadId) { await ensureSidecarThread(pendingReferences); - setQueuedSubmit({ message, references: pendingReferences }); + // The first send waits for the new thread to reach the stream hook. + // Settle with that send so the composer clears only once it is sent. + await new Promise((resolve, reject) => { + setQueuedSubmit({ + message, + references: pendingReferences, + resolve, + reject, + }); + }); return; } @@ -457,6 +464,8 @@ export function SidecarPanel({ className }: { className?: string }) { toast.error( error instanceof Error ? error.message : t.sidecar.sendFailed, ); + // Reject so PromptInput keeps the draft and attachments for a retry. + throw error; } }, [ diff --git a/frontend/tests/unit/components/workspace/sidecar-submit-failure.dom.test.tsx b/frontend/tests/unit/components/workspace/sidecar-submit-failure.dom.test.tsx new file mode 100644 index 0000000000..2523f31e1a --- /dev/null +++ b/frontend/tests/unit/components/workspace/sidecar-submit-failure.dom.test.tsx @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { useEffect, type ReactNode } from "react"; + +import { ThreadContext } from "@/components/workspace/messages/context"; +import { + SidecarProvider, + useSidecar, +} from "@/components/workspace/sidecar/context"; +import { SidecarPanel } from "@/components/workspace/sidecar/sidecar-panel"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; +import { DEFAULT_LOCALE } from "@/core/i18n"; +import { I18nProvider } from "@/core/i18n/context"; + +// PromptInput clears the composer only when onSubmit resolves, so these tests +// drive the real panel and composer and control just the send outcome. + +const mocks = rs.hoisted(() => ({ + createSidecarThread: rs.fn(), + sendMessage: rs.fn(), + toastError: rs.fn(), +})); + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), + usePathname: () => "/workspace", + useSearchParams: () => new URLSearchParams(), +})); + +rs.mock("sonner", () => ({ + toast: Object.assign(rs.fn(), { error: mocks.toastError }), +})); + +rs.mock("@/core/models/hooks", () => ({ + useModels: () => ({ + models: [], + tokenUsageEnabled: false, + isLoading: false, + isFetching: false, + error: null, + refetch: rs.fn(), + }), +})); + +rs.mock("@/core/sidecar/api", () => ({ + createSidecarThread: mocks.createSidecarThread, + findLatestSidecarThread: async () => null, +})); + +rs.mock("@/core/threads/hooks", () => ({ + useDeleteThread: () => ({ mutateAsync: rs.fn(), isPending: false }), + useThreadStream: () => ({ + thread: { isLoading: false, messages: [], values: {} }, + sendMessage: mocks.sendMessage, + isUploading: false, + isHistoryLoading: false, + hasMoreHistory: false, + loadMoreHistory: rs.fn(), + }), +})); + +const USER = { + id: "user-1", + email: "user@example.test", + system_role: "user", + needs_setup: false, + oauth_provider: null, +} as User; + +function Setup({ threadId }: { threadId: string | null }) { + const sidecar = useSidecar(); + useEffect(() => { + if (threadId) { + sidecar.setSidecarThreadId(threadId); + } + sidecar.openContext({ + type: "referenced_message", + label: "Earlier answer", + role: "assistant", + content: "Earlier answer", + }); + // Run once: the setters are stable for the provider's lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +function renderPanel(threadId: string | null): ReactNode { + return ( + + + + + + + + + + + + + ); +} + +async function submitDraft(text: string) { + const composer = await screen.findByRole("textbox"); + await waitFor(() => expect(composer.disabled).toBe(false)); + fireEvent.change(composer, { target: { value: text } }); + await act(async () => { + fireEvent.submit(composer.closest("form")!); + }); + return composer; +} + +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); +} + +beforeEach(() => { + mocks.createSidecarThread.mockReset(); + mocks.sendMessage.mockReset(); + mocks.toastError.mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("SidecarPanel keeps the draft when a send fails", () => { + it("keeps the draft when creating the side chat fails", async () => { + mocks.createSidecarThread.mockRejectedValue(new Error("create failed")); + render(renderPanel(null)); + + const composer = await submitDraft("my question"); + await waitFor(() => + expect(mocks.createSidecarThread).toHaveBeenCalledTimes(1), + ); + await settle(); + + expect(mocks.toastError).toHaveBeenCalledTimes(1); + expect(mocks.toastError).toHaveBeenCalledWith("create failed"); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + expect(composer.value).toBe("my question"); + }); + + it("keeps the draft when sending to an existing side chat fails", async () => { + mocks.sendMessage.mockRejectedValue(new Error("upload failed")); + render(renderPanel("sidecar-1")); + + const composer = await submitDraft("my question"); + await waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(1)); + await settle(); + + expect(mocks.toastError).toHaveBeenCalledTimes(1); + expect(mocks.toastError).toHaveBeenCalledWith("upload failed"); + expect(composer.value).toBe("my question"); + }); + + it("keeps the draft when the first send after creating the side chat fails", async () => { + mocks.createSidecarThread.mockResolvedValue({ thread_id: "sidecar-new" }); + mocks.sendMessage.mockRejectedValue(new Error("upload failed")); + render(renderPanel(null)); + + const composer = await submitDraft("my question"); + await waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(1)); + expect(mocks.sendMessage.mock.calls[0]?.[0]).toBe("sidecar-new"); + await settle(); + + expect(mocks.toastError).toHaveBeenCalledTimes(1); + expect(mocks.toastError).toHaveBeenCalledWith("upload failed"); + expect(composer.value).toBe("my question"); + }); + + it("clears the draft only after the first send to a new side chat finishes", async () => { + let finishSend!: () => void; + mocks.createSidecarThread.mockResolvedValue({ thread_id: "sidecar-new" }); + mocks.sendMessage.mockImplementation( + () => + new Promise((resolve) => { + finishSend = resolve; + }), + ); + render(renderPanel(null)); + + const composer = await submitDraft("my question"); + await waitFor(() => expect(mocks.sendMessage).toHaveBeenCalledTimes(1)); + await settle(); + expect(composer.value).toBe("my question"); + + await act(async () => finishSend()); + await waitFor(() => expect(composer.value).toBe("")); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); +});