mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): keep the side-chat draft when a send fails (#6407)
* fix(frontend): keep the side-chat draft when a send fails PromptInput clears the composer only when onSubmit resolves and keeps it on rejection. The side chat's handleSubmit caught every failure, showed the toast and resolved, so a failed side-chat creation or attachment upload lost the typed text and attachments. The first message to a new side chat was also cleared as soon as it was queued, before the queued effect sent it, so a failure there could not be retried either. handleSubmit now rethrows after its toast, and the queued first send carries the submit's resolve/reject so the composer settles with that send's outcome. The queued effect no longer toasts on its own, so a failed first send shows one error instead of two. * docs(changelog): reference #6407 --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
35cdcab1c5
commit
4217b30e3b
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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<void>((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;
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@ -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 (
|
||||
<I18nProvider initialLocale={DEFAULT_LOCALE}>
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
}
|
||||
>
|
||||
<AuthProvider initialUser={USER}>
|
||||
<ThreadContext.Provider
|
||||
value={{ thread: { messages: [] } as never, isMock: false }}
|
||||
>
|
||||
<SidecarProvider
|
||||
parentThreadId="parent-1"
|
||||
isMock={false}
|
||||
context={{ thread_id: "parent-1" } as never}
|
||||
>
|
||||
<Setup threadId={threadId} />
|
||||
<SidecarPanel />
|
||||
</SidecarProvider>
|
||||
</ThreadContext.Provider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function submitDraft(text: string) {
|
||||
const composer = await screen.findByRole<HTMLTextAreaElement>("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<void>((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();
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user