diff --git a/docs b/docs index 3e19bea..103646e 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 3e19bead5807d511f0a6e8a564771ce4d268719b +Subproject commit 103646e814e82b73759366884641fed12475444a diff --git a/web/app/dashboard/conversations/_components/conversation-workbench.tsx b/web/app/dashboard/conversations/_components/conversation-workbench.tsx new file mode 100644 index 0000000..f9894b0 --- /dev/null +++ b/web/app/dashboard/conversations/_components/conversation-workbench.tsx @@ -0,0 +1,538 @@ +"use client"; + +import { + ArrowRightLeftIcon, + ChevronLeft, + ChevronRight, + ChevronsUpDown, + CircleUserRoundIcon, + CircleXIcon, + FilePlus2Icon, + Menu, + MoreHorizontalIcon, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { PanelImperativeHandle } from "react-resizable-panels"; + +import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"; +import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} 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, + agentConversationSelectors, + type AgentConversationFilterKey, + useAgentConversationsStore, +} from "@/lib/stores/agent-conversations"; +import { CreateTicketFromConversationDialog } from "../../tickets/_components/create-ticket-from-conversation-dialog"; +import { ChatPanel } from "./chat-panel"; +import { ConversationInfoPanel } from "./conversation-info-panel"; +import { ConversationList } from "./conversation-list"; + +const workbenchIconButtonClassName = + "size-8 text-muted-foreground hover:bg-muted hover:text-foreground"; + +function getCustomerOnlineClassName(online?: boolean) { + return online + ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300" + : "border-border bg-muted text-muted-foreground"; +} + +function getCustomerOnlineDotClassName(online?: boolean) { + return online ? "bg-emerald-500" : "bg-muted-foreground/70"; +} + +export function ConversationWorkbench() { + const t = useI18n(); + const conversation = useAgentConversationsStore( + agentConversationSelectors.selectedConversation, + ); + const conversationFilter = useAgentConversationsStore( + (state) => state.conversationFilter, + ); + const setConversationFilter = useAgentConversationsStore( + (state) => state.setConversationFilter, + ); + const loadConversations = useAgentConversationsStore( + (state) => state.loadConversations, + ); + const loadMessages = useAgentConversationsStore( + (state) => state.loadMessages, + ); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); + const [closeOpen, setCloseOpen] = useState(false); + const [createTicketOpen, setCreateTicketOpen] = useState(false); + const sidebarPanelRef = useRef(null); + const infoPanelRef = useRef(null); + const filterContainerRef = useRef(null); + const filterMeasureRef = useRef(null); + const [showFilterDropdown, setShowFilterDropdown] = useState(false); + + useEffect(() => { + const container = filterContainerRef.current; + const measure = filterMeasureRef.current; + if (!container || !measure) { + return; + } + + const updateFilterMode = () => { + setShowFilterDropdown(measure.scrollWidth > container.clientWidth); + }; + + updateFilterMode(); + + const observer = new ResizeObserver(() => { + updateFilterMode(); + }); + + observer.observe(container); + observer.observe(measure); + + return () => { + observer.disconnect(); + }; + }, []); + + const currentFilterOption = + agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ?? + agentConversationFilterOptions[0]; + const getFilterLabel = (labelKey: string) => t(labelKey); + + useEffect(() => { + void loadConversations().catch((error) => { + toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed")); + }); + }, [loadConversations, conversationFilter, t]); + + async function handleConversationChanged(conversationId: number) { + await loadConversations(); + await loadMessages(conversationId, { + forceLoading: false, + reset: false, + }); + } + + useAgentConversationRealtime(); + + const handleSidebarToggle = () => { + const panel = sidebarPanelRef.current; + if (!panel) { + setSidebarCollapsed((current) => !current); + return; + } + + if (panel.isCollapsed()) { + panel.expand(); + setSidebarCollapsed(false); + return; + } + + panel.collapse(); + setSidebarCollapsed(true); + }; + + const handleInfoPanelToggle = () => { + const panel = infoPanelRef.current; + if (!panel) { + setInfoPanelCollapsed((current) => !current); + return; + } + + if (panel.isCollapsed()) { + panel.expand(); + setInfoPanelCollapsed(false); + return; + } + + panel.collapse(); + setInfoPanelCollapsed(true); + }; + + const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => ( +
+
+
+ {showFilterDropdown ? ( + + + } + > + + {currentFilterOption + ? getFilterLabel(currentFilterOption.labelKey) + : t("conversation.filterPlaceholder")} + + + + + + setConversationFilter(value as AgentConversationFilterKey) + } + > + {agentConversationFilterOptions.map((opt) => ( + + {getFilterLabel(opt.labelKey)} + + ))} + + + + ) : ( + + setConversationFilter(value as AgentConversationFilterKey) + } + className="min-w-0 flex-1 gap-0" + > + + {agentConversationFilterOptions.map((opt) => ( + + {getFilterLabel(opt.labelKey)} + + ))} + + + )} + +
+ +
+ +
+ ); + + const workspaceContent = ( +
+
+
+ + + {conversation ? ( + <> + + + + {t("conversation.customerAvatar")} + + +
+
+

+ {conversation.customerName || + t("conversation.customerFallback", { + id: conversation.customerId || conversation.id, + })} +

+ + + {conversation.customerOnline + ? t("conversation.customerOnline") + : t("conversation.customerOffline")} + +
+

+ {t("conversation.channelNumber", { id: conversation.channelId || "-" })} + {conversation.customerId ? ( + <> + / + {t("conversation.linkedCustomer")} + + ) : null} +

+
+ + ) : ( +
+

+ {t("conversation.workbenchTitle")} +

+

+ {t("conversation.openMenuSelectConversation")} +

+

+ {t("conversation.selectConversationFromSidebar")} +

+
+ )} +
+
+ + + + } + > + + + + setCreateTicketOpen(true)} + disabled={!conversation} + > + + {t("conversation.createTicket")} + + setTransferOpen(true)} + disabled={!conversation || conversation.status !== 3} + > + + {t("conversation.transferConversation")} + + setCloseOpen(true)} + disabled={!conversation || conversation.status === 4} + > + + {t("conversation.closeConversation")} + + + + +
+
+
+ +
+
+ ); + + return ( +
+ {mobileMenuOpen && ( +
+ ); +} diff --git a/web/app/dashboard/conversations/page.tsx b/web/app/dashboard/conversations/page.tsx index d724da6..8bf500f 100644 --- a/web/app/dashboard/conversations/page.tsx +++ b/web/app/dashboard/conversations/page.tsx @@ -1,538 +1,5 @@ -"use client"; - -import { - ArrowRightLeftIcon, - ChevronLeft, - ChevronRight, - ChevronsUpDown, - CircleUserRoundIcon, - CircleXIcon, - FilePlus2Icon, - Menu, - MoreHorizontalIcon, - X, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import type { PanelImperativeHandle } from "react-resizable-panels"; - -import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog"; -import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} 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, - agentConversationSelectors, - type AgentConversationFilterKey, - useAgentConversationsStore, -} from "@/lib/stores/agent-conversations"; -import { CreateTicketFromConversationDialog } from "../tickets/_components/create-ticket-from-conversation-dialog"; -import { ChatPanel } from "./_components/chat-panel"; -import { ConversationInfoPanel } from "./_components/conversation-info-panel"; -import { ConversationList } from "./_components/conversation-list"; - -const workbenchIconButtonClassName = - "size-8 text-muted-foreground hover:bg-muted hover:text-foreground"; - -function getCustomerOnlineClassName(online?: boolean) { - return online - ? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300" - : "border-border bg-muted text-muted-foreground"; -} - -function getCustomerOnlineDotClassName(online?: boolean) { - return online ? "bg-emerald-500" : "bg-muted-foreground/70"; -} +import { ConversationWorkbench } from "./_components/conversation-workbench"; export default function ConversationsPage() { - const t = useI18n(); - const conversation = useAgentConversationsStore( - agentConversationSelectors.selectedConversation, - ); - const conversationFilter = useAgentConversationsStore( - (state) => state.conversationFilter, - ); - const setConversationFilter = useAgentConversationsStore( - (state) => state.setConversationFilter, - ); - const loadConversations = useAgentConversationsStore( - (state) => state.loadConversations, - ); - const loadMessages = useAgentConversationsStore( - (state) => state.loadMessages, - ); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false); - const [transferOpen, setTransferOpen] = useState(false); - const [closeOpen, setCloseOpen] = useState(false); - const [createTicketOpen, setCreateTicketOpen] = useState(false); - const sidebarPanelRef = useRef(null); - const infoPanelRef = useRef(null); - const filterContainerRef = useRef(null); - const filterMeasureRef = useRef(null); - const [showFilterDropdown, setShowFilterDropdown] = useState(false); - - useEffect(() => { - const container = filterContainerRef.current; - const measure = filterMeasureRef.current; - if (!container || !measure) { - return; - } - - const updateFilterMode = () => { - setShowFilterDropdown(measure.scrollWidth > container.clientWidth); - }; - - updateFilterMode(); - - const observer = new ResizeObserver(() => { - updateFilterMode(); - }); - - observer.observe(container); - observer.observe(measure); - - return () => { - observer.disconnect(); - }; - }, []); - - const currentFilterOption = - agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ?? - agentConversationFilterOptions[0]; - const getFilterLabel = (labelKey: string) => t(labelKey); - - useEffect(() => { - void loadConversations().catch((error) => { - toast.error(error instanceof Error ? error.message : t("conversation.loadListFailed")); - }); - }, [loadConversations, conversationFilter, t]); - - async function handleConversationChanged(conversationId: number) { - await loadConversations(); - await loadMessages(conversationId, { - forceLoading: false, - reset: false, - }); - } - - useAgentConversationRealtime(); - - const handleSidebarToggle = () => { - const panel = sidebarPanelRef.current; - if (!panel) { - setSidebarCollapsed((current) => !current); - return; - } - - if (panel.isCollapsed()) { - panel.expand(); - setSidebarCollapsed(false); - return; - } - - panel.collapse(); - setSidebarCollapsed(true); - }; - - const handleInfoPanelToggle = () => { - const panel = infoPanelRef.current; - if (!panel) { - setInfoPanelCollapsed((current) => !current); - return; - } - - if (panel.isCollapsed()) { - panel.expand(); - setInfoPanelCollapsed(false); - return; - } - - panel.collapse(); - setInfoPanelCollapsed(true); - }; - - const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => ( -
-
-
- {showFilterDropdown ? ( - - - } - > - - {currentFilterOption - ? getFilterLabel(currentFilterOption.labelKey) - : t("conversation.filterPlaceholder")} - - - - - - setConversationFilter(value as AgentConversationFilterKey) - } - > - {agentConversationFilterOptions.map((opt) => ( - - {getFilterLabel(opt.labelKey)} - - ))} - - - - ) : ( - - setConversationFilter(value as AgentConversationFilterKey) - } - className="min-w-0 flex-1 gap-0" - > - - {agentConversationFilterOptions.map((opt) => ( - - {getFilterLabel(opt.labelKey)} - - ))} - - - )} - -
- -
- -
- ); - - const workspaceContent = ( -
-
-
- - - {conversation ? ( - <> - - - - {t("conversation.customerAvatar")} - - -
-
-

- {conversation.customerName || - t("conversation.customerFallback", { - id: conversation.customerId || conversation.id, - })} -

- - - {conversation.customerOnline - ? t("conversation.customerOnline") - : t("conversation.customerOffline")} - -
-

- {t("conversation.channelNumber", { id: conversation.channelId || "-" })} - {conversation.customerId ? ( - <> - / - {t("conversation.linkedCustomer")} - - ) : null} -

-
- - ) : ( -
-

- {t("conversation.workbenchTitle")} -

-

- {t("conversation.openMenuSelectConversation")} -

-

- {t("conversation.selectConversationFromSidebar")} -

-
- )} -
-
- - - - } - > - - - - setCreateTicketOpen(true)} - disabled={!conversation} - > - - {t("conversation.createTicket")} - - setTransferOpen(true)} - disabled={!conversation || conversation.status !== 3} - > - - {t("conversation.transferConversation")} - - setCloseOpen(true)} - disabled={!conversation || conversation.status === 4} - > - - {t("conversation.closeConversation")} - - - - -
-
-
- -
-
- ); - - return ( -
- {mobileMenuOpen && ( -
- ); + return ; } diff --git a/web/app/workbench/layout.tsx b/web/app/workbench/layout.tsx new file mode 100644 index 0000000..9a1fb1a --- /dev/null +++ b/web/app/workbench/layout.tsx @@ -0,0 +1,63 @@ +"use client" + +import { Loader2Icon } from "lucide-react" +import { usePathname, useRouter } from "next/navigation" +import type { CSSProperties, ReactNode } from "react" +import { useEffect } from "react" + +import { useAuth } from "@/components/auth-provider" +import { NotificationProvider } from "@/components/notification-provider" +import { WorkbenchHeader } from "@/components/workbench-header" +import { useI18n } from "@/i18n/provider" + +export default function WorkbenchLayout({ + children, +}: { + children: ReactNode +}) { + const t = useI18n() + const { ready, session } = useAuth() + const pathname = usePathname() + const router = useRouter() + const isWorkbenchRoute = pathname?.startsWith("/workbench") ?? false + + useEffect(() => { + if (ready && !session && isWorkbenchRoute) { + router.replace("/dashboard/login") + } + }, [isWorkbenchRoute, ready, router, session]) + + if (!ready || !session) { + return ( +
+
+
+ +
+
+

{t("auth.checkingSession")}

+

+ {t("auth.syncingProfile")} +

+
+
+
+ ) + } + + return ( +
+ + +
{children}
+
+
+ ) +} diff --git a/web/app/workbench/page.tsx b/web/app/workbench/page.tsx new file mode 100644 index 0000000..cdeced7 --- /dev/null +++ b/web/app/workbench/page.tsx @@ -0,0 +1,5 @@ +import { ConversationWorkbench } from "@/app/dashboard/conversations/_components/conversation-workbench"; + +export default function WorkbenchPage() { + return ; +} diff --git a/web/components/app-sidebar.tsx b/web/components/app-sidebar.tsx index 6fd81d4..aeecaf5 100644 --- a/web/components/app-sidebar.tsx +++ b/web/components/app-sidebar.tsx @@ -1,7 +1,6 @@ "use client" import type { ComponentProps } from "react" -import Link from "next/link" import { useMemo } from "react" import { useI18n } from "@/i18n/provider" @@ -13,6 +12,7 @@ import { useAuth } from "@/components/auth-provider" import { NavMain } from "@/components/nav-main" import { NavSecondary } from "@/components/nav-secondary" import { NavUser } from "@/components/nav-user" +import { WorkspaceSwitcher } from "@/components/workspace-switcher" import { Sidebar, SidebarContent, @@ -45,19 +45,16 @@ export function AppSidebar({ ...props }: ComponentProps) { - } - > - {t("app.brand")} - {t("app.brand")} - + + } + /> diff --git a/web/components/auth-provider.test.mjs b/web/components/auth-provider.test.mjs new file mode 100644 index 0000000..aa892fa --- /dev/null +++ b/web/components/auth-provider.test.mjs @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile(new URL("./auth-provider.tsx", import.meta.url), "utf8"); + +test("refreshProfile preserves the stored token when the profile payload omits it", () => { + assert.match(source, /accessToken:\s*profile\.accessToken\s*\|\|\s*stored\.accessToken/); + assert.match(source, /expiresAt:\s*profile\.expiresAt\s*\|\|\s*stored\.expiresAt/); +}); + +test("refreshProfile only clears session for explicit auth error codes", () => { + assert.match(source, /errorCode\s*===\s*3000\s*\|\|\s*errorCode\s*===\s*3002/); + assert.doesNotMatch(source, /catch\s*\([^)]*\)\s*\{\s*clearSession\(\)/); +}); diff --git a/web/components/auth-provider.tsx b/web/components/auth-provider.tsx index f72fbc4..f3aa83a 100644 --- a/web/components/auth-provider.tsx +++ b/web/components/auth-provider.tsx @@ -35,7 +35,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null) const [ready, setReady] = useState(false) const isDashboardLoginRoute = pathname?.startsWith("/dashboard/login") ?? false - const requiresAuth = (pathname?.startsWith("/dashboard") ?? false) && !isDashboardLoginRoute + const requiresAuth = + ((pathname?.startsWith("/dashboard") ?? false) || + (pathname?.startsWith("/workbench") ?? false)) && + !isDashboardLoginRoute const refreshProfile = useCallback(async () => { const stored = readSession() @@ -52,16 +55,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { user: profile.user, permissions: profile.permissions, roles: profile.roles, + accessToken: profile.accessToken || stored.accessToken, + expiresAt: profile.expiresAt || stored.expiresAt, } writeSession(nextSession) setSession(nextSession) - } catch { - clearSession() - setSession(null) - if (requiresAuth) { - startTransition(() => { - router.replace("/dashboard/login") - }) + } catch (error) { + const errorCode = (error as Error & { errorCode?: number }).errorCode + if (errorCode === 3000 || errorCode === 3002) { + clearSession() + setSession(null) + if (requiresAuth) { + startTransition(() => { + router.replace("/dashboard/login") + }) + } } } finally { setReady(true) diff --git a/web/components/workbench-header.tsx b/web/components/workbench-header.tsx new file mode 100644 index 0000000..fb66215 --- /dev/null +++ b/web/components/workbench-header.tsx @@ -0,0 +1,146 @@ +"use client" + +import { BellIcon, KeyRoundIcon, LogOutIcon, UserIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { useState } from "react" + +import { ChangePasswordDialog } from "@/components/change-password-dialog" +import { LocaleSwitcher } from "@/components/locale-switcher" +import { PaletteToggle } from "@/components/palette-toggle" +import { RealtimeConnectionStatus } from "@/components/realtime-connection-status" +import { ThemeToggle } from "@/components/theme-toggle" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { WorkspaceSwitcher } from "@/components/workspace-switcher" +import { useAuth } from "@/components/auth-provider" +import { useNotifications } from "@/components/notification-provider" +import { useI18n } from "@/i18n/provider" +import { useAgentConversationsStore } from "@/lib/stores/agent-conversations" + +export function WorkbenchHeader() { + const realtimeStatus = useAgentConversationsStore((state) => state.realtimeStatus) + + return ( +
+
+
+ +
+
+
+ +
+ + + + +
+
+
+ ) +} + +function WorkbenchUserMenu() { + const t = useI18n() + const router = useRouter() + const { session, signOut } = useAuth() + const { unreadCount } = useNotifications() + const [changePasswordOpen, setChangePasswordOpen] = useState(false) + const user = { + name: session?.user.nickname || session?.user.username || t("common.notSignedIn"), + email: session?.user.username || t("common.guest"), + avatar: session?.user.avatar || "", + } + const fallback = user.name.slice(0, 1).toUpperCase() || "U" + + return ( + <> + + + } + > + + + + {fallback || } + + + + + + +
+ + + {fallback} + +
+ {user.name} + + {user.email} + +
+
+
+
+ + + { + router.push("/dashboard/notifications") + }} + className="gap-2" + > + + {t("nav.notifications")} + {unreadCount > 0 ? ( + + {unreadCount > 99 ? "99+" : unreadCount} + + ) : null} + + { + setChangePasswordOpen(true) + }} + > + + {t("nav.changePassword")} + + + + { + void signOut() + }} + > + + {t("nav.signOut")} + +
+
+ + + ) +} diff --git a/web/components/workspace-switcher.test.mjs b/web/components/workspace-switcher.test.mjs new file mode 100644 index 0000000..6337fec --- /dev/null +++ b/web/components/workspace-switcher.test.mjs @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { describe, it } from "node:test"; + +const source = await readFile(new URL("./workspace-switcher.tsx", import.meta.url), "utf8"); + +describe("workspace switcher config", () => { + it("contains dashboard and workbench destinations", async () => { + assert.match(source, /key:\s*"dashboard"[\s\S]*href:\s*"\/dashboard"[\s\S]*labelKey:\s*"workspace\.dashboard"/); + assert.match(source, /key:\s*"workbench"[\s\S]*href:\s*"\/workbench"[\s\S]*labelKey:\s*"workspace\.workbench"/); +}); + +it("wraps the dropdown label in a Base UI menu group", async () => { + assert.match(source, /[\s\S]*\{t\("workspace\.switchWorkspace"\)\}<\/DropdownMenuLabel>/); +}); +}); diff --git a/web/components/workspace-switcher.tsx b/web/components/workspace-switcher.tsx new file mode 100644 index 0000000..7eb236c --- /dev/null +++ b/web/components/workspace-switcher.tsx @@ -0,0 +1,115 @@ +"use client" + +import { CheckIcon, ChevronsUpDownIcon } from "lucide-react" +import Link from "next/link" +import type { ReactElement } from "react" + +import { useI18n } from "@/i18n/provider" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export type WorkspaceKey = "dashboard" | "workbench" + +export type WorkspaceOption = { + key: WorkspaceKey + href: string + labelKey: string +} + +export const workspaceOptions: WorkspaceOption[] = [ + { + key: "dashboard", + href: "/dashboard", + labelKey: "workspace.dashboard", + }, + { + key: "workbench", + href: "/workbench", + labelKey: "workspace.workbench", + }, +] + +type WorkspaceSwitcherProps = { + currentWorkspace: WorkspaceKey + variant?: "sidebar" | "header" + className?: string + trigger?: ReactElement +} + +export function WorkspaceSwitcher({ + currentWorkspace, + variant = "header", + className, + trigger, +}: WorkspaceSwitcherProps) { + const t = useI18n() + const currentOption = + workspaceOptions.find((item) => item.key === currentWorkspace) ?? workspaceOptions[0] + const triggerClassName = cn( + "gap-2 text-left", + variant === "header" && + "h-9 rounded-md border border-border/70 bg-background px-2.5 shadow-xs hover:bg-muted", + variant === "sidebar" && "data-[slot=sidebar-menu-button]:p-1.5!", + className + ) + const triggerContent = ( + <> + {t("app.brand")} +
+ {t("app.brand")} + + {t(currentOption.labelKey)} + +
+ + + ) + + return ( + + + } + > + {triggerContent} + + + + {t("workspace.switchWorkspace")} + {workspaceOptions.map((item) => ( + } + className="cursor-pointer gap-2" + > + {t(item.labelKey)} + {item.key === currentWorkspace ? ( + + ) : null} + + ))} + + + + ) +} diff --git a/web/e2e/workbench-function.spec.ts b/web/e2e/workbench-function.spec.ts new file mode 100644 index 0000000..36b1a2f --- /dev/null +++ b/web/e2e/workbench-function.spec.ts @@ -0,0 +1,94 @@ +import { mkdirSync } from "node:fs"; +import { test, expect, type Page } from "@playwright/test"; + +const baseUrl = process.env.E2E_BASE_URL ?? "http://localhost:3000"; +const username = process.env.E2E_USERNAME ?? "admin"; +const password = process.env.E2E_PASSWORD ?? ""; +const reportDir = + process.env.E2E_REPORT_DIR ?? + "../docs/superpowers/test-reports/2026-06-13-support-workbench"; + +mkdirSync(reportDir, { recursive: true }); + +test.use({ + launchOptions: { + channel: "chrome", + }, +}); + +async function screenshot(page: Page, name: string) { + await page.screenshot({ + path: `${reportDir}/${name}.png`, + fullPage: true, + }); +} + +async function login(page: Page) { + await page.goto(`${baseUrl}/workbench/`); + await expect(page).toHaveURL(/\/dashboard\/login/); + await screenshot(page, "01-login-redirect"); + + await page.locator("#username").fill(username); + await page.locator("#password").fill(password); + await screenshot(page, "02-login-filled"); + + const loginResponsePromise = page.waitForResponse( + (response) => + response.url().includes("/api/auth/login") && response.status() === 200, + { timeout: 15000 }, + ); + await page.locator('form button[type="submit"]').click(); + const loginResponse = await loginResponsePromise; + console.log("login response", loginResponse.status(), loginResponse.url()); + + await page.waitForURL((url) => !url.pathname.startsWith("/dashboard/login"), { + timeout: 15000, + }); + await expect + .poll(() => page.evaluate(() => Boolean(window.localStorage.getItem("agent-desk-session")))) + .toBe(true); +} + +test.describe("support workbench", () => { + test("logs in and switches between workbench and dashboard", async ({ page }) => { + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(error.stack || error.message); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(message.text()); + } + }); + + await login(page); + await page.goto(`${baseUrl}/workbench/`); + await page.waitForLoadState("networkidle"); + await screenshot(page, "03-workbench-initial"); + + await expect(page.getByText(/客服工作台|Support Workbench/).first()).toBeVisible(); + await page.getByRole("button", { name: /贝壳AGENT|Shell Agent/i }).first().click(); + await expect(page.getByText(/切换工作区|Switch workspace/)).toBeVisible(); + await expect(page.getByRole("menuitem", { name: /管理后台|Admin Dashboard/ })).toBeVisible(); + await page.waitForTimeout(300); + await screenshot(page, "04-workbench-switcher-open"); + + await page.getByRole("menuitem", { name: /管理后台|Admin Dashboard/ }).click(); + await page.waitForURL(/\/dashboard\/?$/, { timeout: 15000 }); + await page.waitForLoadState("networkidle"); + await screenshot(page, "05-dashboard-after-switch"); + await expect(page.getByText(/管理后台|Admin Dashboard/).first()).toBeVisible(); + + await page.getByRole("button", { name: /贝壳AGENT|Shell Agent/i }).first().click(); + await expect(page.getByRole("menuitem", { name: /客服工作台|Support Workbench/ })).toBeVisible(); + await page.waitForTimeout(300); + await screenshot(page, "06-dashboard-switcher-open"); + await page.getByRole("menuitem", { name: /客服工作台|Support Workbench/ }).click(); + await page.waitForURL(/\/workbench\/?$/, { timeout: 15000 }); + await page.waitForLoadState("networkidle"); + await screenshot(page, "07-workbench-after-return"); + await expect(page.getByText(/客服工作台|Support Workbench/).first()).toBeVisible(); + + expect(runtimeErrors).toEqual([]); + }); +}); diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 0a03971..77fa696 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2351,6 +2351,11 @@ "changePassword": "Change Password", "signOut": "Sign Out" }, + "workspace": { + "dashboard": "Admin Dashboard", + "workbench": "Support Workbench", + "switchWorkspace": "Switch workspace" + }, "theme": { "toggle": "Change theme", "light": "Light", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 7d96293..2076fd6 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2351,6 +2351,11 @@ "changePassword": "修改密码", "signOut": "退出登录" }, + "workspace": { + "dashboard": "管理后台", + "workbench": "客服工作台", + "switchWorkspace": "切换工作区" + }, "theme": { "toggle": "切换主题", "light": "浅色模式", diff --git a/web/tsconfig.json b/web/tsconfig.json index 3a13f90..577a4f4 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -30,5 +30,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "e2e"] }