diff --git a/web/app/dashboard/conversations/_components/conversation-workbench.tsx b/web/app/dashboard/conversations/_components/conversation-workbench.tsx index f9894b0..02c71bc 100644 --- a/web/app/dashboard/conversations/_components/conversation-workbench.tsx +++ b/web/app/dashboard/conversations/_components/conversation-workbench.tsx @@ -35,7 +35,6 @@ import { } from "@/components/ui/resizable"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Sheet, SheetContent } from "@/components/ui/sheet"; -import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime"; import { useI18n } from "@/i18n/provider"; import { agentConversationFilterOptions, @@ -135,8 +134,6 @@ export function ConversationWorkbench() { }); } - useAgentConversationRealtime(); - const handleSidebarToggle = () => { const panel = sidebarPanelRef.current; if (!panel) { diff --git a/web/app/dashboard/conversations/page.tsx b/web/app/dashboard/conversations/page.tsx index 8bf500f..44b4937 100644 --- a/web/app/dashboard/conversations/page.tsx +++ b/web/app/dashboard/conversations/page.tsx @@ -1,5 +1,12 @@ +import { AgentRealtimeProvider } from "@/components/agent-realtime-provider"; + import { ConversationWorkbench } from "./_components/conversation-workbench"; export default function ConversationsPage() { - return ; + return ( + <> + + + + ); } diff --git a/web/app/workbench/layout.tsx b/web/app/workbench/layout.tsx index 640308f..61c34ef 100644 --- a/web/app/workbench/layout.tsx +++ b/web/app/workbench/layout.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation" import type { CSSProperties, ReactNode } from "react" import { useEffect } from "react" +import { AgentRealtimeProvider } from "@/components/agent-realtime-provider" import { useAuth } from "@/components/auth-provider" import { NotificationProvider } from "@/components/notification-provider" import { WorkbenchHeader } from "@/components/workbench-header" @@ -56,6 +57,7 @@ export default function WorkbenchLayout({ } > +
diff --git a/web/components/agent-realtime-provider.test.mjs b/web/components/agent-realtime-provider.test.mjs new file mode 100644 index 0000000..c308044 --- /dev/null +++ b/web/components/agent-realtime-provider.test.mjs @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { describe, it } from "node:test"; + +const providerSource = await readFile( + new URL("./agent-realtime-provider.tsx", import.meta.url), + "utf8", +).catch(() => ""); +const workbenchLayoutSource = await readFile( + new URL("../app/workbench/layout.tsx", import.meta.url), + "utf8", +); +const dashboardConversationsPageSource = await readFile( + new URL("../app/dashboard/conversations/page.tsx", import.meta.url), + "utf8", +); +const conversationWorkbenchSource = await readFile( + new URL("../app/dashboard/conversations/_components/conversation-workbench.tsx", import.meta.url), + "utf8", +); + +describe("AgentRealtimeProvider placement", () => { + it("owns the agent realtime hook", () => { + assert.match(providerSource, /export function AgentRealtimeProvider/); + assert.match(providerSource, /useAgentConversationRealtime\(\)/); + }); + + it("is mounted at the workbench layout level", () => { + assert.match(workbenchLayoutSource, /import \{ AgentRealtimeProvider \}/); + assert.match(workbenchLayoutSource, //); + }); + + it("keeps dashboard conversations realtime without tying it to ConversationWorkbench", () => { + assert.match(dashboardConversationsPageSource, //); + assert.doesNotMatch(conversationWorkbenchSource, /useAgentConversationRealtime/); + }); +}); diff --git a/web/components/agent-realtime-provider.tsx b/web/components/agent-realtime-provider.tsx new file mode 100644 index 0000000..470ad45 --- /dev/null +++ b/web/components/agent-realtime-provider.tsx @@ -0,0 +1,9 @@ +"use client" + +import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime" + +export function AgentRealtimeProvider() { + useAgentConversationRealtime() + + return null +} diff --git a/web/components/workspace-switcher.test.mjs b/web/components/workspace-switcher.test.mjs index 6337fec..156c2ce 100644 --- a/web/components/workspace-switcher.test.mjs +++ b/web/components/workspace-switcher.test.mjs @@ -13,4 +13,9 @@ describe("workspace switcher config", () => { it("wraps the dropdown label in a Base UI menu group", async () => { assert.match(source, /[\s\S]*\{t\("workspace\.switchWorkspace"\)\}<\/DropdownMenuLabel>/); }); + +it("does not auto-open the rail menu from focus events", async () => { + assert.doesNotMatch(source, /onFocus=\{openHoverMenu\}/); + assert.doesNotMatch(source, /onBlur=\{closeHoverMenu\}/); +}); }); diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx index ff97c0a..f565746 100644 --- a/web/components/workspace-switcher.tsx +++ b/web/components/workspace-switcher.tsx @@ -2,7 +2,7 @@ import { CheckIcon, ChevronsUpDownIcon } from "lucide-react" import Link from "next/link" -import { useRef, useState } from "react" +import { useEffect, useRef, useState } from "react" import type { ReactElement } from "react" import { useI18n } from "@/i18n/provider" @@ -82,6 +82,14 @@ export function WorkspaceSwitcher({ }, 120) } + useEffect(() => { + return () => { + if (closeTimerRef.current) { + clearTimeout(closeTimerRef.current) + } + } + }, []) + const triggerClassName = cn( "gap-2 text-left", variant === "header" && @@ -133,8 +141,6 @@ export function WorkspaceSwitcher({ } diff --git a/web/e2e/workbench-function.spec.ts b/web/e2e/workbench-function.spec.ts index 1bc15a2..fd00624 100644 --- a/web/e2e/workbench-function.spec.ts +++ b/web/e2e/workbench-function.spec.ts @@ -49,6 +49,13 @@ async function login(page: Page) { .toBe(true); } +async function expectWorkbenchRealtimeOnline(page: Page) { + await expect(page.getByText(/平台实时:在线|Realtime: online/)).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByText(/平台实时:已断开|Realtime: disconnected/)).toHaveCount(0); +} + test.describe("support workbench", () => { test("logs in and switches between workbench and dashboard", async ({ page }) => { const runtimeErrors: string[] = []; @@ -65,6 +72,7 @@ test.describe("support workbench", () => { await page.goto(`${baseUrl}/workbench/`); await page.waitForLoadState("networkidle"); await screenshot(page, "03-workbench-initial"); + await expectWorkbenchRealtimeOnline(page); await expect(page.getByText(/客服工作台|Support Workbench/).first()).toBeVisible(); const conversationsEntry = page.getByRole("link", { name: /会话|Conversations/ }).first(); @@ -110,6 +118,7 @@ test.describe("support workbench", () => { await page.waitForLoadState("networkidle"); await expect(returnedTicketsEntry).toHaveClass(/bg-sidebar-primary/); await expect(returnedConversationsEntry).not.toHaveClass(/bg-sidebar-primary/); + await expectWorkbenchRealtimeOnline(page); await page.mouse.move(640, 360); await screenshot(page, "09-workbench-tickets-active");