mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 14:09:22 +02:00
Recasts Quick/Advanced as Agent/Manual and lets users flip between modes in-place from a footer switch button instead of a separate Advanced shortcut. The two old modal types now route through one CreateIssueDialog shell that owns the single <Dialog> and <DialogContent> — only the inner panel body swaps on mode change, so the Portal/Backdrop/Popup stay mounted and the switch is instant (no close→open animation flash). Mode preference is persisted globally in localStorage via a small useCreateModeStore, so the `c` shortcut always opens whichever mode the user last used (or switched to). Carry payload (description / agent / prompt) hands off through the shell's local state plus the existing issue-draft store, so nothing the user typed is lost across switches. Also drops the Shift+C → manual branch — `c` is now mode-agnostic and the in-modal switch covers the same intent without users having to remember a second shortcut. Visible labels: "Quick create" → "Create with agent", "New issue" → "Create manually".
702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
import { cn } from "@multica/ui/lib/utils";
|
|
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,
|
|
X,
|
|
Zap,
|
|
} 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 { useCreateModeStore } 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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
|
import { api } from "@multica/core/api";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
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";
|
|
|
|
// 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"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
const personalNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
|
{ key: "inbox", label: "Inbox", icon: Inbox },
|
|
{ key: "myIssues", label: "My Issues", icon: CircleUser },
|
|
];
|
|
|
|
const workspaceNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
|
{ key: "issues", label: "Issues", icon: ListTodo },
|
|
{ key: "projects", label: "Projects", icon: FolderKanban },
|
|
{ key: "autopilots", label: "Autopilot", icon: Zap },
|
|
{ key: "agents", label: "Agents", icon: Bot },
|
|
];
|
|
|
|
const configureNav: { key: NavKey; label: string; icon: typeof Inbox }[] = [
|
|
{ key: "runtimes", label: "Runtimes", icon: Monitor },
|
|
{ key: "skills", label: "Skills", icon: BookOpenText },
|
|
{ key: "settings", label: "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 { 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}>Unpin</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,
|
|
});
|
|
|
|
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 { 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 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 } }));
|
|
|
|
// 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 isDraggingRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!isDraggingRef.current) {
|
|
setLocalPinned(pinnedItems);
|
|
}
|
|
}, [pinnedItems]);
|
|
|
|
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();
|
|
const lastMode = useCreateModeStore.getState().lastMode;
|
|
if (lastMode === "manual") {
|
|
// Auto-fill project when on a project detail page (manual form only —
|
|
// agent mode lets the agent infer project from the prompt).
|
|
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
|
|
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
|
|
useModalStore.getState().open("create-issue", data);
|
|
} else {
|
|
useModalStore.getState().open("quick-create-issue");
|
|
}
|
|
};
|
|
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"} 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={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">
|
|
Workspaces
|
|
</DropdownMenuLabel>
|
|
{workspaces.map((ws) => (
|
|
<DropdownMenuItem
|
|
key={ws.id}
|
|
render={
|
|
<AppLink href={paths.workspace(ws.slug).issues()} />
|
|
}
|
|
>
|
|
<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>
|
|
))}
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
useModalStore.getState().open("create-workspace")
|
|
}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Create workspace
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
{myInvitations.length > 0 && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
Pending invitations
|
|
</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 ?? "Workspace"}</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);
|
|
}}
|
|
>
|
|
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);
|
|
}}
|
|
>
|
|
Decline
|
|
</button>
|
|
</div>
|
|
))}
|
|
</DropdownMenuGroup>
|
|
</>
|
|
)}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem variant="destructive" onClick={logout}>
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
Log out
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
<SidebarMenu>
|
|
{searchSlot && (
|
|
<SidebarMenuItem>
|
|
{searchSlot}
|
|
</SidebarMenuItem>
|
|
)}
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton
|
|
className="text-muted-foreground"
|
|
onClick={() => useModalStore.getState().open("create-issue")}
|
|
>
|
|
<span className="relative">
|
|
<SquarePen />
|
|
<DraftDot />
|
|
</span>
|
|
<span>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">C</kbd>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarHeader>
|
|
|
|
{/* Navigation */}
|
|
<SidebarContent>
|
|
<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>{item.label}</span>
|
|
{item.label === "Inbox" && unreadCount > 0 && (
|
|
<span className="ml-auto text-xs">
|
|
{unreadCount > 99 ? "99+" : unreadCount}
|
|
</span>
|
|
)}
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
{localPinned.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>Pinned</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">{localPinned.length}</span>
|
|
</SidebarGroupLabel>
|
|
<CollapsibleContent>
|
|
<SidebarGroupContent>
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
<SortableContext items={localPinned.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
|
<SidebarMenu className="gap-0.5">
|
|
{localPinned.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>Workspace</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>{item.label}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>Configure</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>{item.label}</span>
|
|
{item.label === "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>
|
|
);
|
|
}
|