diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf728e..77e29ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - omp-web no longer exits when a browser disconnects in the middle of a POST request, for example when a tab closes while a request is still in flight. A Next.js bug (vercel/next.js#99278) could turn that disconnect into an uncaught `Error: aborted`, which the crash handler treated as fatal. These disconnects are now logged to `diagnostics.log` as `client-abort` and the server keeps running; every other uncaught error still exits. - When `PI_CODING_AGENT_DIR` points away from `~/.omp/agent`, the crash journal (`diagnostics.log`) is written to `/omp-web/` instead of `~/.omp/omp-web/`, so isolated test and development servers no longer write to the real journal. - Slide the file panel in from the right edge on phones, with the same timing as the left sidebar, instead of popping it open full-screen. The first open slides too, without a **Loading…** placeholder or a dimmed backdrop flashing over the chat. The panel still covers the whole screen once open, and it opens and closes instantly when reduced motion is enabled. +- On mobile, move the session information control (context ring with stats, tokens, cost, and **Compact context**) to the top-right of the top bar; desktop keeps it in the composer toolbar. Preserve the ring usage coloring, its popover, and the `/session` shortcut. Opening the panel moves focus into it; Escape dismisses it before run shortcuts and returns focus to its opener when focus is still within the panel. +- Make the mobile workspace sidebar full-screen without a dimmed backdrop, with a visible top-left close button. Place the file-panel opener inside the upper bar and add a separate top-right close button inside the file panel, with keyboard focus returning to its opener. Keep the session-title pill centered between the header controls. +- Continue the mobile file-panel tab-row border underneath its close-button cell, so the divider spans the full width. +- On mobile, swipe anywhere on the app surface: swipe right to open the left sidebar or close the right one, and swipe left for the opposite action. No device-edge start or perfectly straight movement is required. Recognition follows overall dominant-axis displacement and tolerates curved thumb movement, a brief corrective start, and shorter deliberate drags. Taps, predominantly vertical movement, multi-touch, text editing or selection, native horizontal scrolling, and open popups retain their normal behavior; persistent drawer lists, including Git changes, remain swipeable. Embedded previews retain native input and full width; swipe on their surrounding toolbar to close the sidebar. +- Let the centered session-title pill fit its content and grow for longer names up to the available header space, instead of stretching short names into a fixed-size box. - Keep **Collapse input** available after expanding a long user message with **Show full input**, so the message can be collapsed again. - Agent host tools (`open_url`, `notify`, `open_file`) no longer fail when you switch to another session mid-run. Any open omp-web tab now answers them, and a URL or file from a session you are not viewing opens only after you confirm it in a dialog. - Ask before opening links from the agent. Turn on **Open agent links without asking** in Settings → Interface & Behavior to open links from the session you are viewing right away; links from other sessions always ask. diff --git a/app/globals.css b/app/globals.css index 7029118c..f9828ce1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3332,15 +3332,24 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { .shell-toolbar-btn:active:not(:disabled) { transform: scale(0.96); } -.shell-panel-toggle { - border-radius: 0 !important; - background: var(--bg-panel); - color: var(--text-muted); +.right-panel-toolbar { + --right-panel-close-width: 36px; + position: relative; + padding-right: var(--right-panel-close-width); } -.shell-panel-toggle:hover:not(:disabled), -.shell-panel-toggle[aria-pressed="true"] { - background: var(--bg-hover); - color: var(--text); +html:root .shell-toolbar-btn.right-panel-close-button { + position: absolute; + top: 0; + right: 0; + width: var(--right-panel-close-width) !important; + height: var(--shell-topbar-height) !important; + min-width: 0 !important; + min-height: 0 !important; + padding: 0 !important; + border-radius: 0; +} +html[data-touch-targets="accessible"] .right-panel-toolbar { + --right-panel-close-width: 44px; } /* Subtle group separator between toolbar control clusters */ .shell-toolbar-divider { @@ -3359,6 +3368,13 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { width: 40px; height: 40px; } + .right-panel-toolbar { + --right-panel-close-width: 44px; + } + html:root .shell-toolbar-btn.right-panel-close-button { + padding-top: env(safe-area-inset-top, 0px) !important; + border-bottom: 1px solid var(--border); + } .shell-toolbar-divider { margin: 0 4px; } @@ -3380,7 +3396,17 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } } +:root { + --shell-topbar-height: 36px; +} +@media (min-width: 641px) { + html[data-touch-targets="accessible"] { + --shell-topbar-height: 44px; + } +} .shell-topbar { + /* Fallback until AppShell measures the real clusters. */ + --topbar-side: 260px; display: flex; flex-wrap: nowrap; justify-content: space-between; @@ -3399,10 +3425,11 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .shell-topbar-center { position: absolute; - top: 4.5px; - left: 50%; - transform: translateX(-50%); - width: clamp(0px, calc(100% - 520px), 400px); + top: 0; + bottom: 0; + /* Equal insets center the content-sized pill between the control clusters. */ + left: var(--topbar-side); + right: var(--topbar-side); pointer-events: none; } .shell-topbar-center > .shell-topbar-breadcrumb { @@ -3416,42 +3443,23 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } } -@media (min-width: 641px) and (max-width: 960px) { - .shell-topbar-center { - display: flex !important; - width: min(260px, 28vw); +@media (max-width: 640px) { + :root { + --shell-topbar-height: calc(44px + env(safe-area-inset-top, 0px)); } - .shell-topbar-center > .shell-topbar-breadcrumb { - max-width: 100%; + .shell-topbar { + --topbar-side: max(96px, calc(92px + env(safe-area-inset-right, 0px))); } -} - -@media (max-width: 640px) { .shell-topbar-center { - display: flex !important; - position: static; - inset: auto; - width: auto; - max-width: none; - min-width: 0; - flex: 1 1 auto; - justify-content: flex-start; - overflow: hidden; - transform: none; - } - .shell-topbar-center .shell-topbar-breadcrumb { - width: auto; - max-width: min(320px, 100%); + top: env(safe-area-inset-top, 0px); } .shell-topbar-center .shell-topbar-breadcrumb { visibility: visible; } - } @media (max-width: 640px) { .shell-topbar { gap: 0 4px !important; - padding-right: 48px !important; } .shell-topbar-overflow-content { overflow-x: auto; @@ -3470,20 +3478,13 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { .shell-topbar .shell-toolbar-divider { margin: 0; } - .shell-topbar [data-topbar-right-group] { - width: 0 !important; - min-width: 0 !important; - padding: 0 !important; - } - .shell-topbar-center { - margin: 0 4px; - justify-content: center; + .shell-topbar-metrics { + display: none; } .shell-topbar-project-context { display: none !important; } .shell-topbar-center .shell-topbar-breadcrumb { - max-width: 100%; height: 32px; padding-inline: 4px; border-color: transparent; @@ -3544,7 +3545,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { display: inline-flex; align-items: center; gap: 6px; - max-width: min(440px, 32vw); + max-width: 100%; height: 26px; padding: 0 8px; border-radius: var(--radius-control); @@ -4064,9 +4065,6 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { /* Desktop sidebar: animate width */ @media (min-width: 641px) { - .sidebar-overlay-backdrop { - display: none !important; - } .sidebar-container { position: relative; overflow: hidden; @@ -4126,7 +4124,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .right-panel-container.right-panel-open { position: fixed; - top: 36px; + top: var(--shell-topbar-height); right: 0; bottom: 0; width: min(360px, 55vw); @@ -4157,20 +4155,16 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .sidebar-container { position: fixed !important; - top: env(safe-area-inset-top, 0px); - left: 0; - bottom: 0; - width: 280px; - max-width: 85vw; - transition: transform var(--dur-slow) var(--ease-out-warm), box-shadow var(--dur-slow) var(--ease-out-warm); + inset: 0; + /* Full-screen drawer: safe-area padding (the list adds its own bottom). */ + padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) 0 env(safe-area-inset-left, 0px); + transition: transform var(--dur-slow) var(--ease-out-warm); } .sidebar-container.sidebar-open { transform: translateX(0); - box-shadow: var(--shadow-modal); } .sidebar-container.sidebar-closed { transform: translateX(-100%); - box-shadow: none; } /* Full-screen overlay that slides in from the right edge. Closed panels are @@ -4186,7 +4180,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } .right-panel-toolbar { padding-top: env(safe-area-inset-top, 0px); - min-height: calc(44px + env(safe-area-inset-top, 0px)) !important; + min-height: var(--shell-topbar-height) !important; } .right-panel-container.right-panel-open { transform: none; @@ -4325,7 +4319,8 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { html[data-touch-targets="accessible"] :is( .shell-toolbar-btn, .right-panel-toolbar [role="toolbar"] > button, - .composer-primary-action + .composer-primary-action, + .composer-context-control ) { min-width: 44px; } @@ -4339,6 +4334,7 @@ html[data-touch-targets="accessible"] :is( .composer-toolbar > button, .composer-toolbar > div > button, .composer-primary-action, + .composer-context-control, .shell-toolbar-btn, .sidebar-icon-button, .sidebar-project-action, diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0d855989..799946cf 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useSidebarHistory } from "@/hooks/useSidebarHistory"; import { useModalDialog } from "@/hooks/useModalDialog"; +import { useMobileSidebarGestures } from "@/hooks/useMobileSidebarGestures"; import { SessionSidebar } from "./SessionSidebar"; import { ToastProvider } from "./ui/toast"; import { toast } from "./ui/toast"; @@ -17,7 +18,7 @@ import type { RightPanelView } from "./RightPanel"; import { BranchNavigator } from "./BranchNavigator"; import { LanguageSwitcher } from "./LanguageSwitcher"; import { CommandPaletteMount } from "./CommandPaletteMount"; -import { Check, ChevronDown, Command, Ellipsis, Folder, History, Menu, PanelLeft, PanelRight, Terminal, Wand2, X, Zap } from "lucide-react"; +import { Check, ChevronDown, Command, Ellipsis, Folder, History, Menu, PanelLeft, PanelRight, Terminal, Wand2, Zap } from "lucide-react"; import { ThemeSwitcher } from "./ThemeSwitcher"; import { translate, useI18n } from "@/lib/i18n"; import { formatApiError } from "@/lib/i18n/api-error"; @@ -648,6 +649,7 @@ export function AppShell() { }, [resetAppUpdateVisibleStage]); const chatInputRef = useRef(null); const topBarRef = useRef(null); + const [sessionInfoContainer, setSessionInfoContainer] = useState(null); // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); @@ -762,8 +764,7 @@ export function AppShell() { }); }, [activeTopPanel, systemPrompt, systemPromptLoading, toggleTopPanel]); - // The topbar session panel is gone (its content lives in the composer ring - // popover), so /session opens that instead. + // /session opens the same information panel as the top-bar control. const openSessionStatsPanel = useCallback(() => { chatInputRef.current?.openContextPanel(); }, []); @@ -970,6 +971,12 @@ export function AppShell() { const [fileTabs, setFileTabs] = useState([]); const [activeFileTabId, setActiveFileTabId] = useState(null); const [rightPanelOpen, setRightPanelOpen] = useState(false); + const closeRightPanel = useCallback(() => { + setRightPanelOpen(false); + if (!isCompactOverlay) { + topBarRef.current?.querySelector(".shell-panel-opener")?.focus(); + } + }, [isCompactOverlay]); const [rightPanelHasOpened, setRightPanelHasOpened] = useState(false); const [rightView, setRightView] = useState<"explorer" | "git" | "file">("explorer"); // User-chosen pixel width (null = fluid 42% default), persisted. @@ -983,13 +990,20 @@ export function AppShell() { const [rightPanelMounted, setRightPanelMounted] = useState(false); const rightPanelIsModal = isCompactOverlay && rightPanelMounted && rightPanelOpen && !settingsTab; const rightPanelRef = useModalDialog({ - onClose: () => setRightPanelOpen(false), + onClose: closeRightPanel, active: rightPanelIsModal, }); const attachRightPanel = useCallback((element: HTMLDivElement | null) => { rightPanelRef.current = element; setRightPanelMounted(element !== null); }, [rightPanelRef]); + useMobileSidebarGestures({ + enabled: isMobile && mobileSidebarReady && !settingsTab, + leftOpen: sidebarOpen, + rightOpen: rightPanelOpen, + onLeftOpenChange: setSidebarOpen, + onRightOpenChange: setRightPanelOpen, + }); const pendingRightPanelWidthRef = useRef(null); const rightResizeHandlersRef = useRef<{ onMove: (ev: MouseEvent) => void; onUp: () => void } | null>(null); useEffect(() => { @@ -1564,23 +1578,30 @@ export function AppShell() { !["absolute", "fixed"].includes(getComputedStyle(child).position)); const contentStyle = getComputedStyle(content); const headerStyle = getComputedStyle(header); - const rightStyle = getComputedStyle(right); const controlsWidth = children.reduce((width, child) => { const style = getComputedStyle(child); return width + child.getBoundingClientRect().width + parseFloat(style.marginLeft) + parseFloat(style.marginRight); }, 0) + Math.max(0, children.length - 1) * parseFloat(contentStyle.columnGap); - const required = controlsWidth + const leftNeed = controlsWidth + (tools.firstElementChild?.getBoundingClientRect().width ?? 0) + parseFloat(getComputedStyle(tools).columnGap) - + parseFloat(headerStyle.paddingLeft) + parseFloat(headerStyle.paddingRight) - + parseFloat(headerStyle.columnGap) - + parseFloat(rightStyle.minWidth) - + parseFloat(rightStyle.paddingLeft) + parseFloat(rightStyle.paddingRight); + + parseFloat(headerStyle.paddingLeft); + const rightNeed = parseFloat(headerStyle.paddingRight) + right.offsetWidth; + const required = leftNeed + parseFloat(headerStyle.columnGap) + rightNeed; if (closed) details.open = false; content.style.display = display; content.style.visibility = visibility; - const compact = required > header.clientWidth; + // Title pill: same inset on both sides = widest real cluster (+8px gap), so it + // is truly centered and never under a control. Expand the tools only if the + // pill still keeps >= 200px (the container-query hide threshold) with them. + header.style.setProperty( + "--topbar-side", + `${Math.ceil(Math.max(tools.offsetLeft + tools.offsetWidth, header.clientWidth - right.offsetLeft)) + 8}px`, + ); + const titleFits = header.querySelector(".shell-topbar-center") === null + || 2 * Math.max(leftNeed, rightNeed) + 216 <= header.clientWidth; + const compact = required > header.clientWidth || !titleFits; if (compact && details.dataset.compact !== "true" && hadControlsFocus) { restoreToolsFocusRef.current = true; } @@ -1588,6 +1609,8 @@ export function AppShell() { }; const observer = new ResizeObserver(update); observer.observe(header); + observer.observe(right); + observer.observe(tools); observer.observe(content); for (const child of content.children) observer.observe(child); document.fonts.addEventListener("loadingdone", update); @@ -1596,7 +1619,7 @@ export function AppShell() { observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); }; - }, [hasGenerationSpeed, isMobile, locale, rightPanelOpen, showChat]); + }, [isMobile, locale, settingsTab, showChat]); // While restoring initial session from URL, don't show the placeholder const showPlaceholder = initialSessionRestored && !showChat; @@ -1648,6 +1671,7 @@ export function AppShell() { onOpenSettings={() => setSettingsTab((prev) => prev ? null : "general")} onOpenArchive={() => setArchiveBrowserOpen(true)} updateAvailable={Boolean(appUpdate?.updateAvailable) || ompUpdateAvailable} + onClose={isMobile ? handleSidebarToggle : undefined} /> ); @@ -1755,13 +1779,8 @@ export function AppShell() { } } @media (max-width: 640px) { - .sidebar-overlay-backdrop.sidebar-mobile-pending { - opacity: 0 !important; - pointer-events: none !important; - } .sidebar-container.sidebar-mobile-pending.sidebar-open { transform: translateX(-100%); - box-shadow: none; } } `} @@ -1771,21 +1790,6 @@ export function AppShell() { {/* Left sidebar: hidden on full-page Settings */} {!settingsTab && ( <> - {/* Mobile overlay backdrop */} -
setSidebarOpen(false)} - style={{ - position: "fixed", - inset: 0, - zIndex: 199, - background: "color-mix(in srgb, var(--text) 28%, transparent)", - opacity: sidebarOpen ? 1 : 0, - pointerEvents: sidebarOpen ? "auto" : "none", - transition: "opacity var(--dur-slow) var(--ease-out-warm)", - }} - /> -
@@ -2282,6 +2311,7 @@ export function AppShell() { onSystemPromptLoaderChange={handleSystemPromptLoaderChange} onSessionStatsChange={handleSessionStatsChange} onSessionStatsPanelOpen={openSessionStatsPanel} + sessionInfoContainer={sessionInfoContainer} onGenerationSpeedChange={handleGenerationSpeedChange} onOpenProviders={() => setSettingsTab("providers")} toolCallsDefaultCollapsed={toolCallsDefaultCollapsed} @@ -2354,7 +2384,7 @@ export function AppShell() { - {!settingsTab && ( - - )} void proceedWithAppUpdate()} onNotNow={dismissAppUpdate} /> {archiveBrowserOpen && ( { assert.match(html, /aria-label="(More actions|chatInput\.plusMenu)"/); }); -test("renders the compact toolbar action", () => { - const html = renderToStaticMarkup( - React.createElement(ChatInput, { - onSend() {}, - onAbort() {}, - onCompact() {}, - isStreaming: false, - }), - ); - - assert.match(html, /title="(Compact context|chatInput\.compactContext)"/); -}); - test("shows the advisor thunder indicator with the reviewing model and reasoning", () => { const html = renderToStaticMarkup( React.createElement(ChatInput, { diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 8b99e757..55422994 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef, memo, KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; import { ChevronDown, ClipboardPaste, ListChecks, Loader2, Mic, Paperclip, Plus, Shrink, Snail, Sparkles, Wrench, X, Zap } from "lucide-react"; import { getSubmitDuringRunBehavior, isWordCompletionEnabled } from "@/lib/composer-prefs"; import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession"; @@ -183,12 +184,14 @@ interface Props { advisorActive?: boolean; /** Resolved advisor role (display model + reasoning) for the composer tooltips. */ advisorModel?: { name: string; reasoning: string | null } | null; - /** Compact the session context from the composer toolbar. */ + /** Compact the session context from the session information panel. */ onCompact?: () => void; - /** Live context totals feeding the composer context ring. */ + /** Live context totals feeding the session information ring. */ contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; /** Session stats shown in the context ring popover. */ sessionStats?: SessionStatsInfo | null; + /** Mobile top-bar mount point for the session information control. */ + sessionInfoContainer?: HTMLDivElement | null; /** Model capacity shown in the context ring popover. */ modelCapacity?: { contextWindow?: number; maxTokens?: number } | null; /** Generation speed shown in the context ring popover. */ @@ -327,6 +330,7 @@ export const ChatInput = memo(forwardRef(function ChatIn onCompact, contextUsage, sessionStats, + sessionInfoContainer, modelCapacity, generationSpeed, onRemoveQueuedMessage, @@ -389,6 +393,8 @@ export const ChatInput = memo(forwardRef(function ChatIn const [modelDropdownOpen, setModelDropdownOpen] = useState(false); const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false); const [contextOpen, setContextOpen] = useState(false); + const [composerContextContainer, setComposerContextContainer] = useState(null); + const contextContainer = isMobile ? sessionInfoContainer : composerContextContainer; const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [plusExpanded, setPlusExpanded] = useState<"tools" | "advisor" | null>(null); const [modelSearchQuery, setModelSearchQuery] = useState(""); @@ -421,6 +427,7 @@ export const ChatInput = memo(forwardRef(function ChatIn const modelSearchInputRef = useRef(null); const thinkingDropdownRef = useRef(null); const contextWrapRef = useRef(null); + const contextReturnFocusRef = useRef(null); const plusMenuRef = useRef(null); const historyMenuRef = useRef(null); const slashMenuRef = useRef(null); @@ -1694,7 +1701,7 @@ export const ChatInput = memo(forwardRef(function ChatIn saved: formatTokenCount(compactSavedTokens, locale), }) : null; - // Composer context ring: live totals, falling back to the session snapshot. + // Session information ring: live totals, falling back to the session snapshot. const ringCtx = contextUsage ?? sessionStats?.contextUsage ?? null; const ringPct = ringCtx?.percent ?? null; const ringTone = ringPct !== null && ringPct > 90 @@ -1750,6 +1757,39 @@ export const ChatInput = memo(forwardRef(function ChatIn if (!thinkingDropdownOpen) return; requestAnimationFrame(() => thinkingDropdownRef.current?.querySelector('[role="menuitemradio"]:not([disabled])')?.focus()); }, [thinkingDropdownOpen]); + useLayoutEffect(() => { + const panel = contextWrapRef.current?.querySelector('[role="dialog"]'); + if (contextOpen && panel) { + const active = document.activeElement; + contextReturnFocusRef.current ??= active instanceof HTMLElement ? active : null; + panel.focus(); + } else if (!contextOpen) { + const previous = contextReturnFocusRef.current; + contextReturnFocusRef.current = null; + const active = document.activeElement; + if (previous?.isConnected && (active === document.body || contextWrapRef.current?.contains(active))) { + previous.focus(); + } + } + }, [contextOpen, contextContainer]); + useEffect(() => { + if (!contextOpen) return; + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape" || event.isComposing || event.defaultPrevented) return; + const panel = contextWrapRef.current?.querySelector('[role="dialog"]'); + if (!panel?.getClientRects().length) return; + const popup = event.target instanceof Element + ? event.target.closest('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]') + : null; + if (popup && !contextWrapRef.current?.contains(popup)) return; + // Capture Escape before the composer or global shortcut can stop a run. + event.preventDefault(); + event.stopPropagation(); + setContextOpen(false); + }; + document.addEventListener("keydown", onKeyDown, true); + return () => document.removeEventListener("keydown", onKeyDown, true); + }, [contextOpen]); // Close dropdowns on outside click useEffect(() => { @@ -3140,9 +3180,15 @@ export const ChatInput = memo(forwardRef(function ChatIn )} - {/* Context ring: usage gauge opening the session context popover */} + {/* Desktop keeps the control in the composer; mobile uses the header mount. */} {onCompact && ( -
+
+ )} + {onCompact && contextContainer && createPortal( +
)} -
+
, + contextContainer, )} {/* Dictation */} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 6257d52d..402dddc7 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -50,6 +50,7 @@ interface Props { onSessionForked?: (newSessionId: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; + sessionInfoContainer?: HTMLDivElement | null; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; onSystemPromptChange?: (prompt: string | null) => void; onSystemPromptLoaderChange?: (loader: (() => Promise) | null) => void; @@ -490,7 +491,7 @@ const CommittedTranscript = memo(function CommittedTranscript({ ); }); -export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCallsDefaultCollapsed = true, hideThinkingBlock = false, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onProviderUsageContextChange, onGenerationSpeedChange, onOpenFile, onOpenUrl, onOpenProviders }: Props) { +export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCallsDefaultCollapsed = true, hideThinkingBlock = false, onAgentEnd, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, sessionInfoContainer, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onProviderUsageContextChange, onGenerationSpeedChange, onOpenFile, onOpenUrl, onOpenProviders }: Props) { const { t, tn } = useI18n(); const { playDoneSound, unlockAudio } = useAudio(); const isMobile = useIsMobile(); @@ -1040,6 +1041,7 @@ export function ChatWindow({ session, newSessionCwd, newSessionWorkspace, toolCa const chatInputElement = ( void; rightPanelOpen: boolean; + onClose: () => void; rightPanelWidth: number | null; rightPanelResizing: boolean; rightPanelRef: Ref; @@ -80,6 +82,7 @@ export const RightPanel = memo(function RightPanel({ rightView, onSelectView, rightPanelOpen, + onClose, rightPanelWidth, rightPanelResizing, rightPanelRef, @@ -182,7 +185,7 @@ export const RightPanel = memo(function RightPanel({ }} > {/* Right panel toolbar: tabs + editor integrations (chat, path, explorer) */} -
+
{ e.currentTarget.style.color = "var(--text)"; e.currentTarget.style.background = "var(--bg-hover)"; }} onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-dim)"; e.currentTarget.style.background = "none"; }} > -
)} +
{/* Explorer tab view — kept mounted so expansion survives tab switches. */} diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index fac7da5c..ba2371bd 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -12,7 +12,7 @@ import { toast } from "./ui/toast"; import { clearLastOpenSession, setLastOpenSession, workspaceKeyOf } from "@/lib/workspace-memory"; import { groupSessionsByProject, projectActivityCounts, sortManagedProjects } from "@/lib/project-ordering"; import { comparableProjectPath } from "@/lib/comparable-path"; -import { Archive, Check, ChevronRight, FileUp, Plus, RefreshCw, Search, Settings2, SlidersHorizontal } from "lucide-react"; +import { Archive, Check, ChevronRight, FileUp, Plus, RefreshCw, Search, Settings2, SlidersHorizontal, X } from "lucide-react"; import { publishSessionsChanged } from "@/lib/session-change-bus"; import { EMPTY_PROJECT_SET, @@ -71,6 +71,8 @@ interface Props { onOpenArchive?: () => void; /** True when settings full-page view is currently open. */ settingsOpen?: boolean; + /** Mobile full-screen drawer only: shows a top-left close control. */ + onClose?: () => void; } @@ -78,7 +80,7 @@ interface Props { -export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, optimisticSession, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onWorkspaceOptionsChange, addProjectOpen, setAddProjectOpen, usageVisible = true, onOpenSettings, onOpenArchive, updateAvailable, settingsOpen = false }: Props) { +export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, optimisticSession, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onWorkspaceOptionsChange, addProjectOpen, setAddProjectOpen, usageVisible = true, onOpenSettings, onOpenArchive, updateAvailable, settingsOpen = false, onClose }: Props) { const { t } = useI18n(); @@ -1188,7 +1190,20 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, }} >
- +
+ {onClose && ( + + )} + +
{onOpenArchive && ( diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs new file mode 100644 index 00000000..00bd46cd --- /dev/null +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -0,0 +1,370 @@ +import "../tests/setup-dom.mjs"; +import assert from "node:assert/strict"; +import test, { afterEach } from "node:test"; +import React from "react"; +import { act, cleanup, renderHook } from "@testing-library/react/pure.js"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const { useMobileSidebarGestures } = await jiti.import("./useMobileSidebarGestures.ts"); +afterEach(() => { cleanup(); window.getSelection()?.removeAllRanges(); document.body.replaceChildren(); }); + +function mount(initial = {}) { + return renderHook(({ enabled }) => { + const [leftOpen, onLeftOpenChange] = React.useState(initial.leftOpen ?? false); + const [rightOpen, onRightOpenChange] = React.useState(initial.rightOpen ?? false); + useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftOpenChange, onRightOpenChange }); + return { leftOpen, rightOpen }; + }, { initialProps: { enabled: true } }); +} + +function touch(type, x, y, { target = document.body, count = 1, identifier = 1, cancelable = true, onPreventDefault } = {}) { + const point = { identifier, clientX: x, clientY: y }; + const event = new window.Event(type, { bubbles: true, cancelable }); + Object.defineProperties(event, { + touches: { value: type === "touchend" || type === "touchcancel" ? [] : Array.from({ length: count }, (_, index) => ({ ...point, identifier: identifier + index })) }, + changedTouches: { value: [point] }, + }); + if (onPreventDefault) { + const preventDefault = event.preventDefault.bind(event); + event.preventDefault = () => { onPreventDefault(); preventDefault(); }; + } + act(() => target.dispatchEvent(event)); + return event; +} +function swipe(x1, x2, { y1 = 300, y2 = 302, ...options } = {}) { + touch("touchstart", x1, y1, options); + touch("touchmove", x1 + (x2 - x1) * 0.05, y1 + (y2 - y1) * 0.05, options); + touch("touchmove", x1 + (x2 - x1) * 0.1, y1 + (y2 - y1) * 0.1, options); + const move = touch("touchmove", x2, y2, options); + const end = touch("touchend", x2, y2, options); + return { move, end }; +} + +// jsdom covers recognition and state transitions; real browser touch input +// must verify native scrolling, synthesized clicks, and drawer animations. +test("intentional swipes from the screen interior open and reverse swipes close each sidebar", () => { + const hook = mount(); + const openLeft = swipe(140, 240); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: false }); + assert.equal(openLeft.move.defaultPrevented, true); + assert.equal(openLeft.end.defaultPrevented, true); + const wrongDirection = swipe(140, 240); + assert.equal(wrongDirection.move.defaultPrevented, false); + assert.equal(wrongDirection.end.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, true); + swipe(240, 140); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); + swipe(240, 140); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: true }); + swipe(140, 240); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("short, vertical and diagonal pulls do not open drawers or steal scrolling", () => { + const hook = mount(); + assert.equal(swipe(140, 160).end.defaultPrevented, true); + assert.equal(swipe(140, 148, { y2: 410 }).move.defaultPrevented, false); + assert.equal(swipe(140, 226, { y2: 450 }).move.defaultPrevented, false); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("multi-touch and touch cancellation abort a pending pull", () => { + const hook = mount(); + touch("touchstart", 14, 300); + touch("touchmove", 100, 302, { count: 2 }); + touch("touchend", 100, 302); + touch("touchstart", 14, 300); + touch("touchstart", 20, 300, { count: 2 }); + touch("touchmove", 100, 302); + touch("touchend", 100, 302); + touch("touchstart", 14, 300); + touch("touchmove", 100, 302); + touch("touchcancel", 100, 302); + touch("touchend", 100, 302); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("form editing and another visible dialog win over edge gestures", () => { + const hook = mount(); + const input = document.createElement("textarea"); + document.body.append(input); + assert.equal(swipe(14, 100, { target: input }).move.defaultPrevented, false); + for (const tag of ["input", "select", "div"]) { + const control = document.createElement(tag); + if (tag === "div") control.setAttribute("contenteditable", "true"); + document.body.append(control); + assert.equal(swipe(140, 240, { target: control }).move.defaultPrevented, false); + control.remove(); + } + const topPanel = document.createElement("div"); + topPanel.setAttribute("data-top-panel", ""); + document.body.append(topPanel); + assert.equal(swipe(140, 240, { target: topPanel }).move.defaultPrevented, false); + topPanel.remove(); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + document.body.append(dialog); + assert.equal(swipe(14, 100).move.defaultPrevented, false); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); + dialog.style.display = "none"; + swipe(14, 100); + assert.equal(hook.result.current.leftOpen, true); +}); + +test("the foreground drawer owns the closing gesture, and disabling during a pull cancels it", () => { + const hook = mount({ leftOpen: true, rightOpen: true }); + const drawer = document.createElement("div"); + drawer.id = "workspace-file-panel"; + drawer.setAttribute("role", "dialog"); + document.body.append(drawer); + swipe(window.innerWidth - 14, window.innerWidth - 100, { target: drawer }); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: true }); + swipe(14, 100, { target: drawer }); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: false }); + drawer.remove(); + touch("touchstart", window.innerWidth - 14, 300); + touch("touchmove", window.innerWidth - 100, 302); + hook.rerender({ enabled: false }); + touch("touchend", window.innerWidth - 100, 302); + assert.equal(hook.result.current.leftOpen, true); + assert.equal(swipe(window.innerWidth - 14, window.innerWidth - 100).move.defaultPrevented, false); +}); + +test("an edge tap preserves its click while a closed drawer does not block later swipes", () => { + const hook = mount(); + touch("touchstart", 14, 300); + assert.equal(touch("touchend", 14, 300).defaultPrevented, false); + const drawer = document.createElement("div"); + drawer.id = "workspace-sidebar"; + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-hidden", "true"); + drawer.setAttribute("inert", ""); + document.body.append(drawer); + swipe(14, 100); + assert.equal(hook.result.current.leftOpen, true); + drawer.removeAttribute("aria-hidden"); + drawer.removeAttribute("inert"); + swipe(window.innerWidth - 14, window.innerWidth - 100, { target: drawer }); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("a dialog inside the inert background does not block the foreground drawer's close gesture", () => { + const hook = mount({ rightOpen: true }); + const background = document.createElement("main"); + background.setAttribute("inert", ""); + const dialog = document.createElement("div"); + dialog.setAttribute("role", "dialog"); + background.append(dialog); + document.body.append(background); + swipe(14, 100); + assert.equal(hook.result.current.rightOpen, false); +}); + +test("an edge pull inside the open header tools does not open a drawer over those controls", () => { + const hook = mount(); + const tools = document.createElement("details"); + tools.className = "shell-topbar-overflow"; + tools.open = true; + document.body.append(tools); + assert.equal(swipe(14, 100, { target: tools }).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("composer menus and pickers block drawer gestures until dismissed", () => { + const hook = mount(); + const popup = document.createElement("div"); + document.body.append(popup); + for (const role of ["menu", "listbox"]) { + popup.setAttribute("role", role); + assert.equal(swipe(14, 100).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); + } + popup.remove(); + swipe(14, 100); + assert.equal(hook.result.current.leftOpen, true); +}); + +test("the drawer's persistent Git list allows closing while a nested popup still blocks it", () => { + const hook = mount({ rightOpen: true }); + const drawer = document.createElement("div"); + drawer.id = "workspace-file-panel"; + drawer.setAttribute("role", "dialog"); + const files = document.createElement("div"); + files.setAttribute("role", "listbox"); + const popup = document.createElement("div"); + popup.setAttribute("role", "menu"); + drawer.append(files, popup); + document.body.append(drawer); + assert.equal(swipe(14, 100, { target: files }).move.defaultPrevented, false); + assert.equal(hook.result.current.rightOpen, true); + popup.remove(); + assert.equal(swipe(14, 100, { target: files }).move.defaultPrevented, true); + assert.equal(hook.result.current.rightOpen, false); +}); + +test("native horizontal scrollers own their swipes while fitting or clipped content permits navigation", () => { + const hook = mount(); + const scroller = document.createElement("div"); + scroller.style.overflowX = "auto"; + // jsdom has no layout; declared widths cover fitting and overflowing scroll surfaces. + Object.defineProperties(scroller, { clientWidth: { value: 200 }, scrollWidth: { value: 200, writable: true } }); + const content = document.createElement("span"); + scroller.append(content); + document.body.append(scroller); + swipe(140, 240, { target: scroller }); + assert.equal(hook.result.current.leftOpen, true); + swipe(240, 140, { target: scroller }); + assert.equal(hook.result.current.leftOpen, false); + scroller.scrollWidth = 800; + assert.equal(swipe(140, 240, { target: content }).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); + scroller.style.overflowX = "scroll"; + assert.equal(swipe(140, 240, { target: scroller }).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); + scroller.style.overflowX = "hidden"; + swipe(140, 240, { target: content }); + assert.equal(hook.result.current.leftOpen, true); +}); + +test("adjusting selected text does not trigger a sidebar swipe", () => { + const hook = mount(); + const text = document.createElement("span"); + text.textContent = "Selected conversation text"; + document.body.append(text); + const range = document.createRange(); + range.selectNodeContents(text); + window.getSelection().addRange(range); + assert.equal(swipe(140, 240, { target: text }).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("a popup present at touchstart retains priority when that touch dismisses it", () => { + const hook = mount(); + const popup = document.createElement("div"); + popup.setAttribute("role", "menu"); + document.body.append(popup); + document.addEventListener("touchstart", () => popup.remove(), { once: true }); + assert.equal(swipe(140, 240).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("text selection begun during a swipe cancels navigation before or after intent lock", () => { + const hook = mount(); + const text = document.createElement("span"); + text.textContent = "Selectable message"; + document.body.append(text); + const select = () => { + const range = document.createRange(); + range.selectNodeContents(text); + window.getSelection().addRange(range); + }; + touch("touchstart", 140, 300, { target: text }); + select(); + assert.equal(touch("touchmove", 240, 302, { target: text }).defaultPrevented, false); + touch("touchend", 240, 302, { target: text }); + assert.equal(hook.result.current.leftOpen, false); + window.getSelection().removeAllRanges(); + touch("touchstart", 140, 300, { target: text }); + touch("touchmove", 180, 301, { target: text }); + select(); + assert.equal(touch("touchmove", 240, 302, { target: text }).defaultPrevented, false); + touch("touchend", 240, 302, { target: text }); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("a horizontal start that curves into a mostly vertical drag does not navigate", () => { + const hook = mount(); + touch("touchstart", 140, 300); + touch("touchmove", 180, 301); + touch("touchmove", 240, 520); + touch("touchend", 240, 520); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("top-bar panels block swipes starting outside their own content", () => { + const hook = mount(); + const panel = document.createElement("div"); + document.body.append(panel); + for (const marker of ["data-top-panel", "data-branch-panel"]) { + panel.setAttribute(marker, ""); + assert.equal(swipe(140, 240).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); + panel.removeAttribute(marker); + } + panel.remove(); + swipe(140, 240); + assert.equal(hook.result.current.leftOpen, true); +}); + +test("a naturally curved swipe with vertical drift still opens the intended sidebar", () => { + const hook = mount(); + touch("touchstart", 140, 300); + touch("touchmove", 154, 316); + touch("touchmove", 182, 330); + touch("touchmove", 228, 362); + touch("touchend", 228, 362); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: false }); +}); + +test("a shorter deliberate horizontal swipe does not require a long precise drag", () => { + const hook = mount(); + swipe(140, 180, { y2: 325 }); + assert.equal(hook.result.current.leftOpen, true); +}); + +test("recognition follows overall displacement after an initial corrective movement", () => { + const hook = mount(); + touch("touchstart", 180, 300); + touch("touchmove", 195, 301); + touch("touchmove", 170, 310); + touch("touchmove", 120, 325); + touch("touchend", 120, 325); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: true }); +}); + +test("an open drawer tolerates a small wrong-way start before a clear closing swipe", () => { + const hook = mount({ rightOpen: true }); + touch("touchstart", 180, 300); + touch("touchmove", 165, 302); + touch("touchmove", 205, 318); + touch("touchmove", 245, 340); + touch("touchend", 245, 340); + assert.equal(hook.result.current.rightOpen, false); +}); + +test("a curved swipe remains tracked when initial browser panning makes later moves non-cancelable", () => { + const hook = mount(); + touch("touchstart", 140, 300); + touch("touchmove", 154, 316); + let prevented = 0; + const ownedByBrowser = { cancelable: false, onPreventDefault: () => { prevented += 1; } }; + touch("touchmove", 182, 330, ownedByBrowser); + touch("touchmove", 228, 362, ownedByBrowser); + touch("touchend", 228, 362, ownedByBrowser); + assert.equal(prevented, 0); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: false }); +}); + +test("an overlay dismissed by pointerdown still owns the following touch gesture", () => { + const hook = mount(); + const panel = document.createElement("div"); + panel.setAttribute("data-top-panel", ""); + document.body.append(panel); + document.addEventListener("pointerdown", () => panel.remove(), { once: true }); + const press = new window.Event("pointerdown", { bubbles: true }); + Object.defineProperty(press, "pointerType", { value: "touch" }); + act(() => document.body.dispatchEvent(press)); + assert.equal(swipe(140, 240).move.defaultPrevented, false); + assert.equal(hook.result.current.leftOpen, false); +}); + +test("a slight horizontal wobble before vertical scrolling does not seize the gesture", () => { + const hook = mount(); + touch("touchstart", 200, 300); + assert.equal(touch("touchmove", 213, 312).defaultPrevented, false); + assert.equal(touch("touchmove", 214, 360).defaultPrevented, false); + assert.equal(touch("touchmove", 214, 420).defaultPrevented, false); + touch("touchend", 214, 420); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); diff --git a/hooks/useMobileSidebarGestures.ts b/hooks/useMobileSidebarGestures.ts new file mode 100644 index 00000000..bc882187 --- /dev/null +++ b/hooks/useMobileSidebarGestures.ts @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect } from "react"; + +const SWIPE_DISTANCE = 32; +const DIRECTION_SLOP = 12; + +type Options = { + enabled: boolean; + leftOpen: boolean; + rightOpen: boolean; + onLeftOpenChange: (open: boolean) => void; + onRightOpenChange: (open: boolean) => void; +}; + +/** Intentional horizontal swipes reuse drawer state without taking over native gestures. */ +export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftOpenChange, onRightOpenChange }: Options) { + useEffect(() => { + if (!enabled) return; + let gesture: { id: number; x: number; y: number; target: Element; claimed: boolean } | null = null; + let overlayAtPointerDown = false; + const cancel = () => { gesture = null; overlayAtPointerDown = false; }; + const hasBlockingOverlay = () => { + const owner = rightOpen ? "workspace-file-panel" : leftOpen ? "workspace-sidebar" : null; + const ownerElement = owner ? document.getElementById(owner) : null; + for (const dialog of document.querySelectorAll('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], dialog[open], [data-top-panel], [data-branch-panel]')) { + if (dialog.id === owner || dialog.closest('[inert], [hidden], [aria-hidden="true"]')) continue; + if (dialog.getAttribute("role") === "listbox" && ownerElement?.contains(dialog)) continue; + const style = getComputedStyle(dialog); + if (style.display !== "none" && style.visibility !== "hidden") return true; + } + return false; + }; + const pointerStart = (event: PointerEvent) => { + if (event.pointerType === "touch") overlayAtPointerDown = hasBlockingOverlay(); + }; + const start = (event: TouchEvent) => { + const blockedBeforeTouch = overlayAtPointerDown; + cancel(); + if (event.touches.length !== 1 || !(event.target instanceof Element)) return; + if (event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), .shell-topbar-overflow[open], [data-top-panel]')) return; + if (window.getSelection()?.isCollapsed === false) return; + // Pointerdown precedes touchstart; outside handlers may already have dismissed it. + if (blockedBeforeTouch || hasBlockingOverlay()) return; + const touch = event.touches[0]; + gesture = { id: touch.identifier, x: touch.clientX, y: touch.clientY, target: event.target, claimed: false }; + }; + const move = (event: TouchEvent) => { + if (!gesture) return; + if (event.touches.length !== 1 || event.touches[0].identifier !== gesture.id || window.getSelection()?.isCollapsed === false) { + cancel(); + return; + } + const dx = event.touches[0].clientX - gesture.x; + const dy = Math.abs(event.touches[0].clientY - gesture.y); + if (!gesture.claimed) { + // Do not seize a near-diagonal wobble before there is enough travel to navigate. + if (Math.abs(dx) < SWIPE_DISTANCE) return; + // Like mature swipe recognizers, stay pending through thumb drift and + // classify net displacement by its dominant axis, not a straight-line cone. + if (Math.abs(dx) <= dy) return; + if ((rightOpen && dx < 0) || (!rightOpen && leftOpen && dx > 0)) return; + // Let code blocks, tab strips and other horizontal scrollers keep their pans. + for (let element: Element | null = gesture.target; element; element = element.parentElement) { + if (element.scrollWidth <= element.clientWidth + 1) continue; + const overflow = getComputedStyle(element).overflowX; + if (overflow === "auto" || overflow === "scroll") { + cancel(); + return; + } + } + if (hasBlockingOverlay()) { + cancel(); + return; + } + gesture.claimed = true; + } + // Browser panning can make later moves non-cancelable; keep tracking the + // overall gesture, but never try to prevent a default the browser owns. + if (event.cancelable) event.preventDefault(); + }; + const end = (event: TouchEvent) => { + const current = gesture; + cancel(); + if (!current || window.getSelection()?.isCollapsed === false) return; + const touch = event.changedTouches[0]; + if (event.touches.length || !touch || touch.identifier !== current.id) return; + const dx = touch.clientX - current.x; + const dy = Math.abs(touch.clientY - current.y); + const allowedDirection = rightOpen ? dx > 0 : leftOpen ? dx < 0 : true; + const horizontal = Math.abs(dx) > dy; + // A partial drag should not turn into a compatibility click, but remains + // unclaimed so ordinary vertical panning is still available. + if (allowedDirection && (current.claimed || (horizontal && Math.abs(dx) >= DIRECTION_SLOP)) && event.cancelable) event.preventDefault(); + if (!current.claimed) return; + if (Math.abs(dx) < SWIPE_DISTANCE || Math.abs(dx) <= dy) return; + // Use overall release direction, allowing a small initial correction. + if (rightOpen) { + if (dx > 0) onRightOpenChange(false); + } else if (leftOpen) { + if (dx < 0) onLeftOpenChange(false); + } else if (dx > 0) onLeftOpenChange(true); + else onRightOpenChange(true); + }; + window.addEventListener("pointerdown", pointerStart, true); + document.addEventListener("touchstart", start, { capture: true, passive: true }); + document.addEventListener("touchmove", move, { capture: true, passive: false }); + document.addEventListener("touchend", end, { capture: true, passive: false }); + document.addEventListener("touchcancel", cancel, true); + return () => { + window.removeEventListener("pointerdown", pointerStart, true); + document.removeEventListener("touchstart", start, true); + document.removeEventListener("touchmove", move, true); + document.removeEventListener("touchend", end, true); + document.removeEventListener("touchcancel", cancel, true); + }; + }, [enabled, leftOpen, rightOpen, onLeftOpenChange, onRightOpenChange]); +}