Files
PaperClipAI/ui/src/components/Layout.production.tsx
T
Clément df5a78cf91 fix(ui): keep iPhone task chat stable while typing and streaming (#17232)
## Thinking Path

> - Paperclip keeps human-agent conversations attached to tasks.
> - People need to read and reply comfortably from a phone.
> - The mobile task page scrolled the document and positioned its
composer with a sticky navigation offset.
> - Safari's keyboard resize and later focus pan could move that
document again, causing overlap, gaps, and jumps while editing.
> - This change gives mobile and desktop the same message scroller and a
normal flex footer.
> - CSS owns the layout. A small VisualViewport adapter handles the
visible height and Safari's stale document focus scroll.
> - Streaming no longer measures every historical row while following
the bottom.

## Linked Issues or Issue Description

Fixes https://github.com/paperclipai/paperclip/issues/16497

Related: https://github.com/paperclipai/paperclip/pull/15245 also
addresses keyboard positioning. This change additionally removes mobile
document auto-follow and reduces historical-row measurement work.

## What Changed

### One scroll owner and a natural-flow composer

Before:

```text
Document scroll + mobile auto-follow
  └─ Conversation + sticky composer + animated navigation offset
```

After:

```text
Height-constrained conversation shell
  ├─ Header
  ├─ Message scroller — owns follow/reading position
  └─ Composer — normal flex footer
Navigation stays at the layout viewport bottom, behind the keyboard
```

- Reuse TaskMessageScroller on mobile. Delete the separate 136-line
useWindowAutoFollow implementation and its superseded tests.
- Keep the composer in normal flex flow. Remove its sticky bottom
positioning and navigation-offset animation.
- Constrain the mobile conversation with CSS. Classic tasks and other
pages retain their existing layout.
- Add a 47-line useMobileVisualViewport hook. It changes shell height on
resize, not on typing or incoming messages. It never translates the
shell or hides navigation.
- Reject Safari's stale document focus pan only for an unzoomed,
keyboard-open conversation. Inner message and editor scrolling stay
native.
- Keep a saved reading anchor when reading older messages. Skip
historical-row measurements while following the bottom.
- Update tests and the existing mobile streaming fixture. Keep only two
layout-contract sentences in DESIGN.md.

**Scope:** 19 files, +367 / -482 lines: 115 fewer lines overall.
Production UI/CSS alone is +158 / -190 lines. No API, database, or
dependency changes.

## Verification

Bottom-follow is also saved explicitly for Back navigation. A new
regression covers returning after rotation changes message wrapping,
without restoring historical-row scans.

### Physical iPhone acceptance

Tested on an iPhone 15 Pro running iOS 26.7.1. The reporter confirmed:

- Opening the keyboard positions the composer correctly immediately,
before any typing.
- The installed web app works.
- Long drafts, insertion, deletion, and sending are acceptable. The
reporter described long drafts as not perfect but good enough.
- Reading older messages during incoming output preserves position;
returning to the bottom resumes following.
- Rotation, keyboard close/reopen, and attachment/picker transitions
work.

Remote inspection corroborated the corrected open-keyboard state:
visible height 358px, composer bottom 358px, document scrollY 0.
Navigation remained display:block at y=631px, behind the keyboard.

Earlier candidates failed device testing. The decisive trace showed a
correct viewport resize followed about one frame later by a stale 337px
native document focus pan. Typing caused Safari to correct it. Tests now
cover that event ordering, the reverse ordering, and zoom panning.

### Performance evidence

The existing TaskChatPerfHarness supplied 200 historical responses,
4,000 collapsed tools, and 30 updates at 100ms intervals.

| Measurement | Before | After |
| --- | ---: | ---: |
| Historical row geometry reads | 6,000 | 0 |
| Renderer main-thread work | 331.2ms | 273.5ms |
| Largest task | 27.1ms | 12.3ms |

This is one instrumented Chromium development run on the Mac, not a
phone benchmark or a statistical speedup claim. The zero-row-measurement
result also has a regression test that failed with 6,000 reads before
the change.

To reproduce, serve ui/tests/task-chat-perf.html with the UI Vite
server. Record 30 streaming updates at the bottom. Inspect
TaskMessageScroller and row getBoundingClientRect calls. Existing
historical-render tests also confirm that unchanged Markdown does not
reparse on live-tail updates.

### Automated and full-app checks

- [Current-head
CI](https://github.com/paperclipai/paperclip/actions/runs/39862524031)
is green: 54 successful checks and 2 skipped checks. Greptile rates the
current head 5/5, confirms the Back-navigation fix, and has no
unresolved findings.

- **601 focused tests pass** across the task page, layout, composer,
scroller, viewport adapter, and historical-render coverage.
- UI typecheck and production build pass.
- Token gates, Storybook catalog checks, and catalog/map structural
tests pass.
- Independent review of the final source tree passed with no findings.
- The full source app ran on macOS with an isolated database and saved
conversation history. A reply sent through the real UI persisted after
reload. Provider execution was not part of this layout test.
- **Full local repository checks did not pass.** Build/typecheck stop at
missing Rust cargo. The full test run reported 142 failed server tests
and 3 failed suites; missing cargo is confirmed in those three suites.
The other server failures were not diagnosed in this UI task.
Current-head CI now supplies the repository-wide evidence; these local
failures remain disclosed.

## Risks

- Mobile conversations now share the desktop scroll owner. Navigation,
loading, history restoration, and reading-position tests cover that
change.
- The keyboard-state threshold is a 120px viewport reduction.
Accessory-only keyboards retain the normal navigation spacing.
- The document-scroll fallback is scoped to rendered conversations with
an open keyboard at scale 1. It does not handle inner scrolling or
prevent zoom panning.
- Long-draft behavior is accepted, with minor polish still possible.
- The UI change is reversible and requires no data migration.

## Model Used

OpenAI GPT-6 Astra, exact model ID gpt-6-astra, through OpenCode.
Capabilities used: reasoning, code editing, tests, Chromium performance
tracing, and Safari remote inspection. The harness does not expose the
context-window size. An independent general-purpose subagent reviewed
the final tree; its serving model ID was not exposed.

## 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
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; repository-wide
failures are disclosed above
- [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
2026-10-26 08:44:39 -07:00

756 lines
28 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 { useMobileVisualViewport } from "../hooks/useMobileVisualViewport";
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 mobileViewportRef = useMobileVisualViewport(isMobile);
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
ref={mobileViewportRef}
data-mobile={isMobile ? "true" : undefined}
className={cn(
"paperclip-app-shell 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(
"app-main-row 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(
"app-content-column flex min-w-0 flex-col",
isMobile ? "w-full" : "h-full flex-1",
)}
>
<div
className={cn(
"app-breadcrumb",
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("app-content-row", 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>
);
}