mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent Chat already provides a persistent conversation with each agent. > - Its shortcuts share the primary navigation and do not give chats a dedicated place. > - People need to find agents, start a chat, and switch conversations without moving the page layout. > - This pull request adds a secondary chat sidebar and a landing page around the existing chat surface. > - The same conversation, composer, history, and context panel remain in use. ## Linked Issues or Issue Description Refs #13283 and #13420. This extends the existing experimental Agent Chat navigation after review of the component and page stories. It supports the CEO Chat roadmap item through the existing task-backed conversation model. **Subsystem affected** The board UI and the company-scoped conversation list API. **Current behavior** Chat shortcuts sit inside the primary navigation. There is no dedicated landing page with a searchable conversation list. A separate landing header also moves the sidebar when an agent is selected. **Proposed behavior** Show a Chat entry in primary navigation. Keep a searchable agent sidebar beside the chat content. The plus button starts or reopens the current user's single conversation with that agent. Keep the header and sidebar in the same positions before and after selection. **Reason and benefit** People can find agents and return to persistent conversations without leaving the chat area or creating duplicate chats. **Breaking changes** The experimental chat navigation changes. Explicitly adding a chat now resolves its conversation immediately. Direct visits to unused agent chat URLs remain read-only. The existing per-agent routes and message contracts remain compatible. No database migration is required. ## What Changed - Add an account- and company-scoped conversation list endpoint with the existing access checks, feature gate, and OpenAPI entry. - Add the live secondary sidebar, landing page, avatars, search, loading states, errors, and retry controls. - Make the agent picker wait for chat creation and display failures. Existing agents reopen the same conversation. A dismissed selection cannot close a reopened picker or navigate over a newer choice. - Preserve recent-activity ordering and terminated agents’ chat history. Scope live list refreshes to the current user’s conversation events. A failed historical-agent lookup leaves healthy chats usable and offers a focused retry. - Keep the sidebar and header stable across chat routes. Keep mobile selection in the navigation drawer. - Use the production components in Storybook. Prepare the theme and mobile viewport before mounting the page to avoid the startup flash. - Update product documentation, the design guide, and navigation tests. Replace old browser expectations for stars and recent shortcuts with persistent conversation and layout coverage. ## Verification - `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` pass after rebasing onto master. - Focused UI tests pass, including 105 sidebar, picker, and live-update checks after review fixes. The 33 conversation service and route tests and 10 OpenAPI checks pass, including ownership, feature gating, and concurrent creation. - The full local test run passed 14,163 server tests before three environment or timeout failures. The embedded Postgres startup, connector socket, and native runner failures all passed direct reruns. - Browser test-drive verification covers a real provider reply, add and reopen, persisted history after reload, no-match search recovery, mobile drawer dismissal, and top-aligned context panels. - Browser measurements confirm that the sidebar has the same position and dimensions on the landing page and an agent conversation. - Storybook builds and its add-and-reopen interaction passes. - The revised browser regression passes locally against a freshly built throwaway instance. It covers stable sidebar geometry, add/reopen uniqueness, drafts, search, history, and terminated-agent history after reload. The full CI browser suite also passes. - Latest commit `b323577d9523180104df4000eaceedea2772608c`: all 54 completed checks pass, including the complete server/workspace/browser suites, aggregate verification, build/typecheck, security scans, and canary packaging. The two Storybook jobs are skipped by their workflow conditions. [CI run](https://github.com/paperclipai/paperclip/actions/runs/36714052050). - Greptile reviewed this same commit at 5/5 with no remaining actionable findings; all review threads are resolved. - Reviewer path: enable Agent Chat, click Chat, use plus to choose an agent, send a message, switch away, and reopen that agent. One conversation must remain, with its history intact. ## Risks - The new sidebar lists persistent conversations instead of starred and recent shortcuts. - Chat creation is asynchronous. Errors stay visible in the picker, and delayed responses cannot navigate into a previous company or account. - The shell adjustment is limited to chat routes and preserves the existing conversation implementation. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code execution, and browser tools. The session does not expose the exact API model ID or context-window size. ## 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 #123` / `Refs #123` 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
797 lines
31 KiB
TypeScript
797 lines
31 KiB
TypeScript
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 { NewIssueDialog } from "./NewIssueDialog";
|
|
import { NewProjectDialog } from "./NewProjectDialog";
|
|
import { NewGoalDialog } from "./NewGoalDialog";
|
|
import { NewAgentDialog } from "./NewAgentDialog";
|
|
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 { 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",
|
|
"connect",
|
|
"chat",
|
|
"vercel-connect",
|
|
"review",
|
|
"attention",
|
|
"gateways",
|
|
"advanced",
|
|
"app",
|
|
]);
|
|
|
|
export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
|
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,
|
|
agentId,
|
|
routineId,
|
|
} = useParams<{
|
|
companyPrefix: string;
|
|
pluginRoutePath?: string;
|
|
agentId?: string;
|
|
routineId?: string;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const navigationType = useNavigationType();
|
|
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
|
|
const shellRoute = classifyShellRoute(location.pathname, companyPrefix);
|
|
const isCompanySettingsRoute = shellRoute.builtInContextualSurface === "settings";
|
|
const companyPathSegments = shellRoute.companySegments;
|
|
const isTaskDetailRoute = shellRoute.isTaskDetail;
|
|
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 lastMainScrollTop = useRef(0);
|
|
const previousPathname = useRef<string | null>(null);
|
|
const mainContentRef = useRef<HTMLElement | null>(null);
|
|
const scrollMemory = useRef(new NavigationScrollMemory());
|
|
const activeScrollKey = useRef<string>(location.key);
|
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
|
const matchedCompany = useMemo(() => {
|
|
if (!companyPrefix) return null;
|
|
const requestedPrefix = companyPrefix.toUpperCase();
|
|
return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null;
|
|
}, [companies, companyPrefix]);
|
|
const hasUnknownCompanyPrefix =
|
|
Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany;
|
|
const pluginRoutePath = useMemo(
|
|
() => matchedPluginRoutePath?.toLowerCase() ?? getCompanyRouteSegment(location.pathname, companyPrefix),
|
|
[companyPrefix, location.pathname, matchedPluginRoutePath],
|
|
);
|
|
const routeSidebarCompanyId = matchedCompany?.id ?? null;
|
|
const routeSidebarCompanyPrefix = matchedCompany?.issuePrefix ?? null;
|
|
const { slots: routeSidebarSlots } = usePluginSlots({
|
|
slotTypes: ["page", "routeSidebar"],
|
|
companyId: routeSidebarCompanyId,
|
|
enabled: Boolean(routeSidebarCompanyId && pluginRoutePath),
|
|
});
|
|
const routeSidebarSlot = useMemo(
|
|
() => resolveRouteSidebarSlot(routeSidebarSlots, pluginRoutePath),
|
|
[pluginRoutePath, routeSidebarSlots],
|
|
);
|
|
const sidebarContext = useMemo(
|
|
() => ({
|
|
companyId: routeSidebarCompanyId,
|
|
companyPrefix: routeSidebarCompanyPrefix,
|
|
}),
|
|
[routeSidebarCompanyId, routeSidebarCompanyPrefix],
|
|
);
|
|
// Most contextual routes replace the global navigation inside the same
|
|
// sidebar shell. Skills, 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 ? <AgentConversationsSidebar /> : shellRoute.builtInContextualSurface === "agent" && agentId ? (
|
|
<AgentContextualSidebar agentRef={agentId} />
|
|
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "routine" && routineId ? (
|
|
<SetupWizardSidebarOutlet><RoutineContextualSidebar routineId={routineId} /></SetupWizardSidebarOutlet>
|
|
) : streamlinedUiEnabled && shellRoute.builtInContextualSurface === "skills" ? (
|
|
<SkillsContextualSidebar />
|
|
) : sharedSecondarySidebar;
|
|
const hasSecondarySidebar = secondarySidebar != null;
|
|
const keepsPrimarySidebar = streamlinedUiEnabled && hasSecondarySidebar && (
|
|
isAgentChatRoute
|
|
|| shellRoute.builtInContextualSurface === "skills"
|
|
|| 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(),
|
|
retry: false,
|
|
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"),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
return;
|
|
}
|
|
lastMainScrollTop.current = 0;
|
|
setMobileNavVisible(true);
|
|
}, [isMobile]);
|
|
|
|
// Swipe gesture to open/close sidebar on mobile
|
|
useEffect(() => {
|
|
if (!isMobile) return;
|
|
|
|
const EDGE_ZONE = 30; // px from left edge to start open-swipe
|
|
const MIN_DISTANCE = 50; // minimum horizontal swipe distance
|
|
const MAX_VERTICAL = 75; // max vertical drift before we ignore
|
|
|
|
let startX = 0;
|
|
let startY = 0;
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
const t = e.touches[0]!;
|
|
startX = t.clientX;
|
|
startY = t.clientY;
|
|
};
|
|
|
|
const onTouchEnd = (e: TouchEvent) => {
|
|
const t = e.changedTouches[0]!;
|
|
const dx = t.clientX - startX;
|
|
const dy = Math.abs(t.clientY - startY);
|
|
|
|
if (dy > MAX_VERTICAL) return; // vertical scroll, ignore
|
|
|
|
// Swipe right from left edge → open
|
|
if (!sidebarOpen && startX < EDGE_ZONE && dx > MIN_DISTANCE) {
|
|
setSidebarOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Swipe left when open → close
|
|
if (sidebarOpen && dx < -MIN_DISTANCE) {
|
|
setSidebarOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("touchstart", onTouchStart, { passive: true });
|
|
document.addEventListener("touchend", onTouchEnd, { passive: true });
|
|
|
|
return () => {
|
|
document.removeEventListener("touchstart", onTouchStart);
|
|
document.removeEventListener("touchend", onTouchEnd);
|
|
};
|
|
}, [isMobile, sidebarOpen, setSidebarOpen]);
|
|
|
|
const updateMobileNavVisibility = useCallback((currentTop: number) => {
|
|
const delta = currentTop - lastMainScrollTop.current;
|
|
|
|
if (currentTop <= 24) {
|
|
setMobileNavVisible(true);
|
|
} else if (delta > 8) {
|
|
setMobileNavVisible(false);
|
|
} else if (delta < -8) {
|
|
setMobileNavVisible(true);
|
|
}
|
|
|
|
lastMainScrollTop.current = currentTop;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobile) {
|
|
setMobileNavVisible(true);
|
|
lastMainScrollTop.current = 0;
|
|
return;
|
|
}
|
|
|
|
const onScroll = () => {
|
|
updateMobileNavVisibility(window.scrollY || document.documentElement.scrollTop || 0);
|
|
};
|
|
|
|
onScroll();
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [isMobile, updateMobileNavVisibility]);
|
|
|
|
useEffect(() => {
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
document.body.style.overflow = isMobile ? "visible" : "clip";
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [isMobile]);
|
|
|
|
// `scrollIntoView` walks every ancestor scroll container. On a long thread
|
|
// the post-submit `scrollIntoView` on the new comment reaches `<html>` and
|
|
// animates `documentElement.scrollTop` via the browser's internal scroll
|
|
// algorithm, which bypasses the CSS `overflow` on the root element and
|
|
// visually shifts the entire shell (sidebar included) off-screen. Pin
|
|
// both roots to scrollTop=0 on every scroll tick.
|
|
useEffect(() => {
|
|
if (isMobile) return;
|
|
return pinDocumentScrollToZero();
|
|
}, [isMobile]);
|
|
|
|
useEffect(() => {
|
|
if (typeof document === "undefined") return;
|
|
const mainContent = mainContentRef.current;
|
|
return scheduleMainContentFocus(mainContent);
|
|
}, [location.pathname]);
|
|
|
|
// Continuously record the scroll offset of the active history entry so a
|
|
// later back/forward navigation can restore it (see NavigationScrollMemory).
|
|
useEffect(() => {
|
|
const main = mainContentRef.current;
|
|
if (!main) return;
|
|
const recordScroll = () => {
|
|
scrollMemory.current.remember(activeScrollKey.current, main.scrollTop);
|
|
};
|
|
main.addEventListener("scroll", recordScroll, { passive: true });
|
|
return () => main.removeEventListener("scroll", recordScroll);
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const main = mainContentRef.current;
|
|
const shouldResetScroll = shouldResetScrollOnNavigation({
|
|
previousPathname: previousPathname.current,
|
|
pathname: location.pathname,
|
|
navigationType,
|
|
state: location.state,
|
|
});
|
|
|
|
previousPathname.current = location.pathname;
|
|
|
|
const isHistoryPop = navigationType === "POP";
|
|
const restoredScrollTop = isHistoryPop ? scrollMemory.current.recall(location.key) : 0;
|
|
activeScrollKey.current = location.key;
|
|
|
|
if (isHistoryPop) {
|
|
applyMainContentScrollTop(main, restoredScrollTop);
|
|
// Cached page content can finish laying out a frame after commit; re-apply
|
|
// once it has so the restored offset isn't clamped to a shorter interim height.
|
|
const raf = requestAnimationFrame(() => applyMainContentScrollTop(main, restoredScrollTop));
|
|
return () => cancelAnimationFrame(raf);
|
|
}
|
|
|
|
if (shouldResetScroll) {
|
|
resetNavigationScroll(main);
|
|
}
|
|
}, [location.key, location.pathname, location.state, navigationType]);
|
|
|
|
return (
|
|
<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>{secondarySidebar}</SecondarySidebar>
|
|
) : (
|
|
<Sidebar>{sidebarSections}</Sidebar>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
forceExpanded={replacesPrimarySidebar}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<SidebarShell
|
|
open={sidebarOpen}
|
|
collapsed={replacesPrimarySidebar ? false : collapsed}
|
|
peeking={replacesPrimarySidebar ? false : peeking}
|
|
resizable
|
|
onPanelMouseEnter={replacesPrimarySidebar ? undefined : handlePanelPointerEnter}
|
|
onPanelMouseLeave={replacesPrimarySidebar ? undefined : handlePanelPointerLeave}
|
|
onPanelFocusCapture={!replacesPrimarySidebar && collapsed ? handlePanelFocus : undefined}
|
|
onPanelBlurCapture={!replacesPrimarySidebar && collapsed ? handlePanelBlur : undefined}
|
|
>
|
|
<div className="flex flex-1 min-h-0">
|
|
{replacesPrimarySidebar ? (
|
|
<SecondarySidebar>{secondarySidebar}</SecondarySidebar>
|
|
) : (
|
|
<Sidebar>{sidebarSections}</Sidebar>
|
|
)}
|
|
</div>
|
|
<SidebarAccountMenu
|
|
deploymentMode={health?.deploymentMode}
|
|
forceExpanded={replacesPrimarySidebar}
|
|
/>
|
|
</SidebarShell>
|
|
)}
|
|
|
|
{!isMobile && showsAdjacentSecondarySidebar && !keepsPrimarySidebar ? (
|
|
<SecondarySidebar className="w-60 shrink-0">
|
|
{secondarySidebar}
|
|
</SecondarySidebar>
|
|
) : null}
|
|
|
|
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "h-full flex-1")}>
|
|
<div
|
|
className={cn(
|
|
!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(
|
|
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)",
|
|
} 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
|
|
? mobileNavVisible
|
|
? "overflow-visible pb-(--tc-composer-visible-nav-offset)"
|
|
: "overflow-visible pb-(--tc-composer-hidden-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 />
|
|
<NewIssueDialog />
|
|
<NewProjectDialog />
|
|
<NewGoalDialog />
|
|
<NewAgentDialog />
|
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
|
<ToastViewport />
|
|
<AnnouncementWell health={health} />
|
|
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
|
</div>
|
|
</ChatSetupSidebarProvider>
|
|
);
|
|
}
|