mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-12 19:06:06 +02:00
tokens.css defined colours, radii and font families but not a single --text-* step, so font sizes had no baseline to align to and grew wherever they were needed: 51 distinct sizes across web + desktop, 370 written as arbitrary values, six at half a pixel (10.5 / 11.5 / 12.5 / 13.5 / 14.5 / 15.5px). text-xs and text-sm carried nearly all UI text while the range between them — 11, 13, 15px — could only be reached with arbitrary values. Hierarchy does not come from having more sizes; past a handful, each extra size makes the hierarchy blurrier. Add ten role-named steps, each with its own line-height so leading cannot fragment the way size did, and move every product-UI call site onto them. Steps are named for what the text is for, not for a t-shirt size, because that is what keeps the scale from drifting again. Six steps deliberately keep the exact size/line-height pairs of the Tailwind defaults they replace, so the ~1,900-call-site rename moves nothing on screen. The visible changes are confined to former arbitrary values snapping to a step: 8/9/10px -> micro (11px) on badges and overlines; 17 -> 18; 22 -> 24; 30 (text-3xl) -> 36 on headings and stat numbers; 12.8px -> label (13px) on small buttons and toggles. Half-pixel sizes are gone. This supersedes #6108, which was reverted by #6116 because the sidebar group labels rendered at the inherited 16px. The cause was not the scale but cn(): `text-<x>` is ambiguous in Tailwind, and tailwind-merge resolves it against a table listing only the default sizes, so it filed every role step under text-colour and dropped whichever of `text-caption` / `text-sidebar-foreground/70` came first. Registering the steps as a font-size class group restores the real conflict groups — size beats size, colour beats colour, the two coexist — and a test pins the list against the scale, since the failure is silent in source. Hand-written CSS is covered too. The transcript kept a 12.5px body long after every Tailwind call site was on the scale, so the "no half-pixel sizes" claim was true of the classes and false of the product; the editor's prose, code and mermaid ramps had the same blind spot, and seven of their eight values already equalled a step exactly. All now reference var(--text-*). The guard test reads raw `font-size:` declarations as well as class names, exempting only the 16px iOS input-zoom workaround in base.css and the landing pages' marketing ramp. apps/mobile (own NativeWind config) and apps/docs (fumadocs' own type system) keep Tailwind's default scale and are untouched. Landing display type (rem/clamp, 2.2-6.4rem) stays on its separate ramp, as do four decorative emoji / serif-hero sizes. Verified on a running local stack: pinned sidebar rows and group labels measure 12px/16px, nav items 14px/20px — identical to pre-migration. An audit of every rendered font size across the product surfaces finds nothing off the scale; the only exceptions are avatar initials and emoji, which actor-avatar.tsx sizes proportionally to the avatar diameter by design. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
795 lines
32 KiB
TypeScript
795 lines
32 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 { JoinDiscordCard } from "./join-discord-card";
|
|
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 {
|
|
ChevronDown,
|
|
ChevronRight,
|
|
LogOut,
|
|
Plus,
|
|
Check,
|
|
SquarePen,
|
|
X,
|
|
} 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 { CappedNumberFlow } from "@multica/ui/components/ui/number-flow";
|
|
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, inboxUnreadSummaryOptions, hasOtherWorkspaceUnread, unreadWorkspaceIds } from "@multica/core/inbox/queries";
|
|
import { chatSessionsOptions } from "@multica/core/chat/queries";
|
|
import { countUnreadChatMessages } from "@multica/core/chat/unread";
|
|
import { useChatStore } from "@multica/core/chat";
|
|
import { api, ApiError } from "@multica/core/api";
|
|
import { useModalStore } from "@multica/core/modals";
|
|
import { useConfigStore } from "@multica/core/config";
|
|
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 { routeIconForPath } from "./route-icon-components";
|
|
import { useT } from "../i18n";
|
|
import {
|
|
useShortcut,
|
|
} from "@multica/core/shortcuts";
|
|
import { ShortcutKeycaps } from "../common/shortcut-keycaps";
|
|
import { useAppForeground } from "../common/use-app-foreground";
|
|
|
|
// 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>> = [];
|
|
const EMPTY_INBOX_SUMMARY: Awaited<ReturnType<typeof api.getInboxUnreadSummary>> = [];
|
|
|
|
// 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"
|
|
| "chat"
|
|
| "myIssues"
|
|
| "issues"
|
|
| "projects"
|
|
| "autopilots"
|
|
| "agents"
|
|
| "squads"
|
|
| "usage"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
// Static schema (key only) — labels resolved at render via useT("layout"),
|
|
// icons derived from the destination path via routeIconForPath.
|
|
type NavLabelKey =
|
|
| "inbox"
|
|
| "chat"
|
|
| "my_issues"
|
|
| "issues"
|
|
| "projects"
|
|
| "autopilots"
|
|
| "agents"
|
|
| "squads"
|
|
| "usage"
|
|
| "runtimes"
|
|
| "skills"
|
|
| "settings";
|
|
|
|
// Nav icons are NOT declared here: they are derived from each item's
|
|
// destination path at render time, so the sidebar and the desktop tab bar
|
|
// always agree. See route-icon-components.tsx.
|
|
const personalNav: { key: NavKey; labelKey: NavLabelKey }[] = [
|
|
{ key: "inbox", labelKey: "inbox" },
|
|
{ key: "chat", labelKey: "chat" },
|
|
{ key: "myIssues", labelKey: "my_issues" },
|
|
];
|
|
|
|
const workspaceNav: { key: NavKey; labelKey: NavLabelKey }[] = [
|
|
{ key: "issues", labelKey: "issues" },
|
|
{ key: "projects", labelKey: "projects" },
|
|
{ key: "autopilots", labelKey: "autopilots" },
|
|
{ key: "agents", labelKey: "agents" },
|
|
{ key: "squads", labelKey: "squads" },
|
|
{ key: "usage", labelKey: "usage" },
|
|
];
|
|
|
|
const configureNav: { key: NavKey; labelKey: NavLabelKey }[] = [
|
|
{ key: "runtimes", labelKey: "runtimes" },
|
|
{ key: "skills", labelKey: "skills" },
|
|
{ key: "settings", labelKey: "settings" },
|
|
];
|
|
|
|
function DraftDot() {
|
|
const hasDraft = useIssueDraftStore((s) => s.hasDraft());
|
|
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.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],
|
|
);
|
|
// Chat tab unread badge: IM-style total of unread *messages* across chat
|
|
// threads (countUnreadChatMessages is the shared definition — mobile's tab
|
|
// badge derives from the same function, keeping the platforms in agreement).
|
|
const { data: chatSessions = [] } = useQuery({
|
|
...chatSessionsOptions(wsId ?? ""),
|
|
enabled: !!wsId,
|
|
});
|
|
// The session the user is reading right now must not count: the thread list
|
|
// renders its row badge as 0 (auto mark-read is about to clear it), and a
|
|
// reply landing in the open conversation would otherwise flash a sidebar
|
|
// count with no matching row. "Reading right now" = a session is active, a
|
|
// chat surface is actually showing it (chat page route or the floating
|
|
// window), AND the app is in the foreground. When the app is backgrounded,
|
|
// auto mark-read is suppressed (MUL-4485) so the reply stays unread — the
|
|
// badge must count it, or the notification is silently eaten while the user
|
|
// is away. A remembered selection while both surfaces are closed also still
|
|
// counts, for the same reason.
|
|
const activeChatSessionId = useChatStore((s) => s.activeSessionId);
|
|
const floatingChatOpen = useChatStore((s) => s.isOpen);
|
|
const appForeground = useAppForeground();
|
|
const chatHref = p.chat();
|
|
const viewedChatSessionId =
|
|
appForeground && (floatingChatOpen || isNavActive(pathname, chatHref))
|
|
? activeChatSessionId
|
|
: null;
|
|
const chatUnreadCount = React.useMemo(
|
|
() => countUnreadChatMessages(chatSessions, viewedChatSessionId),
|
|
[chatSessions, viewedChatSessionId],
|
|
);
|
|
// Cross-workspace unread summary backs the workspace-switcher dot. One
|
|
// shared cache entry across workspaces; gated on an active workspace since
|
|
// the endpoint resolves through the workspace-member middleware.
|
|
const { data: unreadSummary = EMPTY_INBOX_SUMMARY } = useQuery({
|
|
...inboxUnreadSummaryOptions(),
|
|
enabled: !!wsId,
|
|
});
|
|
const otherWorkspaceUnread = React.useMemo(
|
|
() => hasOtherWorkspaceUnread(unreadSummary, wsId),
|
|
[unreadSummary, wsId],
|
|
);
|
|
// Which workspaces have unread, so the switcher dropdown can point at the
|
|
// specific one(s) rather than just the aggregate avatar dot.
|
|
const unreadWsIds = React.useMemo(() => unreadWorkspaceIds(unreadSummary), [unreadSummary]);
|
|
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);
|
|
const getPinHref = useCallback(
|
|
(pin: PinnedItem) => (pin.item_type === "issue" ? p.issueDetail(pin.item_id) : p.projectDetail(pin.item_id)),
|
|
[p],
|
|
);
|
|
|
|
// 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 isActivePinnedRoute = visiblePinned.some((pin) => pathname === getPinHref(pin));
|
|
|
|
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() });
|
|
},
|
|
});
|
|
|
|
const createIssueShortcut = useShortcut("createIssue");
|
|
|
|
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" />
|
|
{/* Shared brand dot: a pending invitation OR another
|
|
workspace with unread inbox items. The active
|
|
workspace's own unread stays on the Inbox nav count
|
|
(below), so it is deliberately excluded here. */}
|
|
{(myInvitations.length > 0 || otherWorkspaceUnread) && (
|
|
<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="lg"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-body font-medium leading-tight">
|
|
{user?.name}
|
|
</p>
|
|
<p className="truncate text-caption text-muted-foreground leading-tight">
|
|
{user?.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel className="text-caption 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>
|
|
{/* Points at the specific workspace holding unread
|
|
inbox items. Sits in the same right-edge slot as the
|
|
active-workspace check; the active workspace is
|
|
excluded (its unread is the Inbox nav count), so dot
|
|
and check never collide on one row. */}
|
|
{ws.id !== workspace?.id && unreadWsIds.has(ws.id) && (
|
|
<span className="size-2 rounded-full bg-brand" />
|
|
)}
|
|
{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-caption 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-body">{inv.workspace_name ?? t(($) => $.sidebar.invitation_workspace_fallback)}</span>
|
|
<button
|
|
type="button"
|
|
className="text-caption 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-caption 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>
|
|
{createIssueShortcut ? (
|
|
<ShortcutKeycaps shortcut={createIssueShortcut} decorative className="pointer-events-none ml-auto" />
|
|
) : null}
|
|
</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 Icon = routeIconForPath(href);
|
|
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"
|
|
>
|
|
<Icon />
|
|
<span>{t(($) => $.nav[item.labelKey])}</span>
|
|
{item.key === "inbox" && unreadCount > 0 && (
|
|
<CappedNumberFlow
|
|
value={unreadCount}
|
|
animated={false}
|
|
className="ml-auto text-caption"
|
|
/>
|
|
)}
|
|
{item.key === "chat" && chatUnreadCount > 0 && (
|
|
<CappedNumberFlow
|
|
value={chatUnreadCount}
|
|
animated={false}
|
|
className="ml-auto text-caption"
|
|
/>
|
|
)}
|
|
</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-micro 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={getPinHref(pin)}
|
|
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 Icon = routeIconForPath(href);
|
|
const isActive = !isActivePinnedRoute && 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"
|
|
>
|
|
<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 Icon = routeIconForPath(href);
|
|
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"
|
|
>
|
|
<Icon />
|
|
<span>{t(($) => $.nav[item.labelKey])}</span>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
);
|
|
})}
|
|
</SidebarMenu>
|
|
</SidebarGroupContent>
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
|
|
<SidebarFooter className="p-2">
|
|
<JoinDiscordCard />
|
|
<div className="flex justify-end">
|
|
<HelpLauncher />
|
|
</div>
|
|
</SidebarFooter>
|
|
<SidebarRail />
|
|
</Sidebar>
|
|
);
|
|
}
|