Refactor real-time sync from per-event precise mutations to WS-as-invalidation-signal + debounced refetch. Backend: - Add SubscribeAll to Event Bus — auto-broadcasts ALL events, eliminates manual 25-item allEvents list - Add skill event constants to protocol, fix skill handler string literals - Add title_changed activity tracking Frontend: - WSClient: add onAny() method for wildcard event subscription - useRealtimeSync: rewrite to refreshMap + prefix routing + 100ms debounce - Precise handlers only for side effects: workspace:deleted, member:removed, member:added (self-check) - Reconnect now refetches all stores (fixes missing members/skills/workspace refresh) - Stale-while-revalidate: fetch() only shows loading spinner on initial load, not on refetch - Remove redundant useWSEvent in agents/page.tsx and skills-page.tsx - WSClient.disconnect() now clears all handler registrations Inbox bugfixes: - Unify sidebar badge count with page count via dedupedItems + unreadCount in store - Sort by time DESC (removed severity-first ordering) - Ellipsis on truncated detail labels UI: - Status/Priority pickers: replace RadioGroup with MenuItem for auto-close on selection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
7.9 KiB
TypeScript
216 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import {
|
|
Inbox,
|
|
ListTodo,
|
|
Bot,
|
|
Monitor,
|
|
ChevronDown,
|
|
Settings,
|
|
LogOut,
|
|
Plus,
|
|
Check,
|
|
BookOpenText,
|
|
SquarePen,
|
|
} from "lucide-react";
|
|
import { WorkspaceAvatar } from "@/features/workspace";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarHeader,
|
|
SidebarFooter,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
} from "@/components/ui/sidebar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
|
import { useAuthStore } from "@/features/auth";
|
|
import { useWorkspaceStore } from "@/features/workspace";
|
|
import { useInboxStore } from "@/features/inbox";
|
|
import { useModalStore } from "@/features/modals";
|
|
|
|
const primaryNav = [
|
|
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
|
{ href: "/issues", label: "Issues", icon: ListTodo },
|
|
];
|
|
|
|
const workspaceNav = [
|
|
{ href: "/agents", label: "Agents", icon: Bot },
|
|
{ href: "/runtimes", label: "Runtimes", icon: Monitor },
|
|
{ href: "/skills", label: "Skills", icon: BookOpenText },
|
|
{ href: "/settings", label: "Settings", icon: Settings },
|
|
];
|
|
|
|
export function AppSidebar() {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const user = useAuthStore((s) => s.user);
|
|
const authLogout = useAuthStore((s) => s.logout);
|
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
|
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
|
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
|
|
|
const unreadCount = useInboxStore((s) => s.unreadCount());
|
|
|
|
const logout = () => {
|
|
authLogout();
|
|
useWorkspaceStore.getState().clearWorkspace();
|
|
router.push("/login");
|
|
};
|
|
|
|
return (
|
|
<Sidebar variant="inset">
|
|
{/* Workspace Switcher */}
|
|
<SidebarHeader className="py-3">
|
|
<div className="flex items-center gap-4">
|
|
<SidebarMenu className="min-w-0 flex-1">
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<SidebarMenuButton>
|
|
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
|
|
<span className="flex-1 truncate font-medium">
|
|
{workspace?.name ?? "Multica"}
|
|
</span>
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</SidebarMenuButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent
|
|
className="w-52"
|
|
align="start"
|
|
side="bottom"
|
|
sideOffset={4}
|
|
>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
{user?.email}
|
|
</DropdownMenuLabel>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup className="group/ws-section">
|
|
<DropdownMenuLabel className="flex items-center text-xs text-muted-foreground">
|
|
Workspaces
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
className="ml-auto opacity-0 group-hover/ws-section:opacity-100 transition-opacity rounded hover:bg-accent p-0.5"
|
|
onClick={() => useModalStore.getState().open("create-workspace")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">
|
|
Create workspace
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</DropdownMenuLabel>
|
|
{workspaces.map((ws) => (
|
|
<DropdownMenuItem
|
|
key={ws.id}
|
|
onClick={() => {
|
|
if (ws.id !== workspace?.id) {
|
|
switchWorkspace(ws.id);
|
|
}
|
|
}}
|
|
>
|
|
<WorkspaceAvatar name={ws.name} size="sm" />
|
|
<span className="flex-1 truncate">{ws.name}</span>
|
|
{ws.id === workspace?.id && (
|
|
<Check className="h-3.5 w-3.5 text-primary" />
|
|
)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem variant="destructive" onClick={logout}>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
Log out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
className="flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
|
|
onClick={() => useModalStore.getState().open("create-issue")}
|
|
>
|
|
<SquarePen className="size-3.5" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">New issue</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</SidebarHeader>
|
|
|
|
{/* Navigation */}
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu className="gap-0.5">
|
|
{primaryNav.map((item) => {
|
|
const isActive = pathname === item.href;
|
|
return (
|
|
<SidebarMenuItem key={item.href}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
render={<Link href={item.href} />}
|
|
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
|
>
|
|
<item.icon />
|
|
<span>{item.label}</span>
|
|
{item.label === "Inbox" && unreadCount > 0 && (
|
|
<span className="ml-auto text-xs">
|
|
{unreadCount > 99 ? "99+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu className="gap-0.5">
|
|
{workspaceNav.map((item) => {
|
|
const isActive = pathname === item.href;
|
|
return (
|
|
<SidebarMenuItem key={item.href}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
render={<Link href={item.href} />}
|
|
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
|
>
|
|
<item.icon />
|
|
<span>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
<SidebarFooter />
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|