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;
}