Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
130c75d1ec Polish desktop sidebar motion 2026-06-19 12:15:47 +08:00
10 changed files with 276 additions and 79 deletions

View File

@@ -49,6 +49,7 @@
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",

View File

@@ -144,7 +144,7 @@ function createWindow(): void {
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 13 },
trafficLightPosition: { x: 16, y: 17 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
@@ -22,41 +23,69 @@ import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const TOP_BAR_HEIGHT_CLASS = "h-12";
const WINDOW_TOOLBAR_CLEARANCE = 184;
const toolbarMotion = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const;
function WindowToolbar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
const navButtonClassName =
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
return (
<div
className="h-12 shrink-0 flex items-center justify-end px-2"
className={cn(
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
TOP_BAR_HEIGHT_CLASS,
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-0.5"
className="flex items-center gap-1 pl-[70px]"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
<SidebarTrigger
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
<div className="flex items-center gap-1">
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
title="Go back"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
title="Go forward"
className={navButtonClassName}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
</div>
);
}
function SidebarTopSpacer() {
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
@@ -72,30 +101,31 @@ function useNativeNavigationGestures() {
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
// left side so tabs don't land under the macOS traffic lights (which live at
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
// the sidebar can be brought back without keyboard shortcut.
// is not occupying main-flow width, leave room for the fixed window toolbar
// so tabs do not land beneath the traffic lights / navigation controls.
function MainTopBar() {
const { state, isMobile } = useSidebar();
const sidebarHidden = state === "collapsed" || isMobile;
return (
<header
className={cn(
"h-12 shrink-0 flex items-center gap-2",
sidebarHidden && "pl-20",
)}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
<motion.header
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
initial={false}
transition={toolbarMotion}
>
{sidebarHidden && (
<SidebarTrigger
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
/>
)}
<TabBar />
</header>
<motion.div
aria-hidden
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
className="absolute inset-y-0 right-0"
initial={false}
transition={toolbarMotion}
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div className="relative z-10 flex h-full items-center">
<TabBar />
</div>
</motion.header>
);
}
@@ -183,9 +213,10 @@ export function DesktopShell() {
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{slug && <WindowToolbar />}
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
@@ -193,7 +224,7 @@ export function DesktopShell() {
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</div>
</motion.div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}

View File

@@ -5,6 +5,7 @@ import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useTranslation } from "react-i18next"
import { motion, useReducedMotion } from "motion/react"
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
import { cn } from "@multica/ui/lib/utils"
@@ -173,7 +174,12 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile, isResizing } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile, isResizing, width } = useSidebar()
const reducedMotion = useReducedMotion()
const animateOffcanvas = collapsible === "offcanvas"
const motionTransition = reducedMotion
? { duration: 0 }
: { type: "spring" as const, stiffness: 420, damping: 38, mass: 0.8 }
if (collapsible === "none") {
return (
@@ -226,31 +232,49 @@ function Sidebar({
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
<motion.div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
!isResizing && "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
!animateOffcanvas && !isResizing && "transition-[width] duration-200 ease-linear",
!animateOffcanvas && "group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
? !animateOffcanvas && "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: !animateOffcanvas && "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
animate={animateOffcanvas ? { width: state === "collapsed" ? 0 : width } : undefined}
initial={false}
transition={motionTransition}
/>
<div
<motion.div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
!isResizing && "transition-[left,right,width] duration-200 ease-linear",
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
animateOffcanvas
? side === "left"
? "left-0"
: "right-0"
: "data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
!animateOffcanvas && !isResizing && "transition-[left,right,width] duration-200 ease-linear",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
animate={
animateOffcanvas
? {
x: state === "collapsed" ? (side === "left" ? -width : width) : 0,
width,
}
: undefined
}
initial={false}
transition={motionTransition}
{...(props as React.ComponentProps<typeof motion.div>)}
>
<div
data-sidebar="sidebar"
@@ -259,7 +283,7 @@ function Sidebar({
>
{children}
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -37,6 +37,7 @@
"linkify-it": "^5.0.0",
"katex": "catalog:",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react-day-picker": "^9.14.0",
"react-markdown": "^10.1.0",

View File

@@ -218,6 +218,25 @@
color: var(--sidebar-accent-foreground);
}
/* Right detail sidebars use react-resizable-panels, which sizes panels by
* updating the outer panel's flex-grow. Animate that property for button
* toggles, but disable it while the resize handle is actively dragged. */
[data-right-sidebar-panel] {
transition-property: flex-grow;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
[data-group]:has(> [data-separator="active"]) > [data-right-sidebar-panel] {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
[data-right-sidebar-panel] {
transition: none;
}
}
/* Sonner toast: align icon to first line of text, not vertically centered */
[data-sonner-toast] {
align-items: flex-start !important;

View File

@@ -87,6 +87,11 @@ import { ProgressRing } from "./progress-ring";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
import { useT } from "../../i18n";
import { useIssueDetailScrollRestore } from "../hooks/use-issue-detail-scroll-restore";
import {
AnimatedRightSidebar,
rightSidebarPanelMotionProps,
useAnimatedRightSidebarState,
} from "../../layout/animated-right-sidebar";
function SubscriberPopoverContent({
members,
@@ -683,7 +688,12 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
});
const sidebarRef = usePanelRef();
const isMobile = useIsMobile();
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(defaultSidebarOpen);
const {
open: desktopSidebarOpen,
visualOpen: desktopSidebarVisualOpen,
beginToggle: beginDesktopSidebarToggle,
handleResize: handleDesktopSidebarResize,
} = useAnimatedRightSidebarState(defaultSidebarOpen);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
useEffect(() => {
@@ -1296,9 +1306,11 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
const nextOpen = panel.isCollapsed();
beginDesktopSidebarToggle(nextOpen);
if (nextOpen) panel.expand();
else panel.collapse();
}, [isMobile, sidebarRef]);
}, [beginDesktopSidebarToggle, isMobile, sidebarRef]);
useIssueDetailScrollRestore({
restoreKey: `${wsId}:${id}`,
@@ -2155,19 +2167,18 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<ResizableHandle />
<ResizablePanel
id="sidebar"
{...rightSidebarPanelMotionProps}
defaultSize={defaultSidebarOpen ? 320 : 0}
minSize={260}
maxSize={420}
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setDesktopSidebarOpen(size.inPixels > 0)}
onResize={handleDesktopSidebarResize}
>
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
<AnimatedRightSidebar open={desktopSidebarVisualOpen}>
{sidebarContent}
</div>
</div>
</AnimatedRightSidebar>
</ResizablePanel>
</ResizablePanelGroup>
);

View File

@@ -0,0 +1,90 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { motion } from "motion/react";
import { cn } from "@multica/ui/lib/utils";
export const rightSidebarPanelMotionProps = {
"data-right-sidebar-panel": "true",
style: { overflowX: "hidden" },
} as const;
const RIGHT_SIDEBAR_PANEL_TRANSITION_MS = 220;
const RIGHT_SIDEBAR_PANEL_SETTLE_MS = RIGHT_SIDEBAR_PANEL_TRANSITION_MS + 80;
const rightSidebarTransition = {
type: "spring",
stiffness: 420,
damping: 38,
mass: 0.8,
} as const;
export function useAnimatedRightSidebarState(initialOpen: boolean) {
const [open, setOpen] = useState(initialOpen);
const [visualOpen, setVisualOpen] = useState(initialOpen);
const toggleTargetRef = useRef<boolean | null>(null);
const settleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const beginToggle = useCallback((nextOpen: boolean) => {
toggleTargetRef.current = nextOpen;
setOpen(nextOpen);
setVisualOpen(nextOpen);
if (settleTimeoutRef.current) {
clearTimeout(settleTimeoutRef.current);
}
settleTimeoutRef.current = setTimeout(() => {
toggleTargetRef.current = null;
settleTimeoutRef.current = null;
}, RIGHT_SIDEBAR_PANEL_SETTLE_MS);
}, []);
const handleResize = useCallback((size: { inPixels: number }) => {
const nextOpen = size.inPixels > 0;
const toggleTarget = toggleTargetRef.current;
if (toggleTarget === null) {
setOpen(nextOpen);
setVisualOpen(nextOpen);
return;
}
setOpen(toggleTarget);
}, []);
useEffect(() => {
return () => {
if (settleTimeoutRef.current) {
clearTimeout(settleTimeoutRef.current);
}
};
}, []);
return { open, visualOpen, beginToggle, handleResize };
}
export function AnimatedRightSidebar({
open,
children,
className,
}: {
open: boolean;
children: ReactNode;
className?: string;
}) {
return (
<motion.div
animate={{ opacity: open ? 1 : 0, x: open ? 0 : 12 }}
className={cn(
"h-full overflow-x-hidden overflow-y-auto border-l",
!open && "pointer-events-none",
className,
)}
initial={false}
transition={rightSidebarTransition}
>
<div className="p-4">{children}</div>
</motion.div>
);
}

View File

@@ -72,6 +72,11 @@ import {
} from "@multica/ui/components/ui/tooltip";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import {
AnimatedRightSidebar,
rightSidebarPanelMotionProps,
useAnimatedRightSidebarState,
} from "../../layout/animated-right-sidebar";
import {
AlertDialog,
AlertDialogAction,
@@ -464,7 +469,12 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
// to `true` made the mobile <Sheet> mount in the open position on first render
// (after `useIsMobile()` flipped from false→true), briefly covering the page
// with its modal backdrop and locking scroll — leaving the page unresponsive.
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true);
const {
open: desktopSidebarOpen,
visualOpen: desktopSidebarVisualOpen,
beginToggle: beginDesktopSidebarToggle,
handleResize: handleDesktopSidebarResize,
} = useAnimatedRightSidebarState(true);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const sidebarOpen = isMobile ? mobileSidebarOpen : desktopSidebarOpen;
@@ -474,6 +484,20 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
}
}, [isMobile]);
const handleToggleSidebar = useCallback(() => {
if (isMobile) {
setMobileSidebarOpen((open) => !open);
return;
}
const panel = sidebarRef.current;
if (!panel) return;
const nextOpen = panel.isCollapsed();
beginDesktopSidebarToggle(nextOpen);
if (nextOpen) panel.expand();
else panel.collapse();
}, [beginDesktopSidebarToggle, isMobile, sidebarRef]);
// Lead popover
const [leadOpen, setLeadOpen] = useState(false);
const [leadFilter, setLeadFilter] = useState("");
@@ -798,16 +822,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
variant={sidebarOpen ? "secondary" : "ghost"}
size="icon-sm"
className={sidebarOpen ? "" : "text-muted-foreground"}
onClick={() => {
if (isMobile) {
setMobileSidebarOpen((open) => !open);
} else {
const panel = sidebarRef.current;
if (!panel) return;
if (panel.isCollapsed()) panel.expand();
else panel.collapse();
}
}}
onClick={handleToggleSidebar}
>
<PanelRight />
</Button>
@@ -832,19 +847,18 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
{!isMobile && (
<ResizablePanel
id="sidebar"
{...rightSidebarPanelMotionProps}
defaultSize={desktopSidebarOpen ? 320 : 0}
minSize={260}
maxSize={420}
collapsible
groupResizeBehavior="preserve-pixel-size"
panelRef={sidebarRef}
onResize={(size) => setDesktopSidebarOpen(size.inPixels > 0)}
onResize={handleDesktopSidebarResize}
>
<div className="overflow-y-auto border-l h-full">
<div className="p-4">
{sidebarContent}
</div>
</div>
<AnimatedRightSidebar open={desktopSidebarVisualOpen}>
{sidebarContent}
</AnimatedRightSidebar>
</ResizablePanel>
)}
{isMobile && (

6
pnpm-lock.yaml generated
View File

@@ -187,6 +187,9 @@ importers:
lucide-react:
specifier: 'catalog:'
version: 1.0.1(react@19.2.3)
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 'catalog:'
version: 19.2.3
@@ -863,6 +866,9 @@ importers:
lucide-react:
specifier: 'catalog:'
version: 1.0.1(react@19.2.3)
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)