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");