diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index a700de1c..31253d06 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -2121,25 +2121,44 @@ export default function Home() {
)}
- {/* Mobile/Tablet Sidebar Overlay Backdrop */}
+ {/* Mobile/Tablet Sidebar Overlay Backdrop.
+ When embedded in a Pro pane the viewport is desktop-wide, so the
+ `lg:hidden` viewport-variant alone wouldn't gate this overlay;
+ scope to the pane via `absolute` so the backdrop stays inside
+ the pane instead of covering the whole window. */}
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
setSidebarOpen(false)}
/>
)}
- {/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
+ {/* Sidebar - overlay on mobile/tablet, in-flow on desktop.
+ When embedded, overlay-mode is driven by pane-aware JS rather
+ than viewport-variants (which still see the full window). */}
(null);
+ // Measured pane width, published to children via PaneSizeContext so that
+ // useDeviceDetection / useIsMobile / etc. branch on pane width — not full
+ // viewport — and inner pages collapse to their mobile/tablet layouts when
+ // the pane is narrow.
+ const [paneWidth, setPaneWidth] = useState
(null);
+
+ useEffect(() => {
+ const el = paneRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const initialRect = el.getBoundingClientRect();
+ if (initialRect.width > 0) setPaneWidth(initialRect.width);
+ const ro = new ResizeObserver((entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ const w = entry.contentRect.width;
+ setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
return (
{ if (!isFocused) onPaneFocus(paneId); }}
>
- {tabs
- .filter((tab) => loadedTabIds.includes(tab.id))
- .map((tab) => {
- const isActive = tab.id === activeTabId;
- return (
-
- {renderTabBody(tab)}
-
- );
- })}
+
+ {tabs
+ .filter((tab) => loadedTabIds.includes(tab.id))
+ .map((tab) => {
+ const isActive = tab.id === activeTabId;
+ return (
+
+ {renderTabBody(tab)}
+
+ );
+ })}
+
);
}
@@ -233,8 +259,16 @@ export default function ProHome() {
if (!isDesktop) return null;
+ // Stable keys are essential: when the split collapses, the row's child
+ // list goes from [splitPane, divider, mainPane] (or the leading variant)
+ // to [mainPane]. Without keys, React would reuse the Pane instance at
+ // index 0 — repurposing the *split* pane's instance into the main pane,
+ // which strands the main pane's ResizeObserver/paneWidth on a now-
+ // unmounted DOM node and reparents the mail tab body (causing remount
+ // + stale "still-narrow" measurements after the split is closed).
const mainPane = (
{
if (showBack && onBack) {
@@ -40,7 +47,6 @@ export function MobileHeader({
@@ -118,12 +124,13 @@ export function MobileViewerHeader({
className,
}: MobileViewerHeaderProps) {
const t = useTranslations('sidebar');
+ const isPaneDesktop = useIsDesktop();
+ if (isPaneDesktop) return null;
return (
diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts
index 0cf52373..bcec359a 100644
--- a/hooks/use-media-query.ts
+++ b/hooks/use-media-query.ts
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store";
+import { usePaneSize } from "@/hooks/use-pane-size";
// Tailwind v4 breakpoints
const BREAKPOINTS = {
@@ -38,12 +39,38 @@ export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
}
+/**
+ * When the Pro shell renders a page inside a (possibly split) pane, that pane
+ * publishes its measured width via `PaneSizeContext`. Inner pages should
+ * branch their layout against the pane width — not the full viewport — so a
+ * narrow pane gets the mobile/tablet layout instead of overflowing.
+ *
+ * Returns `null` when no pane size is published, signalling the caller to
+ * fall back to `window.matchMedia`.
+ */
+function classifyPane(paneWidth: number | null) {
+ if (paneWidth === null) return null;
+ return {
+ isMobile: paneWidth < BREAKPOINTS.md,
+ isTablet: paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg,
+ isDesktop: paneWidth >= BREAKPOINTS.lg,
+ };
+}
+
/**
* Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
+ *
+ * When invoked inside a Pro pane, the returned values reflect the pane's
+ * width instead of the window's. The global UI store is NOT updated in that
+ * case — two split panes would otherwise fight to write conflicting values,
+ * and the store is meant to mirror the actual viewport for callers that read
+ * it directly (mobile navigation helpers etc.).
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
+ const paneWidth = usePaneSize();
+ const paneClassification = classifyPane(paneWidth);
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const isTabletQuery = useMediaQuery(
@@ -52,9 +79,11 @@ export function useDeviceDetection() {
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
useEffect(() => {
+ if (paneClassification) return;
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
- }, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
+ }, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]);
+ if (paneClassification) return paneClassification;
return { isMobile, isTablet, isDesktop };
}
@@ -62,19 +91,29 @@ export function useDeviceDetection() {
* Convenience hooks for specific breakpoints
*/
export function useIsMobile() {
- return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
+ return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport;
}
export function useIsTablet() {
- return useMediaQuery(
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
+ return paneWidth !== null
+ ? paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg
+ : viewport;
}
export function useIsDesktop() {
- return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
+ return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport;
}
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
- return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
+ return paneWidth !== null ? paneWidth >= BREAKPOINTS[breakpoint] : viewport;
}
diff --git a/hooks/use-pane-size.ts b/hooks/use-pane-size.ts
new file mode 100644
index 00000000..1b952073
--- /dev/null
+++ b/hooks/use-pane-size.ts
@@ -0,0 +1,14 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+/**
+ * Width of the pane that's hosting the current subtree, in CSS pixels.
+ * `null` means "no pane is providing a size" — fall back to viewport-based
+ * media queries. Set by the Pro shell on each split pane via ResizeObserver.
+ */
+export const PaneSizeContext = createContext(null);
+
+export function usePaneSize(): number | null {
+ return useContext(PaneSizeContext);
+}