mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Operators use the settings area to control a company and its Paperclip instance. > - The current navigation separates related settings and uses duplicate instance pages. > - Company exports also do independent reads in sequence and do extra work for previews. > - Hardened workspace commands can differ from their saved command after loopback binding. > - This pull request makes these related operator workflows consistent and faster. > - The benefit is one clear settings area, faster exports, and stable runtime command matching. ## Linked Issues or Issue Description Refs #338 Related: #9834 **What existing behavior does this improve?** This improves the company settings UI, company export preparation, and workspace runtime command matching. **Current behavior** Company and instance settings use separate navigation and duplicate pages. Export preparation reads many independent records in sequence. Preview generation can also build an unused organization image. A command with a forced loopback bind can fail to match its saved runtime command. **Proposed behavior** Use one settings navigation and put general instance controls on the company General page. Load independent export data with bounded concurrency, skip unused preview image work, and load the export page only when it is needed. Treat the loopback-bound form of a command as the same runtime command. **Reason and benefit** Operators get one clear settings area. Large company exports need fewer serialized reads. Export previews and initial UI loads do less work. Hardened runtime services remain linked to their saved command definitions. **Breaking changes** The obsolete instance General URL redirects to the unified settings page. Access and Heartbeats remain available, and legacy bookmarks keep their destinations. No API response shape or database schema changes. ## What Changed - Unified company and instance settings navigation and removed duplicate instance settings pages. - Embedded general instance controls in the company General page and kept access-sensitive navigation behavior. - Preserved instance Access and Heartbeats controls in the unified navigation and normalized old bookmarks to those destinations. - Improved environment and access-state handling when workspace seed requests overlap. - Added bounded export reads, a lighter preview path, deferred export preparation, and lazy export-page loading. - Matched loopback-bound runtime commands to their saved command definitions. - Added focused shared, server, and UI regression tests. ## Verification - `pnpm exec vitest run <18 changed test files>`: 18 files and 256 tests passed. - `pnpm check:token-gates`: passed all four token gates. - `pnpm -r typecheck`: passed for all workspace projects. - `pnpm build`: passed for all workspace projects. - `pnpm test:run`: tests ran without a reported failure, but the runner did not close after the server handoff tests. The process closed with status 0 after an interrupt. - Focused latest-head route tests: 2 files and 4 tests passed. - GitHub latest-head checks: all completed without failure. - Greptile: 5/5 with no unresolved review threads. ## Risks - Medium risk: settings routes and navigation changed across several operator roles. - Medium risk: bounded export concurrency increases simultaneous database reads. The limits stay below the normal pool size. - Low risk: runtime command matching accepts only the known Tailscale HTTPS loopback transformation. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex with a GPT-5-family coding model. The runtime does not expose the exact deployed model ID or context-window size. Reasoning, tool use, and local code execution were enabled. ## 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) - [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details Exception: This task requires the existing execution branch. The harness does not permit a branch rename. - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
720 lines
27 KiB
TypeScript
720 lines
27 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } 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 { AppsSidebar } from "./AppsSidebar";
|
|
import { AppDetailSidebar } from "./AppConnectionSidebar";
|
|
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 { useAppsEnabled } from "../hooks/useAppsEnabled";
|
|
import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory";
|
|
import { healthApi } from "../api/health";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { resolveArchivedCompanyBounce, shouldSyncCompanySelectionFromRoute } from "../lib/company-selection";
|
|
import { useOptionalToastActions } from "../context/ToastContext";
|
|
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 {
|
|
return getCompanyPathSegments(pathname, companyPrefix)[0]?.toLowerCase() ?? null;
|
|
}
|
|
|
|
function getCompanyPathSegments(pathname: string, companyPrefix: string | undefined): string[] {
|
|
if (!companyPrefix) return [];
|
|
const segments = pathname.split("/").filter(Boolean);
|
|
if (segments.length < 2) return [];
|
|
if (segments[0]?.toUpperCase() !== companyPrefix.toUpperCase()) return [];
|
|
return segments.slice(1);
|
|
}
|
|
|
|
const RESERVED_APP_SUBPATHS = new Set([
|
|
"browse",
|
|
"connections",
|
|
"connect",
|
|
"review",
|
|
"attention",
|
|
"gateways",
|
|
"advanced",
|
|
"app",
|
|
]);
|
|
|
|
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
|
|
const segments = pathname.split("/").filter(Boolean);
|
|
if (segments[0]?.toLowerCase() === "skills") return true;
|
|
if (!companyPrefix) return false;
|
|
return (
|
|
segments[0]?.toUpperCase() === companyPrefix.toUpperCase() &&
|
|
segments[1]?.toLowerCase() === "skills"
|
|
);
|
|
}
|
|
|
|
export function Layout() {
|
|
const {
|
|
sidebarOpen,
|
|
setSidebarOpen,
|
|
toggleSidebar,
|
|
toggleCollapsed,
|
|
collapsed,
|
|
peeking,
|
|
setPeeking,
|
|
isMobile,
|
|
setForceCollapsed,
|
|
} = useSidebar();
|
|
const { openNewIssue, openOnboarding } = useDialogActions();
|
|
const { togglePanelVisible } = usePanel();
|
|
// Optional: Layout also renders in harnesses without a ToastProvider.
|
|
const pushToast = useOptionalToastActions()?.pushToast ?? null;
|
|
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 { enabled: appsEnabled } = useAppsEnabled();
|
|
const isCompanySettingsRoute = [
|
|
"/company/settings",
|
|
"/company/export",
|
|
"/company/import",
|
|
].some((settingsPath) => location.pathname.includes(settingsPath));
|
|
const companyPathSegments = getCompanyPathSegments(location.pathname, companyPrefix);
|
|
const isToolsRoute = companyPathSegments[0]?.toLowerCase() === "tools";
|
|
const isAppsRoute = companyPathSegments[0]?.toLowerCase() === "apps";
|
|
const appDetailConnectionId =
|
|
isAppsRoute && companyPathSegments[1] && !RESERVED_APP_SUBPATHS.has(companyPathSegments[1].toLowerCase())
|
|
? companyPathSegments[1]
|
|
: null;
|
|
const appDetailApplicationId =
|
|
isAppsRoute && companyPathSegments[1]?.toLowerCase() === "app" && companyPathSegments[2]
|
|
? companyPathSegments[2]
|
|
: null;
|
|
// The Skills Store renders its own secondary (category) sidebar, so the main
|
|
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
|
|
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
|
|
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 />
|
|
) : appsEnabled && appDetailConnectionId ? (
|
|
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
|
|
) : appsEnabled && appDetailApplicationId ? (
|
|
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
|
|
) : appsEnabled && (isAppsRoute || isToolsRoute) ? (
|
|
<AppsSidebar />
|
|
) : 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;
|
|
// Cloud provisions the single company for a stack, and POST /companies is a
|
|
// 403 floor there — auto-opening the wizard could only dead-end.
|
|
if (health?.cloud) return;
|
|
if (companies.length === 0) {
|
|
onboardingTriggered.current = true;
|
|
openOnboarding();
|
|
}
|
|
}, [companies, companiesLoading, openOnboarding, health?.cloud, 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;
|
|
}
|
|
|
|
// Stale state (remembered paths, history, bookmarks, restored tabs)
|
|
// deposits users into archived companies long after archiving; a cold
|
|
// arrival bounces to an active company instead of dwelling there.
|
|
// Deliberate visits (the company is already the selection) stay put.
|
|
const bounce = resolveArchivedCompanyBounce({
|
|
matchedCompany,
|
|
selectedCompanyId,
|
|
companies,
|
|
});
|
|
if (bounce) {
|
|
pushToast?.({
|
|
title: `${matchedCompany.name} is archived`,
|
|
body: `Switched to ${bounce.name}.`,
|
|
tone: "info",
|
|
dedupeKey: `archived-company-bounce:${matchedCompany.id}`,
|
|
});
|
|
setSelectedCompanyId(bounce.id, { source: "route_sync" });
|
|
navigate(`/${bounce.issuePrefix}/dashboard`, { 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,
|
|
pushToast,
|
|
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),
|
|
onGoToInbox: () => navigate("/inbox"),
|
|
});
|
|
|
|
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-(--sz-safe-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-(--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-(--sz-safe-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}
|
|
serverGit={health?.serverInfo?.git}
|
|
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}
|
|
serverGit={health?.serverInfo?.git}
|
|
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}
|
|
// Publish the pinned-composer bottom offset to descendants
|
|
// (PAP-495): while the auto-hiding mobile nav is on screen, raise
|
|
// it to the nav height so a sticky composer clears the nav; drop
|
|
// it back to the safe-area dock when the nav hides. Desktop leaves
|
|
// the token at its :root default.
|
|
style={
|
|
isMobile
|
|
? ({
|
|
"--tc-composer-bottom": mobileNavVisible
|
|
? "var(--sz-calc-14)"
|
|
: "var(--sz-calc-8)",
|
|
} as CSSProperties)
|
|
: undefined
|
|
}
|
|
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-(--sz-calc-14)"
|
|
: "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>
|
|
);
|
|
}
|