mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The web UI (`ui/`) renders inside a single persistent shell (`Layout`) whose `#main-content` element is the scroll container for every route > - Because that scroll container survives route changes, browser back/forward (a history `POP`) lands on the previous page with the scroll offset of the page you just left > - Concretely: scroll deep into an issue detail, hit browser back, and the inbox renders scrolled to the issue-detail offset instead of where you left off — making it hard to find your place > - The app already reset scroll for forward (`PUSH`) navigation but deliberately did nothing on `POP`, so nothing restored the prior position > - This pull request records each history entry's scroll offset as the user scrolls and restores it on `POP`, while keeping the existing reset-to-top behavior for `PUSH`/`REPLACE` > - The benefit is that back/forward returns you to the exact place you were, so the inbox (and any scrolled page) keeps your reading position ## Linked Issues or Issue Description No public GitHub issue — describing the bug inline following the bug report template. **What happened** From the inbox, open an issue, scroll down within the issue detail, then press the browser Back button. The inbox is restored at the wrong scroll position — it shows the Y offset from the issue-detail page rather than the position you had in the inbox. **Expected behavior** Browser back/forward returns the page to the scroll position it had when you left it. **Steps to reproduce** 1. Open the inbox and scroll to a known position partway down the list. 2. Click into an issue. 3. Scroll down within the issue detail. 4. Press the browser Back button to return to the inbox. 5. Observe the inbox is scrolled to the issue-detail offset instead of where you left off. **Deployment mode** Web UI (`ui/`), any deployment — client-side scroll behavior only. Root cause: `#main-content` is a single scroll container that stays mounted across route changes. Scroll was only reset on forward (`PUSH`) navigation and left untouched on `POP`, so the stale offset from the outgoing page persisted onto the page being returned to. ## What Changed - Added `NavigationScrollMemory` (`ui/src/lib/navigation-scroll.ts`): a per-history-key map of `#main-content` scroll offsets, clamped to `>= 0`. - Added `applyMainContentScrollTop` helper to restore a saved offset onto the main content element (null-safe). - In `Layout` (`ui/src/components/Layout.tsx`): continuously record the active history entry's scroll offset on scroll, and on `POP` navigation restore the remembered offset (re-applying on the next animation frame so a late-laying-out cached page doesn't clamp the offset to a shorter interim height). Forward `PUSH`/`REPLACE` keeps the existing reset-to-top behavior. - Added unit tests covering the remember/recall logic and the restore helper. ## Verification - `ui` unit tests pass, including the new `navigation-scroll` cases (remember/recall per key, clamping, and DOM restore). - TypeScript clean on the changed files. - Manual: inbox → open issue → scroll down → browser Back returns the inbox to its previous scroll position; forward navigation still resets to top. ## Risks Low risk. Scoped to client-side scroll restoration in the web UI; no API, schema, or migration changes. The only behavioral change is that `POP` navigation now restores a saved offset instead of leaving the container untouched; `PUSH`/`REPLACE` behavior is unchanged. Memory is per-session and bounded by visited history keys. ## Model Used Claude (Anthropic), Opus-class model via Paperclip's `claude_local` adapter, with extended thinking and tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
625 lines
24 KiB
TypeScript
625 lines
24 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
|
import { Sidebar } from "./Sidebar";
|
|
import { CompanySettingsSidebar } from "./CompanySettingsSidebar";
|
|
import { CompanySettingsNav } from "./access/CompanySettingsNav";
|
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
|
import { PropertiesPanel } from "./PropertiesPanel";
|
|
import { CommandPalette } from "./CommandPalette";
|
|
import { NewIssueDialog } from "./NewIssueDialog";
|
|
import { NewProjectDialog } from "./NewProjectDialog";
|
|
import { NewGoalDialog } from "./NewGoalDialog";
|
|
import { NewAgentDialog } from "./NewAgentDialog";
|
|
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
|
import { ToastViewport } from "./ToastViewport";
|
|
import { MobileBottomNav } from "./MobileBottomNav";
|
|
import { WorktreeBanner } from "./WorktreeBanner";
|
|
import { DevRestartBanner } from "./DevRestartBanner";
|
|
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
|
import { SidebarShell } from "./SidebarShell";
|
|
import { SecondarySidebar } from "./SecondarySidebar";
|
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
|
import { useDialogActions } from "../context/DialogContext";
|
|
import { GeneralSettingsProvider } from "../context/GeneralSettingsContext";
|
|
import { usePanel } from "../context/PanelContext";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
|
import { healthApi } from "../api/health";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
|
import {
|
|
applyMainContentScrollTop,
|
|
NavigationScrollMemory,
|
|
resetNavigationScroll,
|
|
shouldResetScrollOnNavigation,
|
|
} from "../lib/navigation-scroll";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { scheduleMainContentFocus } from "../lib/main-content-focus";
|
|
import { pinDocumentScrollToZero } from "../lib/pin-document-scroll";
|
|
import { cn } from "../lib/utils";
|
|
import { NotFoundPage } from "../pages/NotFound";
|
|
import { PluginSlotMount, resolveRouteSidebarSlot, usePluginSlots } from "../plugins/slots";
|
|
|
|
function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefined): string | null {
|
|
if (!companyPrefix) return null;
|
|
const segments = pathname.split("/").filter(Boolean);
|
|
if (segments.length < 2) return null;
|
|
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return null;
|
|
return segments[1]?.toLowerCase() ?? null;
|
|
}
|
|
|
|
export function Layout() {
|
|
const {
|
|
sidebarOpen,
|
|
setSidebarOpen,
|
|
toggleSidebar,
|
|
toggleCollapsed,
|
|
collapsed,
|
|
peeking,
|
|
setPeeking,
|
|
isMobile,
|
|
setForceCollapsed,
|
|
} = useSidebar();
|
|
const { openNewIssue, openOnboarding } = useDialogActions();
|
|
const { togglePanelVisible } = usePanel();
|
|
const {
|
|
companies,
|
|
loading: companiesLoading,
|
|
selectedCompany,
|
|
selectedCompanyId,
|
|
selectionSource,
|
|
setSelectedCompanyId,
|
|
} = useCompany();
|
|
const {
|
|
companyPrefix,
|
|
pluginRoutePath: matchedPluginRoutePath,
|
|
} = useParams<{ companyPrefix: string; pluginRoutePath?: string }>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const navigationType = useNavigationType();
|
|
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
|
|
// The Skills Store renders its own secondary (category) sidebar, so the main
|
|
// app nav collapses to its rail throughout the /skills section (PAP-10879).
|
|
const isSkillsRoute = /(^|\/)skills(\/|$)/.test(location.pathname);
|
|
const onboardingTriggered = useRef(false);
|
|
const lastMainScrollTop = useRef(0);
|
|
const previousPathname = useRef<string | null>(null);
|
|
const mainContentRef = useRef<HTMLElement | null>(null);
|
|
const scrollMemory = useRef(new NavigationScrollMemory());
|
|
const activeScrollKey = useRef<string>(location.key);
|
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
const matchedCompany = useMemo(() => {
|
|
if (!companyPrefix) return null;
|
|
const requestedPrefix = companyPrefix.toUpperCase();
|
|
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
|
|
}, [companies, companyPrefix]);
|
|
const hasUnknownCompanyPrefix =
|
|
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
|
const pluginRoutePath = useMemo(
|
|
() => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix),
|
|
[companyPrefix, location.pathname, matchedPluginRoutePath],
|
|
);
|
|
const routeSidebarCompanyId = matchedCompany?.id ?? null;
|
|
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
|
|
const { slots: routeSidebarSlots } = usePluginSlots({
|
|
slotTypes: ["page", "routeSidebar"],
|
|
companyId: routeSidebarCompanyId,
|
|
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
|
|
});
|
|
const routeSidebarSlot = useMemo(
|
|
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
|
|
[pluginRoutePath, routeSidebarSlots],
|
|
);
|
|
const sidebarContext = useMemo(
|
|
() => ({
|
|
companyId: routeSidebarCompanyId,
|
|
companyPrefix: routeSidebarCompanyPrefix,
|
|
}),
|
|
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
|
);
|
|
// Takeover routes (company settings, plugin `routeSidebar`) no longer replace
|
|
// the app `<Sidebar/>`. Instead the host collapses it to its rail and renders
|
|
// the contextual sidebar in a second pane (PAP-10695). One resolver drives
|
|
// both desktop (SecondarySidebar) and mobile (off-canvas drawer).
|
|
const secondarySidebar = isCompanySettingsRoute ? (
|
|
<CompanySettingsSidebar />
|
|
) : routeSidebarSlot ? (
|
|
<PluginSlotMount
|
|
slot={routeSidebarSlot}
|
|
context={sidebarContext}
|
|
className="h-full w-full"
|
|
missingBehavior="placeholder"
|
|
/>
|
|
) : null;
|
|
const hasSecondarySidebar = secondarySidebar != null;
|
|
const { data: health } = useQuery({
|
|
queryKey: queryKeys.health,
|
|
queryFn: () => healthApi.get(),
|
|
retry: false,
|
|
refetchInterval: (query) => {
|
|
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
|
|
return data?.devServer?.enabled ? 2000 : false;
|
|
},
|
|
refetchIntervalInBackground: true,
|
|
});
|
|
const keyboardShortcutsEnabled = useQuery({
|
|
queryKey: queryKeys.instance.generalSettings,
|
|
queryFn: () => instanceSettingsApi.getGeneral(),
|
|
}).data?.keyboardShortcuts === true;
|
|
|
|
// A secondary sidebar always collapses the app sidebar to its rail (still
|
|
// peek-able) — a hard invariant that overrides the user pin while the route
|
|
// is active, but does NOT mutate the persisted preference. Clearing the force
|
|
// on cleanup restores the user's expanded/collapsed choice when navigating
|
|
// off the takeover route (PAP-10694).
|
|
const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute;
|
|
useLayoutEffect(() => {
|
|
setForceCollapsed(forceRailCollapsed);
|
|
return () => setForceCollapsed(false);
|
|
}, [forceRailCollapsed, setForceCollapsed]);
|
|
|
|
useEffect(() => {
|
|
if (companiesLoading || onboardingTriggered.current) return;
|
|
if (health?.deploymentMode === "authenticated") return;
|
|
if (companies.length === 0) {
|
|
onboardingTriggered.current = true;
|
|
openOnboarding();
|
|
}
|
|
}, [companies, companiesLoading, openOnboarding, health?.deploymentMode]);
|
|
|
|
useEffect(() => {
|
|
if (!companyPrefix || companiesLoading || companies.length === 0) return;
|
|
|
|
if (!matchedCompany) {
|
|
const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null)
|
|
?? companies[0]
|
|
?? null;
|
|
if (fallback && selectedCompanyId !== fallback.id) {
|
|
setSelectedCompanyId(fallback.id, { source: "route_sync" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (companyPrefix !== matchedCompany.issuePrefix) {
|
|
const suffix = location.pathname.replace(/^\/[^/]+/, "");
|
|
navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (
|
|
shouldSyncCompanySelectionFromRoute({
|
|
selectionSource,
|
|
selectedCompanyId,
|
|
routeCompanyId: matchedCompany.id,
|
|
})
|
|
) {
|
|
setSelectedCompanyId(matchedCompany.id, { source: "route_sync" });
|
|
}
|
|
}, [
|
|
companyPrefix,
|
|
companies,
|
|
companiesLoading,
|
|
matchedCompany,
|
|
location.pathname,
|
|
location.search,
|
|
navigate,
|
|
selectionSource,
|
|
selectedCompanyId,
|
|
setSelectedCompanyId,
|
|
]);
|
|
|
|
const togglePanel = togglePanelVisible;
|
|
// Cmd/Ctrl+B: collapse/expand the pinned rail on desktop; on mobile keep
|
|
// toggling the off-canvas drawer.
|
|
const toggleCollapse = useCallback(() => {
|
|
if (isMobile) {
|
|
toggleSidebar();
|
|
} else {
|
|
toggleCollapsed();
|
|
}
|
|
}, [isMobile, toggleSidebar, toggleCollapsed]);
|
|
const openSearch = useCallback(() => {
|
|
document.dispatchEvent(new KeyboardEvent("keydown", {
|
|
key: "k",
|
|
metaKey: true,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
}, []);
|
|
|
|
// Peek (hover flyout) triggers for the collapsed rail. Opening has a tiny
|
|
// delay so a pointer merely sweeping across the rail doesn't flash it open;
|
|
// closing is debounced to avoid flicker on the rail→overlay seam. Keyboard
|
|
// focus opens immediately so tabbing reaches the full nav. Context gates the
|
|
// effective `peeking` to desktop + collapsed + hover-capable pointers, so
|
|
// these handlers are inert otherwise.
|
|
const peekTimer = useRef<number | null>(null);
|
|
// Whether the pointer is currently over the peek panel. Used to keep the peek
|
|
// open across focus changes (e.g. navigation steals focus to <main>) as long as
|
|
// the user is still hovering — it should only close when they actually mouse off
|
|
// (PAP-10676).
|
|
const pointerInsidePanel = useRef(false);
|
|
// When the user explicitly collapses while the pointer is still over the panel,
|
|
// suppress re-peeking until the pointer actually leaves — otherwise the lingering
|
|
// hover immediately re-expands the rail and the collapse "doesn't take" until the
|
|
// mouse moves away (PAP-10676). Re-armed on the next genuine pointer-leave.
|
|
const suppressPeekRef = useRef(false);
|
|
const clearPeekTimer = useCallback(() => {
|
|
if (peekTimer.current !== null) {
|
|
window.clearTimeout(peekTimer.current);
|
|
peekTimer.current = null;
|
|
}
|
|
}, []);
|
|
const openPeek = useCallback(() => {
|
|
clearPeekTimer();
|
|
peekTimer.current = window.setTimeout(() => setPeeking(true), 50);
|
|
}, [clearPeekTimer, setPeeking]);
|
|
const openPeekImmediate = useCallback(() => {
|
|
clearPeekTimer();
|
|
setPeeking(true);
|
|
}, [clearPeekTimer, setPeeking]);
|
|
const closePeek = useCallback(() => {
|
|
clearPeekTimer();
|
|
peekTimer.current = window.setTimeout(() => setPeeking(false), 120);
|
|
}, [clearPeekTimer, setPeeking]);
|
|
// Tracked even while expanded so that, at the moment of collapse, we know
|
|
// whether the pointer is over the panel and should suppress the re-peek.
|
|
const handlePanelPointerEnter = useCallback(() => {
|
|
pointerInsidePanel.current = true;
|
|
if (collapsed && !suppressPeekRef.current) openPeek();
|
|
}, [collapsed, openPeek]);
|
|
const handlePanelPointerLeave = useCallback(() => {
|
|
pointerInsidePanel.current = false;
|
|
suppressPeekRef.current = false; // pointer left — re-arm peek for the next hover
|
|
closePeek();
|
|
}, [closePeek]);
|
|
const handlePanelFocus = useCallback(() => {
|
|
if (suppressPeekRef.current) return;
|
|
openPeekImmediate();
|
|
}, [openPeekImmediate]);
|
|
// Close on focus leaving the panel only when the pointer isn't hovering it.
|
|
// Clicking a rail/peek nav item moves focus to <main> on navigation; if the
|
|
// mouse is still over the flyout we keep it open until the pointer leaves.
|
|
const handlePanelBlur = useCallback(() => {
|
|
if (pointerInsidePanel.current) return;
|
|
closePeek();
|
|
}, [closePeek]);
|
|
|
|
// Tidy up any pending peek timer on unmount.
|
|
useEffect(() => clearPeekTimer, [clearPeekTimer]);
|
|
|
|
// An explicit collapse must be atomic: cancel any in-flight/active peek, and if
|
|
// the pointer is still over the panel suppress re-peeking until it leaves, so the
|
|
// rail doesn't immediately re-expand under the lingering hover (PAP-10676).
|
|
const wasCollapsed = useRef(collapsed);
|
|
useEffect(() => {
|
|
if (collapsed !== wasCollapsed.current) {
|
|
if (collapsed) {
|
|
clearPeekTimer();
|
|
setPeeking(false);
|
|
suppressPeekRef.current = pointerInsidePanel.current;
|
|
} else {
|
|
suppressPeekRef.current = false;
|
|
}
|
|
wasCollapsed.current = collapsed;
|
|
}
|
|
}, [collapsed, clearPeekTimer, setPeeking]);
|
|
|
|
// Intentionally do NOT close the peek on navigation: clicking a nav item means
|
|
// the pointer is still over the flyout, so it should stay open until the user
|
|
// actually mouses off (handled by onPanelMouseLeave) or blurs out / hits Escape
|
|
// (PAP-10676). Auto-closing here made the sidebar collapse on every page change.
|
|
|
|
// Escape closes an open peek without trapping the pointer.
|
|
useEffect(() => {
|
|
if (!peeking) return;
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
clearPeekTimer();
|
|
setPeeking(false);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [peeking, clearPeekTimer, setPeeking]);
|
|
|
|
useCompanyPageMemory();
|
|
|
|
useKeyboardShortcuts({
|
|
enabled: keyboardShortcutsEnabled,
|
|
onNewIssue: () => openNewIssue(),
|
|
onSearch: openSearch,
|
|
onToggleSidebar: toggleSidebar,
|
|
onToggleCollapse: toggleCollapse,
|
|
onTogglePanel: togglePanel,
|
|
onShowShortcuts: () => setShortcutsOpen(true),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
return;
|
|
}
|
|
lastMainScrollTop.current = 0;
|
|
setMobileNavVisible(true);
|
|
}, [isMobile]);
|
|
|
|
// Swipe gesture to open/close sidebar on mobile
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
|
|
const EDGE_ZONE = 30; // px from left edge to start open-swipe
|
|
const MIN_DISTANCE = 50; // minimum horizontal swipe distance
|
|
const MAX_VERTICAL = 75; // max vertical drift before we ignore
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
const t = e.touches[0]!;
|
|
startX = t.clientX;
|
|
startY = t.clientY;
|
|
};
|
|
|
|
const onTouchEnd = (e: TouchEvent) => {
|
|
const t = e.changedTouches[0]!;
|
|
const dx = t.clientX - startX;
|
|
const dy = Math.abs(t.clientY - startY);
|
|
|
|
if (dy > MAX_VERTICAL) return; // vertical scroll, ignore
|
|
|
|
// Swipe right from left edge → open
|
|
if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) {
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Swipe left when open → close
|
|
if (sidebarOpen && dx < -MIN_DISTANCE) {
|
|
setSidebarOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
|
|
return () => {
|
|
document.removeEventListener("touchstart", onTouchStart);
|
|
document.removeEventListener("touchend", onTouchEnd);
|
|
};
|
|
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
|
|
|
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
|
const delta = currentTop - lastMainScrollTop.current;
|
|
|
|
if (currentTop <= 24) {
|
|
setMobileNavVisible(true);
|
|
} else if (delta > 8) {
|
|
setMobileNavVisible(false);
|
|
} else if (delta < -8) {
|
|
setMobileNavVisible(true);
|
|
}
|
|
|
|
lastMainScrollTop.current = currentTop;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
lastMainScrollTop.current = 0;
|
|
return;
|
|
}
|
|
|
|
const onScroll = () => {
|
|
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
|
|
};
|
|
|
|
onScroll();
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [isMobile, updateMobileNavVisibility]);
|
|
|
|
useEffect(() => {
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
document.body.style.overflow = isMobile ? "visible" : "clip";
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [isMobile]);
|
|
|
|
// `scrollIntoView` walks every ancestor scroll container. On a long thread
|
|
// the post-submit `scrollIntoView` on the new comment reaches `<html>` and
|
|
// animates `documentElement.scrollTop` via the browser's internal scroll
|
|
// algorithm, which bypasses the CSS `overflow` on the root element and
|
|
// visually shifts the entire shell (sidebar included) off-screen. Pin
|
|
// both roots to scrollTop=0 on every scroll tick.
|
|
useEffect(() => {
|
|
if (isMobile) return;
|
|
return pinDocumentScrollToZero();
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return;
|
|
const mainContent = mainContentRef.current;
|
|
return scheduleMainContentFocus(mainContent);
|
|
}, [location.pathname]);
|
|
|
|
// Continuously record the scroll offset of the active history entry so a
|
|
// later back/forward navigation can restore it (see NavigationScrollMemory).
|
|
useEffect(() => {
|
|
const main = mainContentRef.current;
|
|
if (!main) return;
|
|
const recordScroll = () => {
|
|
scrollMemory.current.remember(activeScrollKey.current, main.scrollTop);
|
|
};
|
|
main.addEventListener("scroll", recordScroll, { passive: true });
|
|
return () => main.removeEventListener("scroll", recordScroll);
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const main = mainContentRef.current;
|
|
const shouldResetScroll = shouldResetScrollOnNavigation({
|
|
previousPathname: previousPathname.current,
|
|
pathname: location.pathname,
|
|
navigationType,
|
|
state: location.state,
|
|
});
|
|
|
|
previousPathname.current = location.pathname;
|
|
|
|
const isHistoryPop = navigationType === "POP";
|
|
const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0;
|
|
activeScrollKey.current = location.key;
|
|
|
|
if (isHistoryPop) {
|
|
applyMainContentScrollTop(main, restoredScrollTop);
|
|
// Cached page content can finish laying out a frame after commit; re-apply
|
|
// once it has so the restored offset isn't clamped to a shorter interim height.
|
|
const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop));
|
|
return () => cancelAnimationFrame(raf);
|
|
}
|
|
|
|
if (shouldResetScroll) {
|
|
resetNavigationScroll(main);
|
|
}
|
|
}, [location.key, location.pathname, location.state, navigationType]);
|
|
|
|
return (
|
|
<GeneralSettingsProvider value={{ keyboardShortcutsEnabled }}>
|
|
<div
|
|
className={cn(
|
|
"bg-background text-foreground pt-[env(safe-area-inset-top)]",
|
|
// overflow-x-clip on mobile keeps a stray wide descendant from making the
|
|
// whole viewport scroll horizontally. clip (not hidden) leaves overflow-y
|
|
// computed as visible, so native body scroll + the sticky breadcrumb keep
|
|
// working.
|
|
isMobile ? "min-h-dvh overflow-x-clip" : "flex h-dvh flex-col overflow-clip",
|
|
)}
|
|
>
|
|
<a
|
|
href="#main-content"
|
|
className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-md focus:bg-background focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
Skip to Main Content
|
|
</a>
|
|
<WorktreeBanner />
|
|
<DevRestartBanner devServer={health?.devServer} />
|
|
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
|
{isMobile && sidebarOpen && (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 z-40 bg-black/50"
|
|
onClick={() => setSidebarOpen(false)}
|
|
aria-label="Close sidebar"
|
|
/>
|
|
)}
|
|
|
|
{isMobile ? (
|
|
<div
|
|
className={cn(
|
|
"fixed inset-y-0 left-0 z-50 flex flex-col overflow-hidden pt-[env(safe-area-inset-top)] transition-transform duration-100 ease-out",
|
|
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
|
)}
|
|
>
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
<div className="w-60 shrink-0 overflow-hidden">
|
|
{hasSecondarySidebar ? secondarySidebar : <Sidebar />}
|
|
</div>
|
|
</div>
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
version={health?.version}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<SidebarShell
|
|
open={sidebarOpen}
|
|
collapsed={collapsed}
|
|
peeking={peeking}
|
|
resizable
|
|
onPanelMouseEnter={handlePanelPointerEnter}
|
|
onPanelMouseLeave={handlePanelPointerLeave}
|
|
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
|
|
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
|
|
>
|
|
<div className="flex flex-1 min-h-0">
|
|
<Sidebar />
|
|
</div>
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
version={health?.version}
|
|
/>
|
|
</SidebarShell>
|
|
)}
|
|
|
|
{!isMobile && hasSecondarySidebar ? (
|
|
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
|
) : null}
|
|
|
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
|
<div
|
|
className={cn(
|
|
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
|
)}
|
|
>
|
|
<StandaloneBrowserControls mobile={isMobile} />
|
|
<BreadcrumbBar />
|
|
{isMobile && isCompanySettingsRoute ? (
|
|
<div className="border-b border-border px-4 pb-3">
|
|
<CompanySettingsNav />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className={cn(isMobile ? "block" : "flex flex-1 min-h-0")}>
|
|
<main
|
|
id="main-content"
|
|
ref={mainContentRef}
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"flex-1 p-4 outline-none md:p-6",
|
|
// Reserve the scrollbar gutter on desktop so pages whose height
|
|
// changes (e.g. switching skill-detail tabs) don't widen/shift
|
|
// when the vertical scrollbar appears or disappears (PAP-10907).
|
|
isMobile
|
|
? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]"
|
|
: "overflow-auto [scrollbar-gutter:stable]",
|
|
)}
|
|
>
|
|
{hasUnknownCompanyPrefix ? (
|
|
<NotFoundPage
|
|
scope="invalid_company_prefix"
|
|
requestedPrefix={companyPrefix ?? selectedCompany?.issuePrefix}
|
|
/>
|
|
) : (
|
|
<RouteErrorBoundary>
|
|
<Outlet />
|
|
</RouteErrorBoundary>
|
|
)}
|
|
</main>
|
|
<PropertiesPanel />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
|
<CommandPalette />
|
|
<NewIssueDialog />
|
|
<NewProjectDialog />
|
|
<NewGoalDialog />
|
|
<NewAgentDialog />
|
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
|
<ToastViewport />
|
|
</div>
|
|
</GeneralSettingsProvider>
|
|
);
|
|
}
|