feat: refactor conversations page into a workbench layout and implement workspace switching

- Migrate ConversationsPage to use ConversationWorkbench component.
- Create WorkbenchLayout to handle authentication and layout for workbench.
- Add WorkbenchPage to render ConversationWorkbench.
- Implement WorkspaceSwitcher for switching between dashboard and workbench.
- Update AuthProvider to manage authentication for both dashboard and workbench routes.
- Enhance WorkbenchHeader with user menu and workspace switcher.
- Add tests for workspace switcher and auth provider functionality.
- Update translations for workspace labels in English and Chinese.
- Exclude e2e tests from TypeScript compilation.
This commit is contained in:
mlogclub
2026-06-13 11:49:03 +08:00
parent a8755811b5
commit d22805c4af
15 changed files with 1033 additions and 559 deletions
+11 -14
View File
@@ -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<typeof Sidebar>) {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="data-[slot=sidebar-menu-button]:p-1.5!"
render={<Link href="/dashboard" />}
>
<img
src="/images/logo.svg"
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<span className="text-base font-semibold">{t("app.brand")}</span>
</SidebarMenuButton>
<WorkspaceSwitcher
currentWorkspace="dashboard"
variant="sidebar"
trigger={
<SidebarMenuButton
size="lg"
className="data-[slot=sidebar-menu-button]:p-1.5!"
/>
}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
+15
View File
@@ -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\(\)/);
});
+16 -8
View File
@@ -35,7 +35,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(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)
+146
View File
@@ -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 (
<header className="flex h-(--header-height) shrink-0 items-center border-b border-border/70 bg-background/88 backdrop-blur supports-[backdrop-filter]:bg-background/76">
<div className="flex w-full min-w-0 items-center justify-between gap-3 px-3 lg:px-4">
<div className="min-w-0">
<WorkspaceSwitcher currentWorkspace="workbench" />
</div>
<div className="flex min-w-0 items-center justify-end gap-2">
<div className="hidden sm:block">
<RealtimeConnectionStatus status={realtimeStatus} compact />
</div>
<LocaleSwitcher />
<PaletteToggle />
<ThemeToggle />
<WorkbenchUserMenu />
</div>
</div>
</header>
)
}
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 (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
className="relative size-9 rounded-full"
aria-label={user.name}
/>
}
>
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>
{fallback || <UserIcon className="size-4" />}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56" align="end" sideOffset={8}>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{fallback}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => {
router.push("/dashboard/notifications")
}}
className="gap-2"
>
<BellIcon />
<span className="flex-1">{t("nav.notifications")}</span>
{unreadCount > 0 ? (
<Badge className="h-5 min-w-5 px-1.5">
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
) : null}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setChangePasswordOpen(true)
}}
>
<KeyRoundIcon />
{t("nav.changePassword")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
void signOut()
}}
>
<LogOutIcon />
{t("nav.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ChangePasswordDialog
open={changePasswordOpen}
onOpenChange={setChangePasswordOpen}
onSuccess={signOut}
/>
</>
)
}
@@ -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, /<DropdownMenuGroup>[\s\S]*<DropdownMenuLabel>\{t\("workspace\.switchWorkspace"\)\}<\/DropdownMenuLabel>/);
});
});
+115
View File
@@ -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 = (
<>
<img
src="/images/logo.svg"
alt={t("app.brand")}
width="32"
height="32"
className="size-7 shrink-0 object-contain"
/>
<div className="grid min-w-0 flex-1 text-left leading-tight">
<span className="truncate text-sm font-semibold">{t("app.brand")}</span>
<span className="truncate text-xs text-muted-foreground">
{t(currentOption.labelKey)}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4 shrink-0 text-muted-foreground" />
</>
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
trigger ?? <Button variant="ghost" className={triggerClassName} />
}
>
{triggerContent}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side={variant === "sidebar" ? "right" : "bottom"}
sideOffset={8}
className="w-60 min-w-60"
>
<DropdownMenuGroup>
<DropdownMenuLabel>{t("workspace.switchWorkspace")}</DropdownMenuLabel>
{workspaceOptions.map((item) => (
<DropdownMenuItem
key={item.key}
render={<Link href={item.href} />}
className="cursor-pointer gap-2"
>
<span className="flex-1 truncate">{t(item.labelKey)}</span>
{item.key === currentWorkspace ? (
<CheckIcon className="size-4 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}