Files
PaperClipAI/ui/src/components/Layout.production.tsx
T
7b9334cd10 Draft: Build agent-ready design proposal system (#16875)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People need consistent UI patterns to review and control that work.
> - The current board uses related layouts and controls with different
spacing, type, and behavior.
> - That drift makes it hard for a designer or an agent to build a new
page from a reliable reference.
> - Storybook can show the shared patterns and the states they need to
support.
> - This pull request adds design proposals, a local sandbox, and
reusable UI controls so people can compare the patterns in context.
> - The benefit is a clearer design system that agents can use to build
consistent UI.

## Linked Issues or Issue Description

**Subsystem affected**

`ui/` — React board UI and Storybook.

**Problem or motivation**

Board pages repeat visual and interaction decisions. Storybook did not
provide a complete reference for task views, conversation states,
typography, toolbars, tabs, and light/dark colors. People could not
compare a proposed pattern with the running board in one place.

**Proposed solution**

Add a staged Design Proposals catalog with foundations, components,
layouts, and page specimens. Add an opt-in sandbox. Reuse production
components where possible. Include synthetic conversation states and
shared controls so reviewers can compare and refine them.

**Alternatives considered**

Separate static mockups would not show keyboard behavior, responsive
layout, or real component states. Rebuilding each page only in Storybook
would allow the reference to drift from the board.

**Roadmap alignment**

This improves existing board UI and Skills Manager surfaces. It does not
add a new control-plane capability.

## What Changed

- Added dark and light color references, typography and conversation
specimens, and component stories for buttons, tabs, toolbars, sidebar,
popovers, activity, and task lists.
- Added a local design sandbox with synthetic task conversation playback
and representative states.
- Shared collection toolbars and navigation patterns across task, agent,
project, routine, skill, connector, and artifact pages.
- Added the streamlined Skills tabs and a My Skills collection that uses
the shared skill cards.
- Restored saved-thread access during synthetic playback, kept connector
searchbox semantics, and updated reproduction checks after moving trial
stories.
- Documented the proposed patterns and added focused UI, catalog, and
browser checks.

## Verification

- `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node
scripts/storybook-catalog.mjs --check` passed.
- Full workspace `pnpm -r typecheck` and `pnpm build` passed after
merging master. After the review fixes, focused UI typecheck, `pnpm
build-storybook`, 23 Skills Sources tests, and all 15 Storybook
typography browser tests passed.
- Focused task-thread and system-notice suites passed: 251 tests.
Connector Browse passed 84 tests; the connector, attachment, recovery,
and Storybook flyout browser journeys passed locally.
- A local full Vitest run hit one intermittent chat-channels integration
failure; that test passed in isolation. GitHub CI
[39347996059](https://github.com/paperclipai/paperclip/actions/runs/39347996059)
passed on the exact latest commit: 55 successful checks and two skipped
draft-only Storybook jobs.
- Greptile reviewed the exact latest commit at 5/5 with no new comments
or open review threads. GitHub reports the branch as cleanly mergeable.

## Risks

- This PR changes 206 files and exceeds the repository's 100-file review
target. Review by commit and consider a scoped split before merge.
- Design Proposals are staged references. Fixture interactions do not
prove API behavior, permissions, or provider execution.
- UI changes to shared controls may affect pages outside the proposal
sandbox. Focused browser checks and the full CI matrix passed.

## Model Used

- OpenAI Codex, GPT-6 family. The exact runtime model ID and context
window are not exposed in this session. The model used reasoning, code
execution, browser inspection, and local tests. Earlier branch commits
were also AI-assisted in this Codex conversation.

## 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 available in this session)
- [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 where applicable
- [x] I have either linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run focused 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 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: Scott Tong <scott@scotts-mbp-m5-max.local>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-19 17:45:26 -07:00

751 lines
27 KiB
TypeScript

import { useDesignSandbox } from "@/context/DesignSandboxContext";
import { DesignSandboxSidebar } from "./DesignSandboxSidebar";
import { BoardDialogs } from "./BoardDialogs";
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
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.production";
import { CompanySettingsSidebar } from "./CompanySettingsSidebar.production";
import { CompanySettingsNav } from "./access/CompanySettingsNav";
import { AppsSidebar } from "./AppsSidebar.production";
import { AppDetailSidebar } from "./AppConnectionSidebar.production";
import { BreadcrumbBar } from "./BreadcrumbBar.production";
import { PropertiesPanel } from "./PropertiesPanel";
import { CommandPalette } from "./CommandPalette";
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.production";
import { SecondarySidebar } from "./SecondarySidebar.production";
import { SidebarAccountMenu } from "./SidebarAccountMenu.production";
import { useDialogActions } from "../context/DialogContext";
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 { useMobileNavVisibility } from "../hooks/useMobileNavVisibility";
import { healthApi } from "../api/health";
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 { classifyShellRoute } from "../lib/shell-navigation";
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",
"chat",
"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 designSandbox = useDesignSandbox();
const {
sidebarOpen,
setSidebarOpen,
toggleSidebar,
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 isTaskDetailRoute = classifyShellRoute(location.pathname, companyPrefix).isTaskDetail;
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 previousPathname = useRef<string | null>(null);
const mainContentRef = useRef<HTMLElement | null>(null);
const scrollMemory = useRef(new NavigationScrollMemory());
const activeScrollKey = useRef<string>(location.key);
const mobileNavVisible = useMobileNavVisibility(isMobile, location.pathname);
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 />
) : appDetailConnectionId ? (
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
) : appDetailApplicationId ? (
<AppDetailSidebar
kind="application"
applicationId={appDetailApplicationId}
/>
) : 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: false,
});
// 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;
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({
onNewIssue: () => openNewIssue(),
onSearch: openSearch,
onToggleSidebar: toggleSidebar,
onTogglePanel: togglePanel,
onShowShortcuts: () => setShortcutsOpen(true),
onGoToInbox: () => navigate("/inbox"),
});
// 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]);
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 (
<ChatSetupSidebarProvider>
<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} />
</div>
) : (
<SidebarShell
open={sidebarOpen}
collapsed={designSandbox ? false : collapsed}
peeking={designSandbox ? false : peeking}
resizable
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : undefined}
onPanelMouseEnter={handlePanelPointerEnter}
onPanelMouseLeave={handlePanelPointerLeave}
onPanelFocusCapture={collapsed ? handlePanelFocus : undefined}
onPanelBlurCapture={collapsed ? handlePanelBlur : undefined}
>
{designSandbox ? <DesignSandboxSidebar /> : <>
<div className="flex flex-1 min-h-0">
<Sidebar />
</div>
<SidebarAccountMenu deploymentMode={health?.deploymentMode} />
</>}
</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(--tc-composer-visible-nav-offset)"
: "var(--sz-calc-8)",
"--mobile-nav-motion-duration": mobileNavVisible
? "var(--motion-mobile-nav-enter)"
: "var(--motion-mobile-nav-exit)",
"--mobile-nav-motion-ease": mobileNavVisible
? "var(--motion-ease-out-expo)"
: "var(--motion-ease-standard)",
} 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
? isTaskDetailRoute
? "overflow-visible pb-(--tc-composer-visible-nav-offset)"
: "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 />
<BoardDialogs />
<KeyboardShortcutsCheatsheet
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
/>
<ToastViewport />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</ChatSetupSidebarProvider>
);
}