mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - A new user lands on their first task after onboarding. The task
thread and the composer are the core of that screen.
> - The app shows an optional in-app announcement (now "Introducing
Connectors"). On desktop the card floats at the bottom-left, 22rem wide,
but the sidebar is only 240px wide.
> - In the Nov 5 cloud onboarding test, the tester said: "onboarding
popup blocks core real estate".
> - At 1280x800 the card covers the left part of the composer. At
1440x900 and 1280x800 it covers the task thread area and the sidebar
account menu.
> - This pull request docks the card inside the desktop sidebar column,
in normal layout flow, above the account menu.
> - The benefit is that the card stays visible and dismissible, but it
can never cover the thread or the composer.
## Linked Issues or Issue Description
**What happened?**
On a new company's first task, the "Introducing Connectors" announcement
card floats over the task thread. At 1280x800 it covers the left part of
the composer.
**Steps to reproduce**
1. Create a new company and open its first task at 1280x800.
2. Wait 3 seconds for the announcement card to show.
3. See the card on top of the composer and the account menu.
**Expected behavior**
Optional announcements do not cover the task thread or the composer.
**Evidence**
The staging test company (Claude subscription path) has a local
dismissal for `2026-09-14-connectors`. The live feed at
`pages.paperclip.ing/announcements/v1/current.json` serves that
announcement. No other first-use overlay showed on the first task or the
dashboard. Toasts and the task-detail error notice are transient and are
not first-use popups.
## What Changed
- `AnnouncementWell` has a `placement` prop: `sidebar` (in-flow, no
`fixed`) or `floating` (the earlier fixed placement).
- `Layout` renders the `sidebar` placement inside the desktop
`SidebarShell`, above the account menu. It renders the `floating`
placement only on mobile, where it stays above the bottom nav.
- While the desktop sidebar is collapsed to a rail or closed, the card
waits. It shows again when the sidebar is expanded.
- In the sidebar, the card is capped at half the window height
(`--announcement-sidebar-max-height: 50dvh`) and scrolls. It uses the
existing compact image height token, so the sidebar navigation keeps
most of its height.
- New e2e spec `tests/e2e/announcement-placement.spec.ts`. It makes a
new company with a team lead and a first task. It serves the live
connectors announcement through the app's announcement API. At 1440x900
and 1280x800 it checks that the card does not overlap the composer or
`#main-content`, that the card is fully in the viewport and dismissible,
and that it does not overlap the account menu trigger and the Account
dialog opens.
- Unit test for the two placements in `AnnouncementWell.test.tsx`.
**Product decision for Dotta:** this PR does not hide announcements on
first use. Another option is to hold all announcements until the user
has used the product for some time (for example, after the first task is
done or after the first day). That is a one-line gate in
`AnnouncementWell`'s `enabled` condition, if we want it.
## Verification
Red on `master` (819420a60a), same spec, before the fix:
```
[1/1] [chromium] › tests/e2e/announcement-placement.spec.ts › Announcement placement on a new company's first task › the announcement does not cover the task thread or composer
Error: 1440x900: announcement overlaps task thread
Expected: 0
Received: 42904
Error: 1280x800: announcement overlaps composer
Expected: 0
Received: 8568
Error: 1280x800: announcement overlaps task thread
Expected: 0
Received: 42904
1 failed
```
The spec also checks the account menu (requested by the N-13
connectors-upsell fix). Red on `master` with the same spec:
```
Error: 1440x900: announcement overlaps task thread
Received: 42904
Error: 1440x900: announcement overlaps account menu
Received: 5312
Error: locator.click: Test ended. (the card intercepts the "Open account menu" click)
```
Green after the fix:
```
[1/1] [chromium] › tests/e2e/announcement-placement.spec.ts › Announcement placement on a new company's first task › the announcement does not cover the task thread or composer
1 passed (45.0s)
```
Review follow-up (Greptile/Ponytail: a long announcement in a 208px
sidebar had no height cap). A second spec uses a 400-character
description, a 208px sidebar, and a 1280x720 window. Red without the
cap:
```
Error: docked card keeps at most half the window height
Received: 732
Error: expect(locator).toBeInViewport() failed (sidebar "Tasks" link)
Received: viewport ratio 0
Error: expect(locator).toBeInViewport() failed (account menu)
Received: viewport ratio 0
```
The spec sets the sidebar width key for both the classic and the
Coordinator workspace, and asserts the rendered width is 208px.
Green with the cap (both specs):
```
2 passed (57.8s)
```
Neighbouring suites:
```
cd ui && npx vitest run src/components/AnnouncementWell.test.tsx src/components/AnnouncementCard src/hooks/useAnnouncement.test.tsx src/components/Layout.test.tsx
Test Files 4 passed (4)
Tests 57 passed (57)
```
- `pnpm check:token-gates`: All gates clean.
- `pnpm --filter @paperclipai/ui typecheck`: pass.
- Screenshots: the spec writes `announcement-1440x900.png` and
`announcement-1280x800.png` to its test output. Before: the card (352px
wide) sits over the composer at x 296–364 and over the account menu.
After: the card is 216px wide inside the sidebar, above the account
menu. The thread and composer are clear.
Run it:
```
npx playwright test --config tests/e2e/playwright.config.ts tests/e2e/announcement-placement.spec.ts
```
## Risks
- Low risk. The card now takes vertical space in the sidebar while it
shows. The sidebar navigation scrolls as before. The card goes away when
the user dismisses it.
- Users who keep the desktop sidebar collapsed do not see the
announcement until they expand the sidebar.
- Mobile behavior does not change.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, browser, Playwright).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
781 lines
32 KiB
TypeScript
781 lines
32 KiB
TypeScript
import { CoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
|
import { CoordinatorChatReferencesProvider } from "./coordinator/CoordinatorChatReferences";
|
|
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
|
|
import { useDesignSandbox } from "@/context/DesignSandboxContext";
|
|
import { DesignSandboxSidebar } from "./DesignSandboxSidebar";
|
|
import { BoardDialogs } from "./BoardDialogs";
|
|
import { SetupWizardSidebarOutlet } from "./SetupWizard";
|
|
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } 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 { AgentContextualSidebar } from "./AgentContextualSidebar";
|
|
import { RoutineContextualSidebar } from "./RoutineContextualSidebar";
|
|
import { SkillsContextualSidebar } from "./SkillsContextualSidebar";
|
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
|
import { PropertiesPanel } from "./PropertiesPanel";
|
|
import { CommandPalette } from "./CommandPalette";
|
|
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
|
import { ToastViewport } from "./ToastViewport";
|
|
import { AnnouncementWell } from "./AnnouncementWell";
|
|
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
|
|
import { MobileBottomNav } from "./MobileBottomNav";
|
|
import { WorktreeBanner } from "./WorktreeBanner";
|
|
import { DevRestartBanner } from "./DevRestartBanner";
|
|
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
|
import { AgentConversationsSidebar } from "./AgentConversationsSidebar";
|
|
import { useAgentChatEnabled } from "../hooks/useAgentChatEnabled";
|
|
import { SidebarShell } from "./SidebarShell";
|
|
import { SecondarySidebar } from "./SecondarySidebar";
|
|
import { ContextualSidebarFrame } from "./ContextualSidebarFrame";
|
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
|
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 { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
|
|
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 {
|
|
classifyShellRoute,
|
|
getCompanyPathSegments,
|
|
rememberContextualSidebarOrigin,
|
|
type ContextualSidebarSurface,
|
|
} from "../lib/shell-navigation";
|
|
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;
|
|
}
|
|
|
|
const RESERVED_APP_SUBPATHS = new Set([
|
|
"browse",
|
|
"connections",
|
|
"assistant-connection",
|
|
"connect",
|
|
"chat",
|
|
"vercel-connect",
|
|
"review",
|
|
"attention",
|
|
"gateways",
|
|
"advanced",
|
|
"app",
|
|
]);
|
|
|
|
export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
|
const designSandbox = useDesignSandbox();
|
|
const {
|
|
sidebarOpen,
|
|
setSidebarOpen,
|
|
toggleSidebar,
|
|
collapsed,
|
|
peeking,
|
|
setPeeking,
|
|
isMobile,
|
|
setForceCollapsed,
|
|
} = useSidebar();
|
|
const { openNewIssue, openOnboarding } = useDialogActions();
|
|
const { togglePanelVisible, setPanelVisible } = 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,
|
|
agentId,
|
|
routineId,
|
|
} = useParams<{
|
|
companyPrefix: string;
|
|
pluginRoutePath?: string;
|
|
agentId?: string;
|
|
routineId?: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const navigationType = useNavigationType();
|
|
const { enabled: classicStreamlinedUiEnabled } = useStreamlinedUiEnabled();
|
|
const { enabled: coordinatorWorkspaceEnabled } = useCoordinatorWorkspaceEnabled();
|
|
const streamlinedUiEnabled = classicStreamlinedUiEnabled || coordinatorWorkspaceEnabled;
|
|
const shellRoute = classifyShellRoute(location.pathname, companyPrefix);
|
|
const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings";
|
|
const companyPathSegments = shellRoute.companySegments;
|
|
const isTaskDetailRoute = shellRoute.isTaskDetail;
|
|
useLayoutEffect(() => {
|
|
if (coordinatorWorkspaceEnabled) setPanelVisible(false);
|
|
}, [coordinatorWorkspaceEnabled, location.pathname, setPanelVisible]);
|
|
const { enabled: agentChatEnabled } = useAgentChatEnabled();
|
|
const isAgentChatRoute = agentChatEnabled && companyPathSegments[0]?.toLowerCase() === "chats";
|
|
// Chat keeps its header beside the agent sidebar, including before an agent is selected.
|
|
const useStreamlinedTaskDetailShell = streamlinedUiEnabled && (isTaskDetailRoute || isAgentChatRoute);
|
|
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;
|
|
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],
|
|
);
|
|
// Most contextual routes replace the global navigation inside the same
|
|
// sidebar shell. Apps, Agent details, and Routine details are the
|
|
// exceptions in Streamlined UI: their local navigation is a second rail
|
|
// beside the persistent company nav.
|
|
const sharedSecondarySidebar = isCompanySettingsRoute ? (
|
|
<CompanySettingsSidebar />
|
|
) : !streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
|
|
<SkillsContextualSidebar />
|
|
) : appDetailConnectionId ? (
|
|
<AppDetailSidebar kind="connection" connectionId={appDetailConnectionId} />
|
|
) : appDetailApplicationId ? (
|
|
<AppDetailSidebar kind="application" applicationId={appDetailApplicationId} />
|
|
) : isAppsRoute || isToolsRoute ? (
|
|
<AppsSidebar />
|
|
) : routeSidebarSlot ? (
|
|
streamlinedUiEnabled ? (
|
|
<ContextualSidebarFrame
|
|
surface={`plugin:${routeSidebarSlot.id}`}
|
|
title={routeSidebarSlot.displayName}
|
|
>
|
|
<PluginSlotMount
|
|
slot={routeSidebarSlot}
|
|
context={sidebarContext}
|
|
className="min-h-0 flex-1"
|
|
missingBehavior="placeholder"
|
|
/>
|
|
</ContextualSidebarFrame>
|
|
) : (
|
|
<PluginSlotMount
|
|
slot={routeSidebarSlot}
|
|
context={sidebarContext}
|
|
className="h-full w-full"
|
|
missingBehavior="placeholder"
|
|
/>
|
|
)
|
|
) : null;
|
|
const secondarySidebar = isAgentChatRoute ? (coordinatorWorkspaceEnabled ? null : <AgentConversationsSidebar />) : shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
|
<AgentContextualSidebar agentRef={agentId} />
|
|
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
|
|
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
|
|
) : sharedSecondarySidebar;
|
|
const hasSecondarySidebar = secondarySidebar != null;
|
|
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
|
|
isAgentChatRoute
|
|
|| shellRoute.builtInContextualSurface === "agent"
|
|
|| shellRoute.builtInContextualSurface === "routine"
|
|
|| isAppsRoute
|
|
|| isToolsRoute
|
|
);
|
|
const replacesPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && !keepsPrimarySidebar;
|
|
const showsAdjacentSecondarySidebar = hasSecondarySidebar && (!streamlinedUiEnabled || keepsPrimarySidebar);
|
|
const contextualSurface: ContextualSidebarSurface | null = !streamlinedUiEnabled || !hasSecondarySidebar
|
|
? null
|
|
: routeSidebarSlot && !shellRoute.builtInContextualSurface
|
|
? `plugin:${routeSidebarSlot.id}`
|
|
: shellRoute.builtInContextualSurface;
|
|
const previousShellRoute = useRef<{
|
|
pathname: string;
|
|
surface: ContextualSidebarSurface | null;
|
|
} | null>(null);
|
|
const { data: health } = useQuery({
|
|
queryKey: queryKeys.health,
|
|
queryFn: () => healthApi.get(),
|
|
refetchInterval: (query) => {
|
|
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
|
|
return data?.devServer?.enabled ? 2000 : false;
|
|
},
|
|
refetchIntervalInBackground: false,
|
|
});
|
|
|
|
useLayoutEffect(() => {
|
|
setForceCollapsed(!streamlinedUiEnabled && hasSecondarySidebar);
|
|
return () => setForceCollapsed(false);
|
|
}, [hasSecondarySidebar, setForceCollapsed, streamlinedUiEnabled]);
|
|
|
|
useEffect(() => {
|
|
const previous = previousShellRoute.current;
|
|
if (
|
|
contextualSurface
|
|
&& companyPrefix
|
|
&& previous
|
|
&& previous.surface !== contextualSurface
|
|
&& previous.pathname !== location.pathname
|
|
) {
|
|
rememberContextualSidebarOrigin({
|
|
surface: contextualSurface,
|
|
companyPrefix,
|
|
previousPathname: previous.pathname,
|
|
});
|
|
}
|
|
previousShellRoute.current = { pathname: location.pathname, surface: contextualSurface };
|
|
}, [companyPrefix, contextualSurface, location.pathname]);
|
|
|
|
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 (
|
|
<CoordinatorPresentation.Provider value={coordinatorWorkspaceEnabled}>
|
|
<CoordinatorChatReferencesProvider enabled={coordinatorWorkspaceEnabled}>
|
|
<ChatSetupSidebarProvider>
|
|
<div
|
|
ref={mobileViewportRef}
|
|
data-mobile={isMobile ? "true" : undefined}
|
|
className={cn(
|
|
"paperclip-app-shell bg-background text-foreground pt-(--sz-safe-top)",
|
|
coordinatorWorkspaceEnabled && "coordinator-workspace",
|
|
// 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>{secondarySidebar}</SecondarySidebar>
|
|
) : (
|
|
<Sidebar>{sidebarSections}</Sidebar>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
forceExpanded={replacesPrimarySidebar}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<SidebarShell
|
|
open={sidebarOpen}
|
|
collapsed={designSandbox || replacesPrimarySidebar ? false : collapsed}
|
|
peeking={designSandbox || replacesPrimarySidebar ? false : peeking}
|
|
resizable
|
|
storageKey={designSandbox ? "paperclip.design-proposals.sandbox.sidebar-width" : coordinatorWorkspaceEnabled ? "paperclip.coordinator.sidebar-width" : undefined}
|
|
onPanelMouseEnter={replacesPrimarySidebar ? undefined : handlePanelPointerEnter}
|
|
onPanelMouseLeave={replacesPrimarySidebar ? undefined : handlePanelPointerLeave}
|
|
onPanelFocusCapture={!replacesPrimarySidebar && collapsed ? handlePanelFocus : undefined}
|
|
onPanelBlurCapture={!replacesPrimarySidebar && collapsed ? handlePanelBlur : undefined}
|
|
>
|
|
{designSandbox ? <DesignSandboxSidebar /> : <>
|
|
<div className="flex flex-1 min-h-0">
|
|
{replacesPrimarySidebar ? (
|
|
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
|
) : (
|
|
<Sidebar>{sidebarSections}</Sidebar>
|
|
)}
|
|
</div>
|
|
{/* Dock optional announcements in the sidebar column so they never
|
|
cover the task thread or composer. While the sidebar is a rail
|
|
the card waits until the sidebar is expanded again. */}
|
|
{sidebarOpen && (replacesPrimarySidebar || !collapsed) ? (
|
|
<AnnouncementWell health={health} placement="sidebar" />
|
|
) : null}
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
forceExpanded={replacesPrimarySidebar}
|
|
/>
|
|
</>}
|
|
</SidebarShell>
|
|
)}
|
|
|
|
{!isMobile && ((showsAdjacentSecondarySidebar && !keepsPrimarySidebar) || (designSandbox && replacesPrimarySidebar)) ? (
|
|
<SecondarySidebar className="w-60 shrink-0">
|
|
{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 && useStreamlinedTaskDetailShell && "hidden",
|
|
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",
|
|
!isMobile && useStreamlinedTaskDetailShell && "streamlined-task-detail-surface",
|
|
)}>
|
|
{!isMobile && keepsPrimarySidebar ? (
|
|
<SecondarySidebar className="w-60 shrink-0 bg-background">
|
|
{secondarySidebar}
|
|
</SecondarySidebar>
|
|
) : null}
|
|
<div className={cn(!isMobile && useStreamlinedTaskDetailShell ? "flex min-w-0 flex-1 flex-col" : "contents")}>
|
|
{!isMobile && useStreamlinedTaskDetailShell ? (
|
|
<>
|
|
<StandaloneBrowserControls mobile={false} />
|
|
<BreadcrumbBar taskDetailLayout />
|
|
</>
|
|
) : null}
|
|
<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(--tc-composer-hidden-nav-offset)",
|
|
"--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",
|
|
// The task thread owns its scrollable top spacing. Leaving the
|
|
// page shell's top padding in place creates a stationary dark
|
|
// strip below the breadcrumb while messages scroll behind it.
|
|
!isMobile && useStreamlinedTaskDetailShell && "pt-0 md:pt-0",
|
|
// 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>
|
|
</div>
|
|
<PropertiesPanel taskDetailLayout={!isMobile && useStreamlinedTaskDetailShell} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isMobile && <MobileBottomNav visible={mobileNavVisible} />}
|
|
<CommandPalette />
|
|
<BoardDialogs />
|
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
|
<ToastViewport />
|
|
{isMobile ? <AnnouncementWell health={health} placement="floating" /> : null}
|
|
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
|
</div>
|
|
</ChatSetupSidebarProvider>
|
|
</CoordinatorChatReferencesProvider>
|
|
</CoordinatorPresentation.Provider>
|
|
);
|
|
}
|