From c8d5c108f326fbc59c7063417d29f8fe8d7c60dc Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Wed, 11 Nov 2026 14:06:19 +0900 Subject: [PATCH] fix(frontend): copy share links on the current origin (#8126) * fix(frontend): copy share links on the current origin The sidebar Share action rewrote the origin to the hosted demo (https://deer-flow-v2.vercel.app) whenever the page ran on localhost or 127.0.0.1, which are the default make dev and Docker entry points. The copied link never opened the thread, because the thread only exists on the local deployment, and opening it sent the thread id (and a custom agent name) to a host the operator does not control. Always build the link from window.location.origin and pathOfThread(). * docs(changelog): reference #8126 for the share link origin fix --- CHANGELOG.md | 8 ++ CHANGELOG_zh.md | 6 + .../components/workspace/recent-chat-list.tsx | 12 +- .../thread-sidebar-item-share.dom.test.tsx | 114 ++++++++++++++++++ 4 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/thread-sidebar-item-share.dom.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d1bab5dba3..cf1427ecf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,13 @@ This section accumulates work toward the **2.3.0** milestone ### Fixed +- **frontend:** **Share** in a sidebar chat's menu now copies a link to the + deployment you are using. On `localhost` and `127.0.0.1`, the default + `make dev` and Docker addresses, it copied a link to the hosted demo + (`deer-flow-v2.vercel.app`) instead. That link never opened the + conversation, and opening it sent the thread id to an outside host. + ([#8126]) + - **runtime:** In multi-worker mode (`run_ownership.heartbeat_enabled`), a worker that had lost its run to a peer could put the row back. Its assembly evidence write was an unconditional full-row upsert, so after a peer @@ -16291,3 +16298,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#8069]: https://github.com/bytedance/deer-flow/issues/8069 [#8071]: https://github.com/bytedance/deer-flow/issues/8071 [#8072]: https://github.com/bytedance/deer-flow/pull/8072 +[#8126]: https://github.com/bytedance/deer-flow/pull/8126 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index a43cab4f29..eae7f8f393 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -26,6 +26,11 @@ ### 修复 +- **前端:** 侧边栏对话菜单中的 **分享** 现在复制指向当前部署的链接。此前在 `localhost` 和 + `127.0.0.1`(`make dev` 与 Docker 的默认地址)上,复制的是托管演示站 + (`deer-flow-v2.vercel.app`)的链接:该链接无法打开对话,打开时还会把线程 ID 发送到外部主机。 + ([#8126]) + - **运行时:** 多 Worker 模式(`run_ownership.heartbeat_enabled`)下,已被其他 Worker 接管的运行不再被原 Worker 恢复。此前装配证据通过无条件的整行 upsert 写入,在对端接管后会把 行改回 `running` 并清除接管错误;运行可能在对端已报告失败后以 `success` 结束,或被再次接管。 @@ -12862,3 +12867,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#8043]: https://github.com/bytedance/deer-flow/pull/8043 [#8046]: https://github.com/bytedance/deer-flow/pull/8046 [#8064]: https://github.com/bytedance/deer-flow/pull/8064 +[#8126]: https://github.com/bytedance/deer-flow/pull/8126 diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 4e4192c587..99e98e348c 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -178,14 +178,10 @@ export function ThreadSidebarItem({ }, [t.chats.pinChatFailed, thread, updatePinnedThread]); const handleShare = useCallback(async () => { - // Always use Vercel URL for sharing so others can access - const VERCEL_URL = "https://deer-flow-v2.vercel.app"; - const isLocalhost = - window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1"; - // On localhost: use Vercel URL; On production: use current origin - const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin; - const shareUrl = `${baseUrl}${pathOfThread(thread)}`; + // Link to this deployment, the only place the thread exists. Never + // rewrite the origin to another host: the thread id would leave the + // deployment and the link would not open the thread. + const shareUrl = `${window.location.origin}${pathOfThread(thread)}`; try { const didCopy = await writeTextToClipboard(shareUrl); if (!didCopy) { diff --git a/frontend/tests/unit/components/workspace/thread-sidebar-item-share.dom.test.tsx b/frontend/tests/unit/components/workspace/thread-sidebar-item-share.dom.test.tsx new file mode 100644 index 0000000000..938197e901 --- /dev/null +++ b/frontend/tests/unit/components/workspace/thread-sidebar-item-share.dom.test.tsx @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +rs.mock("next/navigation", () => ({ + useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }), + usePathname: () => "/workspace", + useSearchParams: () => new URLSearchParams(), + useParams: () => ({}), +})); +rs.mock("@/core/clipboard", () => ({ writeTextToClipboard: rs.fn() })); + +import { SidebarProvider } from "@/components/ui/sidebar"; +import { ThreadSidebarItem } from "@/components/workspace/recent-chat-list"; +import { ThreadDeleteDialogProvider } from "@/components/workspace/thread-delete-dialog"; +import { AuthProvider } from "@/core/auth/AuthProvider"; +import type { User } from "@/core/auth/types"; +import { writeTextToClipboard } from "@/core/clipboard"; +import { DEFAULT_LOCALE } from "@/core/i18n"; +import { I18nProvider } from "@/core/i18n/context"; + +type HappyDOMWindow = Window & { + happyDOM: { setURL: (url: string) => void }; +}; + +const THREAD_ID = "83d5133d-f8aa-4095-9bba-2aca03f8f61c"; +const writeText = rs.mocked(writeTextToClipboard); +const initialURL = window.location.href; + +function renderItem(metadata: Record = {}) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const user = { + id: "user-1", + email: "user@example.test", + system_role: "user", + } as User; + const thread = { + thread_id: THREAD_ID, + updated_at: "2026-01-01T00:00:00Z", + metadata, + values: { title: "A thread" }, + } as never; + return render( + + + + + + + + + + + , + ); +} + +/** Opens the row's "more" menu and selects Share. Radix opens on pointerdown. */ +async function share(): Promise { + const trigger = await screen.findByRole("button", { name: /more/i }); + fireEvent.pointerDown(trigger, { button: 0, pointerType: "mouse" }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByText("Share")); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1)); + return writeText.mock.calls[0]![0]; +} + +afterEach(() => { + cleanup(); + writeText.mockReset(); + (window as unknown as HappyDOMWindow).happyDOM.setURL(initialURL); +}); + +describe("ThreadSidebarItem Share", () => { + // The default `make dev` and Docker entry points are localhost:2026 and + // 127.0.0.1:2026. The link must stay on the deployment that holds the + // thread rather than point at a hosted demo. + for (const origin of [ + "http://localhost:2026", + "http://127.0.0.1:2026", + "http://localhost:3000", + "http://[::1]:2026", + "http://192.168.1.20:2026", + "https://deerflow.example.com", + ]) { + it(`copies a link on the current origin (${origin})`, async () => { + (window as unknown as HappyDOMWindow).happyDOM.setURL( + `${origin}/workspace/chats`, + ); + writeText.mockResolvedValue(true); + renderItem(); + expect(await share()).toBe(`${origin}/workspace/chats/${THREAD_ID}`); + }); + } + + it("copies the custom agent route on the current origin", async () => { + (window as unknown as HappyDOMWindow).happyDOM.setURL( + "http://localhost:2026/workspace", + ); + writeText.mockResolvedValue(true); + renderItem({ agent_name: "research bot" }); + expect(await share()).toBe( + `http://localhost:2026/workspace/agents/research%20bot/chats/${THREAD_ID}`, + ); + }); +});