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:
Hyeonsang Cho 2026-10-07 15:09:57 +09:00 committed by GitHub
parent 35cdcab1c5
commit 4217b30e3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 246 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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;
}
},
[

View File

@ -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();
});
});