diff --git a/apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx b/apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx new file mode 100644 index 0000000000..68d1b00e20 --- /dev/null +++ b/apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@multica/ui/components/ui/skeleton"; + +// Rendered by Next.js as the Suspense fallback during route transitions +// inside the (dashboard) segment. Scoped to this segment only — auth / +// landing keep their own full-screen fallbacks. +export default function DashboardLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/platform/navigation.tsx b/apps/web/platform/navigation.tsx index a6ad4e791a..804a9ab66c 100644 --- a/apps/web/platform/navigation.tsx +++ b/apps/web/platform/navigation.tsx @@ -24,6 +24,12 @@ function NavigationProviderInner({ searchParams: new URLSearchParams(searchParams.toString()), getShareableUrl: (path: string) => typeof window === "undefined" ? path : window.location.origin + path, + // router.prefetch is a no-op in dev mode by Next.js design; in production + // it warms the RSC payload + route chunk so the next push() commits with + // no network round-trip. Safe to call repeatedly — Next dedupes internally. + prefetch: (path: string) => { + router.prefetch(path); + }, }; return {children}; diff --git a/packages/ui/styles/base.css b/packages/ui/styles/base.css index 2f92fa4730..b83ec98b90 100644 --- a/packages/ui/styles/base.css +++ b/packages/ui/styles/base.css @@ -114,6 +114,19 @@ animation: chat-text-shimmer 2.5s linear infinite; } +/* Navigation progress bar: 1px brand-tinted indeterminate sweep that shows + * across the top of the dashboard while a transition-wrapped push/replace is + * committing. Driven by useIsNavigating(); independent of the actual network, + * so it disappears the moment React commits the new route. */ +@keyframes nav-progress-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.animate-nav-progress-sweep { + animation: nav-progress-sweep 1.1s ease-in-out infinite; +} + /* Border beam: a brand-tinted highlight sweeps continuously around the * element's rounded border, drawing the eye to a CTA that would otherwise * blend into the chrome (e.g. the "switch to agent" affordance in manual diff --git a/packages/views/layout/dashboard-layout.tsx b/packages/views/layout/dashboard-layout.tsx index 012090c101..938535ac6b 100644 --- a/packages/views/layout/dashboard-layout.tsx +++ b/packages/views/layout/dashboard-layout.tsx @@ -5,6 +5,7 @@ import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar import { ModalRegistry } from "../modals/registry"; import { AppSidebar } from "./app-sidebar"; import { DashboardGuard } from "./dashboard-guard"; +import { NavigationProgress } from "./navigation-progress"; import { WorkspacePresencePrefetch } from "./workspace-presence-prefetch"; interface DashboardLayoutProps { @@ -35,6 +36,7 @@ export function DashboardLayout({ + {children} {extra} diff --git a/packages/views/layout/navigation-progress.tsx b/packages/views/layout/navigation-progress.tsx new file mode 100644 index 0000000000..0dedbf5e83 --- /dev/null +++ b/packages/views/layout/navigation-progress.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useIsNavigating } from "../navigation"; + +// 1px top-of-content progress bar shown while a transition-wrapped +// push/replace is mid-flight. Indeterminate by design — we don't know +// when the next route will commit, just that it's coming. +export function NavigationProgress() { + const isNavigating = useIsNavigating(); + if (!isNavigating) return null; + return ( +
+
+
+ ); +} diff --git a/packages/views/navigation/app-link.test.tsx b/packages/views/navigation/app-link.test.tsx new file mode 100644 index 0000000000..230b0c63e2 --- /dev/null +++ b/packages/views/navigation/app-link.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { AppLink } from "./app-link"; +import { NavigationProvider } from "./context"; +import type { NavigationAdapter } from "./types"; + +function makeAdapter(overrides: Partial = {}): NavigationAdapter { + return { + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + pathname: "/", + searchParams: new URLSearchParams(), + getShareableUrl: (p) => p, + ...overrides, + }; +} + +function renderLink( + adapter: NavigationAdapter, + props: React.ComponentProps = { href: "/issues" }, +) { + return render( + + go + , + ); +} + +describe("AppLink", () => { + it("calls caller onClick BEFORE push so synchronous side effects (close menu, etc) commit before the transition starts", () => { + const order: string[] = []; + const adapter = makeAdapter({ + push: vi.fn(() => order.push("push")), + }); + renderLink(adapter, { + href: "/issues", + onClick: () => order.push("onClick"), + }); + + fireEvent.click(screen.getByText("go")); + expect(order).toEqual(["onClick", "push"]); + }); + + it("calls adapter.prefetch on hover, alongside the caller's onMouseEnter — neither is overridden by {...props}", () => { + const prefetch = vi.fn(); + const callerMouseEnter = vi.fn(); + const adapter = makeAdapter({ prefetch }); + + renderLink(adapter, { + href: "/issues", + onMouseEnter: callerMouseEnter, + }); + + fireEvent.mouseEnter(screen.getByText("go")); + expect(prefetch).toHaveBeenCalledWith("/issues"); + expect(callerMouseEnter).toHaveBeenCalledTimes(1); + }); + + it("calls adapter.prefetch on focus, alongside the caller's onFocus", () => { + const prefetch = vi.fn(); + const callerFocus = vi.fn(); + const adapter = makeAdapter({ prefetch }); + + renderLink(adapter, { + href: "/issues", + onFocus: callerFocus, + }); + + fireEvent.focus(screen.getByText("go")); + expect(prefetch).toHaveBeenCalledWith("/issues"); + expect(callerFocus).toHaveBeenCalledTimes(1); + }); + + it("is a no-op when adapter does not implement prefetch (desktop)", () => { + const adapter = makeAdapter(); + renderLink(adapter); + expect(() => fireEvent.mouseEnter(screen.getByText("go"))).not.toThrow(); + expect(() => fireEvent.focus(screen.getByText("go"))).not.toThrow(); + }); + + it("modifier-click (cmd / ctrl) delegates to openInNewTab and does NOT push", () => { + const push = vi.fn(); + const openInNewTab = vi.fn(); + const adapter = makeAdapter({ push, openInNewTab }); + + renderLink(adapter); + fireEvent.click(screen.getByText("go"), { metaKey: true }); + expect(openInNewTab).toHaveBeenCalledWith("/issues"); + expect(push).not.toHaveBeenCalled(); + }); + + it("a caller-supplied onClick passed via spread cannot silently override the navigation handler", () => { + const push = vi.fn(); + const adapter = makeAdapter({ push }); + const spreadOnClick = vi.fn((e: React.MouseEvent) => e.preventDefault()); + + render( + + {/* simulate a caller that passes onClick through a spread bag */} + + go + + , + ); + + fireEvent.click(screen.getByText("go")); + // Caller still runs (it was hoisted into the named param), but push runs too. + expect(spreadOnClick).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith("/issues"); + }); +}); diff --git a/packages/views/navigation/app-link.tsx b/packages/views/navigation/app-link.tsx index 75417706c3..743252bda5 100644 --- a/packages/views/navigation/app-link.tsx +++ b/packages/views/navigation/app-link.tsx @@ -8,8 +8,11 @@ interface AppLinkProps extends React.AnchorHTMLAttributes { } export const AppLink = forwardRef( - function AppLink({ href, children, onClick, ...props }, ref) { - const { push, openInNewTab } = useNavigation(); + function AppLink( + { href, children, onClick, onMouseEnter, onFocus, ...props }, + ref, + ) { + const { push, openInNewTab, prefetch } = useNavigation(); const handleClick = (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey) { @@ -20,12 +23,35 @@ export const AppLink = forwardRef( return; } e.preventDefault(); + // Caller's onClick runs BEFORE push so any synchronous side effect + // (close popover, clear selection, blur the trigger) lands in the + // same tick rather than getting deferred behind the transition. onClick?.(e); push(href); }; + const handleMouseEnter = (e: React.MouseEvent) => { + prefetch?.(href); + onMouseEnter?.(e); + }; + + const handleFocus = (e: React.FocusEvent) => { + prefetch?.(href); + onFocus?.(e); + }; + return ( - + {children} ); diff --git a/packages/views/navigation/context.tsx b/packages/views/navigation/context.tsx index d55abcb610..65d0b6d2f0 100644 --- a/packages/views/navigation/context.tsx +++ b/packages/views/navigation/context.tsx @@ -1,9 +1,10 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo, useTransition } from "react"; import type { NavigationAdapter } from "./types"; const NavigationContext = createContext(null); +const NavigationPendingContext = createContext(false); export function NavigationProvider({ value, @@ -12,9 +13,25 @@ export function NavigationProvider({ value: NavigationAdapter; children: React.ReactNode; }) { + // Wrap push/replace in startTransition so any caller of useNavigation() + // (sidebar AppLink, command palette, modal post-create jumps) gets a + // React pending signal during route commit. On web this stays true until + // Next.js commits the new RSC payload; on desktop it flips off quickly + // because react-router commits synchronously — both are correct. + const [isPending, startTransition] = useTransition(); + const wrapped = useMemo( + () => ({ + ...value, + push: (path: string) => startTransition(() => value.push(path)), + replace: (path: string) => startTransition(() => value.replace(path)), + }), + [value], + ); return ( - - {children} + + + {children} + ); } @@ -25,3 +42,8 @@ export function useNavigation(): NavigationAdapter { throw new Error("useNavigation must be used within NavigationProvider"); return ctx; } + +/** True while a transition-wrapped push/replace is committing. */ +export function useIsNavigating(): boolean { + return useContext(NavigationPendingContext); +} diff --git a/packages/views/navigation/index.ts b/packages/views/navigation/index.ts index c4debad93d..069b0f7114 100644 --- a/packages/views/navigation/index.ts +++ b/packages/views/navigation/index.ts @@ -1,3 +1,7 @@ -export { NavigationProvider, useNavigation } from "./context"; +export { + NavigationProvider, + useNavigation, + useIsNavigating, +} from "./context"; export { AppLink } from "./app-link"; export type { NavigationAdapter } from "./types"; diff --git a/packages/views/navigation/types.ts b/packages/views/navigation/types.ts index 9de96cc5b8..bffae2f786 100644 --- a/packages/views/navigation/types.ts +++ b/packages/views/navigation/types.ts @@ -8,4 +8,10 @@ export interface NavigationAdapter { openInNewTab?: (path: string, title?: string) => void; /** Return a shareable URL for a path. Web: origin + path. Desktop: public web URL of the connected environment. */ getShareableUrl: (path: string) => string; + /** + * Optional: warm up route assets / RSC payload for a path. Web wires this + * to `router.prefetch`; desktop leaves it undefined because react-router + * already loads the whole SPA. Callers must invoke via `prefetch?.(href)`. + */ + prefetch?: (path: string) => void; }