mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* fix(sidebar): hide stale pinned items immediately on workspace switch When the user switches workspaces, previously pinned items from the old workspace were briefly visible until the new workspace data loaded. Reset the pinned list to an empty array on workspace-id change so the stale items disappear instantly, eliminating the flicker. * fix: separate pinnedItems and wsId effects to prevent drag-sort loss When wsId changes, the combined effect would trigger even if pinnedItems hadn't changed yet (still old workspace data), overwriting localPinned and losing any pending drag-sort results. Split into two independent effects: - pinnedItems effect: updates localPinned when data changes (respects isDragging) - wsId effect: updates localPinnedWsId immediately on workspace switch This ensures workspace switches don't interfere with drag operations.
743 lines
29 KiB
TypeScript
743 lines
29 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
|
import { AppLink, useNavigation } from "../navigation";
|
|
import { HelpLauncher } from "./help-launcher";
|
|
import {
|
|
DndContext,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
closestCenter,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import {
|
|
Inbox,
|
|
ListTodo,
|
|
Bot,
|
|
Monitor,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Settings,
|
|
LogOut,
|
|
Plus,
|
|
Check,
|
|
BookOpenText,
|
|
SquarePen,
|
|
CircleUser,
|
|
FolderKanban,
|
|
BarChart3,
|
|
X,
|
|
Zap,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
|
|
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
|
|
import { StatusIcon } from "../issues/components/status-icon";
|
|
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
|
import { openCreateIssueWithPreference } from "@multica/core/issues/stores/create-mode-store";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
SidebarGroupContent,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarRail,
|
|
} from "@multica/ui/components/ui/sidebar";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import { useAuthStore } from "@multica/core/auth";
|
|
import { useCurrentWorkspace, useWorkspacePaths, paths } from "@multica/core/paths";
|
|
import { workspaceListOptions, myInvitationListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
|
import { resolvePublicFileUrl } from "@multica/core/workspace/avatar-url";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
|
import { api, ApiError } from "@multica/core/api";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
import { useConfigStore } from "@multica/core/config";
|
|
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
|
import { pinListOptions } from "@multica/core/pins/queries";
|
|
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
|
import { issueDetailOptions } from "@multica/core/issues/queries";
|
|
import { projectDetailOptions } from "@multica/core/projects/queries";
|
|
import type { PinnedItem } from "@multica/core/types";
|
|
import { useLogout } from "../auth";
|
|
import { ProjectIcon } from "../projects/components/project-icon";
|
|
import { useT } from "../i18n";
|
|
|
|
// Top-level nav items stay active when the user is on a child route
|
|
// (e.g. "Projects" stays lit on /:slug/projects/:id). Pinned items keep
|
|
// strict equality elsewhere — a pinned project shouldn't highlight on
|
|
// sub-pages of itself.
|
|
function isNavActive(pathname: string, href: string): boolean {
|
|
return pathname === href || pathname.startsWith(href + "/");
|
|
}
|
|
|
|
// Stable empty arrays for query defaults. Using an inline `= []` default on
|
|
// `useQuery` creates a new array reference on every render when `data` is
|
|
// undefined (e.g. query disabled or loading) — which in turn breaks any
|
|
// `useEffect`/`useMemo` that depends on the value, and can trigger infinite
|
|
// re-render loops when the effect itself calls `setState`.
|
|
const EMPTY_PINS: PinnedItem[] = [];
|
|
const EMPTY_WORKSPACES: Awaited<ReturnType<typeof api.listWorkspaces>> = [];
|
|
const EMPTY_INVITATIONS: Awaited<ReturnType<typeof api.listMyInvitations>> = [];
|
|
const EMPTY_INBOX: Awaited<ReturnType<typeof api.listInbox>> = [];
|
|
|
|
// Nav items reference WorkspacePaths method names so they can be resolved
|
|
// against the current workspace slug at render time (see AppSidebar body).
|
|
// Only parameterless paths are valid nav destinations.
|
|
type NavKey =
|
|
| "inbox"
|
|
| "myIssues"
|
|
| "issues"
|
|
| "projects"
|
|
| "autopilots"
|
|
| "agents"
|
|
| "squads"
|
|
| "usage"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
// Static schema (key + icon) — labels resolved at render via useT("layout").
|
|
type NavLabelKey =
|
|
| "inbox"
|
|
| "my_issues"
|
|
| "issues"
|
|
| "projects"
|
|
| "autopilots"
|
|
| "agents"
|
|
| "squads"
|
|
| "usage"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
const personalNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
|
{ key: "inbox", labelKey: "inbox", icon: Inbox },
|
|
{ key: "myIssues", labelKey: "my_issues", icon: CircleUser },
|
|
];
|
|
|
|
const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
|
{ key: "issues", labelKey: "issues", icon: ListTodo },
|
|
{ key: "projects", labelKey: "projects", icon: FolderKanban },
|
|
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
|
|
{ key: "agents", labelKey: "agents", icon: Bot },
|
|
{ key: "squads", labelKey: "squads", icon: Users },
|
|
{ key: "usage", labelKey: "usage", icon: BarChart3 },
|
|
];
|
|
|
|
const configureNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[] = [
|
|
{ key: "runtimes", labelKey: "runtimes", icon: Monitor },
|
|
{ key: "skills", labelKey: "skills", icon: BookOpenText },
|
|
{ key: "settings", labelKey: "settings", icon: Settings },
|
|
];
|
|
|
|
function DraftDot() {
|
|
const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description));
|
|
if (!hasDraft) return null;
|
|
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
|
|
}
|
|
|
|
/**
|
|
* Presentational pin row. The `label` and `iconNode` are computed by the
|
|
* parent `PinRow` from cached issue / project detail queries — keeping
|
|
* this component dumb means the dnd-kit / navigation wiring lives in
|
|
* one place and the data flow is explicit.
|
|
*/
|
|
function SortablePinItem({
|
|
pin,
|
|
href,
|
|
pathname,
|
|
onUnpin,
|
|
label,
|
|
iconNode,
|
|
}: {
|
|
pin: PinnedItem;
|
|
href: string;
|
|
pathname: string;
|
|
onUnpin: () => void;
|
|
label: string;
|
|
iconNode: React.ReactNode;
|
|
}) {
|
|
const { t } = useT("layout");
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
|
const wasDragged = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (isDragging) wasDragged.current = true;
|
|
}, [isDragging]);
|
|
|
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
|
const isActive = pathname === href;
|
|
|
|
return (
|
|
<SidebarMenuItem
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={cn("group/pin", isDragging && "opacity-30")}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<SidebarMenuButton
|
|
size="sm"
|
|
isActive={isActive}
|
|
render={<AppLink href={href} draggable={false} />}
|
|
onClick={(event) => {
|
|
if (wasDragged.current) {
|
|
wasDragged.current = false;
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}}
|
|
className={cn(
|
|
"text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground",
|
|
isDragging && "pointer-events-none",
|
|
)}
|
|
>
|
|
{iconNode}
|
|
<span
|
|
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap"
|
|
style={{
|
|
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
|
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
|
|
}}
|
|
>{label}</span>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={<span role="button" />}
|
|
className="hidden size-2.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground group-hover/pin:flex hover:text-foreground"
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onUnpin();
|
|
}}
|
|
>
|
|
<X className="size-1" />
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" sideOffset={4}>{t(($) => $.sidebar.unpin_tooltip)}</TooltipContent>
|
|
</Tooltip>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Smart wrapper that resolves a pin's display data (label + status/icon)
|
|
* from the issue / project detail query cache. Both queries are declared
|
|
* unconditionally with `enabled` gates so the hook order stays stable
|
|
* regardless of `pin.item_type`.
|
|
*
|
|
* Loading: render a flat skeleton so the sidebar height doesn't jump.
|
|
* Missing (deleted item / 404): render nothing — the row hides itself
|
|
* until the user unpins manually or a server-side cascade catches up.
|
|
*/
|
|
function PinRow({
|
|
pin,
|
|
href,
|
|
pathname,
|
|
onUnpin,
|
|
wsId,
|
|
}: {
|
|
pin: PinnedItem;
|
|
href: string;
|
|
pathname: string;
|
|
onUnpin: () => void;
|
|
wsId: string;
|
|
}) {
|
|
const isIssue = pin.item_type === "issue";
|
|
const issueQuery = useQuery({
|
|
...issueDetailOptions(wsId, pin.item_id),
|
|
enabled: isIssue,
|
|
});
|
|
const projectQuery = useQuery({
|
|
...projectDetailOptions(wsId, pin.item_id),
|
|
enabled: !isIssue,
|
|
});
|
|
|
|
const triggeredRef = useRef(false);
|
|
useEffect(() => {
|
|
const err = isIssue ? issueQuery.error : projectQuery.error;
|
|
if (err instanceof ApiError && err.status === 404 && !triggeredRef.current) {
|
|
triggeredRef.current = true;
|
|
onUnpin();
|
|
}
|
|
}, [isIssue, issueQuery.error, onUnpin, projectQuery.error]);
|
|
|
|
if (isIssue) {
|
|
if (issueQuery.isPending) return <PinSkeleton />;
|
|
if (issueQuery.isError || !issueQuery.data) return null;
|
|
const issue = issueQuery.data;
|
|
const label = issue.identifier ? `${issue.identifier} ${issue.title}` : issue.title;
|
|
const iconNode = (
|
|
/* Override parent [&_svg]:size-4 — pinned items need smaller icons to match sm size */
|
|
<StatusIcon status={issue.status} className="!size-3.5 shrink-0" />
|
|
);
|
|
return (
|
|
<SortablePinItem
|
|
pin={pin}
|
|
href={href}
|
|
pathname={pathname}
|
|
onUnpin={onUnpin}
|
|
label={label}
|
|
iconNode={iconNode}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (projectQuery.isPending) return <PinSkeleton />;
|
|
if (projectQuery.isError || !projectQuery.data) return null;
|
|
const project = projectQuery.data;
|
|
const iconNode = <ProjectIcon project={project} size="sm" />;
|
|
return (
|
|
<SortablePinItem
|
|
pin={pin}
|
|
href={href}
|
|
pathname={pathname}
|
|
onUnpin={onUnpin}
|
|
label={project.title}
|
|
iconNode={iconNode}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function PinSkeleton() {
|
|
return (
|
|
<SidebarMenuItem>
|
|
<div className="flex h-7 w-full items-center gap-2 px-2">
|
|
<div className="size-3.5 shrink-0 rounded-sm bg-sidebar-accent/40" />
|
|
<div className="h-3 w-24 rounded bg-sidebar-accent/40" />
|
|
</div>
|
|
</SidebarMenuItem>
|
|
);
|
|
}
|
|
|
|
interface AppSidebarProps {
|
|
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
|
|
topSlot?: React.ReactNode;
|
|
/** Rendered in the header between workspace switcher and new-issue button (e.g. search trigger) */
|
|
searchSlot?: React.ReactNode;
|
|
/** Extra className for SidebarHeader */
|
|
headerClassName?: string;
|
|
/** Extra style for SidebarHeader */
|
|
headerStyle?: React.CSSProperties;
|
|
}
|
|
|
|
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
|
|
const { t } = useT("layout");
|
|
const { pathname, push } = useNavigation();
|
|
const user = useAuthStore((s) => s.user);
|
|
const userId = useAuthStore((s) => s.user?.id);
|
|
const logout = useLogout();
|
|
const workspace = useCurrentWorkspace();
|
|
const p = useWorkspacePaths();
|
|
const { data: workspaces = EMPTY_WORKSPACES } = useQuery(workspaceListOptions());
|
|
const { data: myInvitations = EMPTY_INVITATIONS } = useQuery(myInvitationListOptions());
|
|
const workspaceCreationDisabled = useConfigStore((s) => s.workspaceCreationDisabled);
|
|
|
|
const wsId = workspace?.id;
|
|
const { data: inboxItems = EMPTY_INBOX } = useQuery({
|
|
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
|
queryFn: () => api.listInbox(),
|
|
enabled: !!wsId,
|
|
});
|
|
const unreadCount = React.useMemo(
|
|
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
|
[inboxItems],
|
|
);
|
|
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
|
const { data: pinnedItems = EMPTY_PINS } = useQuery({
|
|
...pinListOptions(wsId ?? "", userId ?? ""),
|
|
enabled: !!wsId && !!userId,
|
|
});
|
|
const deletePin = useDeletePin();
|
|
const reorderPins = useReorderPins();
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
const sidebarScrollRef = useRef<HTMLDivElement>(null);
|
|
const sidebarFadeStyle = useScrollFade(sidebarScrollRef, 24);
|
|
|
|
// Local presentational copy of pinnedItems for drop-animation stability.
|
|
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
|
|
// write (our own optimistic update, or a WS refetch) cannot reorder the
|
|
// DOM under dnd-kit while its drop animation is still interpolating.
|
|
const [localPinned, setLocalPinned] = useState<PinnedItem[]>(pinnedItems);
|
|
const [localPinnedWsId, setLocalPinnedWsId] = useState<string | null>(wsId ?? null);
|
|
const isDraggingRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!isDraggingRef.current) {
|
|
setLocalPinned(pinnedItems);
|
|
}
|
|
}, [pinnedItems]);
|
|
useEffect(() => {
|
|
setLocalPinnedWsId(wsId ?? null);
|
|
}, [wsId]);
|
|
const visiblePinned = localPinnedWsId === (wsId ?? null) ? localPinned : EMPTY_PINS;
|
|
|
|
const handleDragStart = useCallback(() => {
|
|
isDraggingRef.current = true;
|
|
}, []);
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
isDraggingRef.current = false;
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const oldIndex = localPinned.findIndex((p) => p.id === active.id);
|
|
const newIndex = localPinned.findIndex((p) => p.id === over.id);
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
const reordered = arrayMove(localPinned, oldIndex, newIndex);
|
|
setLocalPinned(reordered);
|
|
reorderPins.mutate(reordered);
|
|
},
|
|
[localPinned, reorderPins],
|
|
);
|
|
|
|
const queryClient = useQueryClient();
|
|
const acceptInvitationMut = useMutation({
|
|
mutationFn: (id: string) => api.acceptInvitation(id),
|
|
// After accepting an invitation, navigate INTO the newly-joined workspace.
|
|
// Otherwise the user stays on their current workspace and just sees the
|
|
// new one appear in the dropdown — silent and confusing (this is MUL-820).
|
|
onSuccess: async (_, invitationId) => {
|
|
const invitation = myInvitations.find((i) => i.id === invitationId);
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
|
// staleTime: 0 forces a real network fetch — we need the joined workspace
|
|
// in the list before we can resolve its slug for navigation.
|
|
const list = await queryClient.fetchQuery({
|
|
...workspaceListOptions(),
|
|
staleTime: 0,
|
|
});
|
|
const joined = invitation
|
|
? list.find((w) => w.id === invitation.workspace_id)
|
|
: null;
|
|
if (joined) {
|
|
push(paths.workspace(joined.slug).issues());
|
|
}
|
|
},
|
|
});
|
|
const declineInvitationMut = useMutation({
|
|
mutationFn: (id: string) => api.declineInvitation(id),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: workspaceKeys.myInvitations() });
|
|
},
|
|
});
|
|
|
|
// Global "C" shortcut: opens whichever create mode the user landed on last
|
|
// (agent vs manual), persisted in useCreateModeStore. The mode switch lives
|
|
// inside both modal footers so users can flip without remembering which
|
|
// shortcut goes where — `c` always means "open the create flow I prefer".
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key !== "c" && e.key !== "C") return;
|
|
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
const isEditable =
|
|
tag === "INPUT" ||
|
|
tag === "TEXTAREA" ||
|
|
tag === "SELECT" ||
|
|
(e.target as HTMLElement)?.isContentEditable;
|
|
if (isEditable) return;
|
|
if (useModalStore.getState().modal) return;
|
|
e.preventDefault();
|
|
// Auto-fill project when on a project detail page. The manual form
|
|
// consumes `project_id`; quick-create also honours it as a seed for
|
|
// its project picker, so passing it through is safe for both modes.
|
|
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
|
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
|
|
openCreateIssueWithPreference(data);
|
|
};
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [pathname]);
|
|
|
|
return (
|
|
<Sidebar variant="inset">
|
|
{topSlot}
|
|
{/* Workspace Switcher */}
|
|
<SidebarHeader className={cn("py-3", headerClassName)} style={headerStyle}>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<SidebarMenuButton>
|
|
<span className="relative">
|
|
<WorkspaceAvatar name={workspace?.name ?? "M"} avatarUrl={workspace?.avatar_url} size="sm" />
|
|
{myInvitations.length > 0 && (
|
|
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-brand ring-1 ring-sidebar" />
|
|
)}
|
|
</span>
|
|
<span className="flex-1 truncate font-medium">
|
|
{workspace?.name ?? "Multica"}
|
|
</span>
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</SidebarMenuButton>
|
|
}
|
|
/>
|
|
<DropdownMenuContent
|
|
className="w-auto min-w-56"
|
|
align="start"
|
|
side="bottom"
|
|
sideOffset={4}
|
|
>
|
|
<div className="flex items-center gap-2.5 px-2 py-1.5">
|
|
<ActorAvatar
|
|
name={user?.name ?? ""}
|
|
initials={(user?.name ?? "U").charAt(0).toUpperCase()}
|
|
avatarUrl={resolvePublicFileUrl(user?.avatar_url)}
|
|
size={32}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium leading-tight">
|
|
{user?.name}
|
|
</p>
|
|
<p className="truncate text-xs text-muted-foreground leading-tight">
|
|
{user?.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
{t(($) => $.sidebar.workspaces_label)}
|
|
</DropdownMenuLabel>
|
|
{workspaces.map((ws) => (
|
|
<DropdownMenuItem
|
|
key={ws.id}
|
|
render={
|
|
<AppLink href={paths.workspace(ws.slug).issues()} />
|
|
}
|
|
>
|
|
<WorkspaceAvatar name={ws.name} avatarUrl={ws.avatar_url} 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>
|
|
))}
|
|
{!workspaceCreationDisabled && (
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
useModalStore.getState().open("create-workspace")
|
|
}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{t(($) => $.sidebar.create_workspace)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuGroup>
|
|
{myInvitations.length > 0 && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
{t(($) => $.sidebar.pending_invitations_label)}
|
|
</DropdownMenuLabel>
|
|
{myInvitations.map((inv) => (
|
|
<div key={inv.id} className="flex items-center gap-2 px-2 py-1.5">
|
|
<WorkspaceAvatar name={inv.workspace_name ?? "W"} size="sm" />
|
|
<span className="flex-1 truncate text-sm">{inv.workspace_name ?? t(($) => $.sidebar.invitation_workspace_fallback)}</span>
|
|
<button
|
|
type="button"
|
|
className="text-xs px-2 py-0.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
disabled={acceptInvitationMut.isPending}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
acceptInvitationMut.mutate(inv.id);
|
|
}}
|
|
>
|
|
{t(($) => $.sidebar.invitation_join)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80 disabled:opacity-50"
|
|
disabled={declineInvitationMut.isPending}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
declineInvitationMut.mutate(inv.id);
|
|
}}
|
|
>
|
|
{t(($) => $.sidebar.invitation_decline)}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
</>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem variant="destructive" onClick={logout}>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
{t(($) => $.sidebar.log_out)}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
<SidebarMenu>
|
|
{searchSlot && (
|
|
<SidebarMenuItem>
|
|
{searchSlot}
|
|
</SidebarMenuItem>
|
|
)}
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
className="text-muted-foreground"
|
|
onClick={() => openCreateIssueWithPreference()}
|
|
>
|
|
<span className="relative">
|
|
<SquarePen />
|
|
<DraftDot />
|
|
</span>
|
|
<span>{t(($) => $.sidebar.new_issue)}</span>
|
|
<kbd className="pointer-events-none ml-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">{t(($) => $.sidebar.new_issue_shortcut)}</kbd>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
{/* Navigation */}
|
|
<SidebarContent ref={sidebarScrollRef} style={sidebarFadeStyle}>
|
|
<SidebarGroup>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu className="gap-0.5">
|
|
{personalNav.map((item) => {
|
|
const href = p[item.key]();
|
|
const isActive = isNavActive(pathname, href);
|
|
return (
|
|
<SidebarMenuItem key={item.key}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
render={<AppLink href={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>{t(($) => $.nav[item.labelKey])}</span>
|
|
{item.key === "inbox" && unreadCount > 0 && (
|
|
<span className="ml-auto text-xs">
|
|
{unreadCount > 99 ? "99+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
{visiblePinned.length > 0 && (
|
|
<Collapsible defaultOpen>
|
|
<SidebarGroup className="group/pinned">
|
|
<SidebarGroupLabel
|
|
render={<CollapsibleTrigger />}
|
|
className="group/trigger cursor-pointer hover:bg-sidebar-accent/70 hover:text-sidebar-accent-foreground"
|
|
>
|
|
<span>{t(($) => $.sidebar.pinned_label)}</span>
|
|
<ChevronRight className="!size-3 ml-1 stroke-[2.5] transition-transform duration-200 group-data-[panel-open]/trigger:rotate-90" />
|
|
<span className="ml-auto text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/pinned:opacity-100">{visiblePinned.length}</span>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={visiblePinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
|
<SidebarMenu className="gap-0.5">
|
|
{visiblePinned.map((pin: PinnedItem) => (
|
|
<PinRow
|
|
key={pin.id}
|
|
pin={pin}
|
|
href={pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id)}
|
|
pathname={pathname}
|
|
onUnpin={() => deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })}
|
|
wsId={wsId ?? ""}
|
|
/>
|
|
))}
|
|
</SidebarMenu>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</SidebarGroupContent>
|
|
</CollapsibleContent>
|
|
</SidebarGroup>
|
|
</Collapsible>
|
|
)}
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>{t(($) => $.sidebar.workspace_group)}</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu className="gap-0.5">
|
|
{workspaceNav.map((item) => {
|
|
const href = p[item.key]();
|
|
const isActive = isNavActive(pathname, href);
|
|
return (
|
|
<SidebarMenuItem key={item.key}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
render={<AppLink href={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>{t(($) => $.nav[item.labelKey])}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>{t(($) => $.sidebar.configure_group)}</SidebarGroupLabel>
|
|
<SidebarGroupContent>
|
|
<SidebarMenu className="gap-0.5">
|
|
{configureNav.map((item) => {
|
|
const href = p[item.key]();
|
|
const isActive = isNavActive(pathname, href);
|
|
return (
|
|
<SidebarMenuItem key={item.key}>
|
|
<SidebarMenuButton
|
|
isActive={isActive}
|
|
render={<AppLink href={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>{t(($) => $.nav[item.labelKey])}</span>
|
|
{item.key === "runtimes" && hasRuntimeUpdates && (
|
|
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
|
|
)}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="p-2">
|
|
<div className="flex justify-end">
|
|
<HelpLauncher />
|
|
</div>
|
|
</SidebarFooter>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|