From 193047f16e3addf6242e2484bae21c4b67474319 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 00:22:17 +0000 Subject: [PATCH 01/14] fix(ui): move session information to the top-right bar --- CHANGELOG.md | 1 + app/globals.css | 3 +++ components/AppShell.tsx | 21 ++++++++++++++++++--- components/ChatInput.tsx | 35 +++++++++++++++++++++++++---------- components/ChatWindow.tsx | 4 +++- 5 files changed, 50 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cf728e..ed5cf240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ 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. +- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar, keeping the ring usage coloring, its popover, keyboard dismissal, and the `/session` shortcut working as before. - 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..ec37e1fe 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3453,6 +3453,9 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { gap: 0 4px !important; padding-right: 48px !important; } + .shell-topbar:has(.shell-session-info) { + padding-right: 92px !important; + } .shell-topbar-overflow-content { overflow-x: auto; justify-content: flex-start; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0d855989..99cf5248 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -648,6 +648,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 +763,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(); }, []); @@ -2156,7 +2156,7 @@ export function AppShell() { alignItems: "center", justifyContent: "flex-end", gap: 6, - paddingRight: rightPanelOpen ? 8 : 44, + paddingRight: (rightPanelOpen ? 8 : 44) + (showChat ? 36 : 0), minWidth: hasGenerationSpeed ? "calc(10ch + 33px)" : 0, width: hasGenerationSpeed ? 200 : 0, fontSize: 11, @@ -2213,6 +2213,20 @@ export function AppShell() { ); })()} + {showChat && ( +
+ )}
@@ -2282,6 +2296,7 @@ export function AppShell() { onSystemPromptLoaderChange={handleSystemPromptLoaderChange} onSessionStatsChange={handleSessionStatsChange} onSessionStatsPanelOpen={openSessionStatsPanel} + sessionInfoContainer={sessionInfoContainer} onGenerationSpeedChange={handleGenerationSpeedChange} onOpenProviders={() => setSettingsTab("providers")} toolCallsDefaultCollapsed={toolCallsDefaultCollapsed} diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 8b99e757..0a1eea93 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; + /** 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, @@ -1694,7 +1698,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 @@ -3140,9 +3144,18 @@ export const ChatInput = memo(forwardRef(function ChatIn )} - {/* Context ring: usage gauge opening the session context popover */} - {onCompact && ( -
+ {/* Keep live session information with the composer state, but render it in the top bar. */} + {onCompact && sessionInfoContainer && createPortal( +
{ + if (event.key !== "Escape" || !contextOpen) return; + event.stopPropagation(); + setContextOpen(false); + contextWrapRef.current?.querySelector("button")?.focus(); + }} + >
)} -
+ , + sessionInfoContainer, )} {/* 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 = ( Date: Mon, 5 Oct 2026 00:32:38 +0000 Subject: [PATCH 02/14] fix(ui): align session info sizing and remove obsolete composer assertion --- CHANGELOG.md | 2 +- app/globals.css | 2 +- components/AppShell.tsx | 2 +- components/ChatInput.test.mjs | 13 ------------- components/ChatInput.tsx | 2 +- 5 files changed, 4 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5cf240..8743db57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ 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. -- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar, keeping the ring usage coloring, its popover, keyboard dismissal, and the `/session` shortcut working as before. +- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar. Preserve the ring usage coloring, its popover, and the `/session` shortcut; Escape dismisses the popover when focus is on its control or inside the panel. - 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 ec37e1fe..2f47b01d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3454,7 +3454,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { padding-right: 48px !important; } .shell-topbar:has(.shell-session-info) { - padding-right: 92px !important; + padding-right: calc(92px + env(safe-area-inset-right, 0px)) !important; } .shell-topbar-overflow-content { overflow-x: auto; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 99cf5248..1b71aec5 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -2219,7 +2219,7 @@ export function AppShell() { className="shell-session-info" style={{ position: "absolute", - right: rightPanelOpen ? 4 : 44, + right: rightPanelOpen ? 4 : "calc(44px + env(safe-area-inset-right, 0px))", top: isMobile ? "env(safe-area-inset-top)" : 4, height: isMobile ? 44 : 28, display: "flex", diff --git a/components/ChatInput.test.mjs b/components/ChatInput.test.mjs index 32107dc3..4eb2f0c6 100644 --- a/components/ChatInput.test.mjs +++ b/components/ChatInput.test.mjs @@ -194,19 +194,6 @@ test("renders goal, planning, and advisor indicators at the composer", () => { 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 0a1eea93..04139a66 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -3163,7 +3163,7 @@ export const ChatInput = memo(forwardRef(function ChatIn aria-label={t("composerContext.title")} aria-expanded={contextOpen} aria-haspopup="dialog" - className="shell-toolbar-btn ui-focus-ring" + className="ui-focus-ring" style={{ display: "flex", alignItems: "center", justifyContent: "center", width: isMobile ? 44 : 28, height: isMobile ? 44 : 28, padding: 0, From adc77f3a85662ba99445723c0d0d350f6b86840f Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 00:52:38 +0000 Subject: [PATCH 03/14] fix(ui): restore focus around the session information panel --- CHANGELOG.md | 2 +- components/ChatInput.tsx | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8743db57..9fa078ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ 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. -- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar. Preserve the ring usage coloring, its popover, and the `/session` shortcut; Escape dismisses the popover when focus is on its control or inside the panel. +- Move the session information control (context ring with stats, tokens, cost, and **Compact context**) from the composer toolbar to the top-right of the top bar. Preserve the ring usage coloring, its popover, and the `/session` shortcut; opening the panel moves keyboard focus into it, and Escape dismisses it and restores the previous focus. - 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/components/ChatInput.tsx b/components/ChatInput.tsx index 04139a66..fa223165 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -425,6 +425,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); @@ -1754,6 +1755,21 @@ 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, sessionInfoContainer]); // Close dropdowns on outside click useEffect(() => { @@ -3153,7 +3169,6 @@ export const ChatInput = memo(forwardRef(function ChatIn if (event.key !== "Escape" || !contextOpen) return; event.stopPropagation(); setContextOpen(false); - contextWrapRef.current?.querySelector("button")?.focus(); }} > + )} + +
{onOpenArchive && ( From 5450ccce108bdaf1dbe126cd6d03daa3377ae7e6 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 03:38:43 +0000 Subject: [PATCH 06/14] fix(ui): preserve header sizing and title measurement across preferences --- CHANGELOG.md | 1 + app/globals.css | 13 ++++++++++--- components/AppShell.tsx | 3 +-- components/ChatInput.tsx | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a656da42..c6d95730 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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 keyboard focus into it, and Escape dismisses it and restores the previous focus. +- Make the mobile workspace sidebar full-screen without a dimmed backdrop, with a visible top-left close button. Align the borderless fixed file-panel toggle with the top bar's height, and center the wider session-title pill between the header controls. - 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 1b28f75e..67a213c5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3344,7 +3344,7 @@ html[data-touch-targets="accessible"] .message-overflow-toggle { } /* Same height as the top bar (token below), no border; the icon centers in the content area under the safe inset. Beats the touch-target size overrides. */ -html .shell-toolbar-btn.shell-panel-toggle { +html:root .shell-toolbar-btn.shell-panel-toggle { height: var(--shell-topbar-height) !important; min-height: 0 !important; padding: 0 0 1px !important; @@ -3366,7 +3366,7 @@ html .shell-toolbar-btn.shell-panel-toggle { width: 40px; height: 40px; } - html .shell-toolbar-btn.shell-panel-toggle { + html:root .shell-toolbar-btn.shell-panel-toggle { padding-top: env(safe-area-inset-top, 0px) !important; } .shell-toolbar-divider { @@ -3393,6 +3393,11 @@ html .shell-toolbar-btn.shell-panel-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; @@ -4316,7 +4321,8 @@ html .shell-toolbar-btn.shell-panel-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; } @@ -4330,6 +4336,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 3d742bad..deb56eb4 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1607,7 +1607,7 @@ export function AppShell() { observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); }; - }, [hasGenerationSpeed, isMobile, locale, rightPanelOpen, showChat]); + }, [hasGenerationSpeed, isMobile, locale, rightPanelOpen, settingsTab, showChat]); // While restoring initial session from URL, don't show the placeholder const showPlaceholder = initialSessionRestored && !showChat; @@ -1769,7 +1769,6 @@ export function AppShell() { @media (max-width: 640px) { .sidebar-container.sidebar-mobile-pending.sidebar-open { transform: translateX(-100%); - box-shadow: none; } } `} diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 689cbc11..dccaf013 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -3183,7 +3183,7 @@ export const ChatInput = memo(forwardRef(function ChatIn aria-label={t("composerContext.title")} aria-expanded={contextOpen} aria-haspopup="dialog" - className="ui-focus-ring" + className="composer-context-control ui-focus-ring" style={{ display: "flex", alignItems: "center", justifyContent: "center", width: isMobile ? 44 : 28, height: isMobile ? 44 : 28, padding: 0, From 4f5967aaf3d6fb65f631b8c86afaecbc805dd151 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 04:23:35 +0000 Subject: [PATCH 07/14] fix(ui): close the session information popover on Escape before run shortcuts --- CHANGELOG.md | 2 +- components/ChatInput.tsx | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d95730..5c9143f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,7 +43,7 @@ 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 keyboard focus into it, and Escape dismisses it and restores the previous focus. +- 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. Align the borderless fixed file-panel toggle with the top bar's height, and center the wider session-title pill between the header controls. - 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. diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index dccaf013..55422994 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -1772,6 +1772,24 @@ export const ChatInput = memo(forwardRef(function ChatIn } } }, [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(() => { @@ -3170,11 +3188,6 @@ export const ChatInput = memo(forwardRef(function ChatIn
{ - if (event.key !== "Escape" || !contextOpen) return; - event.stopPropagation(); - setContextOpen(false); - }} >
- {isMobile && showChat && ( -
- )}
@@ -2362,7 +2376,7 @@ export function AppShell() { - {!settingsTab && ( - - )} void proceedWithAppUpdate()} onNotNow={dismissAppUpdate} /> {archiveBrowserOpen && ( 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. */} From 4e47c9ed43869122815fbd75594d1f03fe666f34 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 07:15:08 +0000 Subject: [PATCH 09/14] fix: continue mobile panel divider below close button --- CHANGELOG.md | 1 + app/globals.css | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0c343db..fd8762af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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. Center the wider session-title pill between the header controls. +- Continue the mobile file-panel tab-row border underneath its close-button cell, so the divider spans the full width. - 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 fd05f8c5..be17be19 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3373,6 +3373,7 @@ html[data-touch-targets="accessible"] .right-panel-toolbar { } 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; From 6e772b841f10e990bc30ea73082c7f2cc3752ca9 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 08:40:27 +0000 Subject: [PATCH 10/14] feat: add mobile sidebar swipes and content-sized titles --- CHANGELOG.md | 4 +- app/globals.css | 9 +- components/AppShell.tsx | 10 +- hooks/useMobileSidebarGestures.test.mjs | 156 ++++++++++++++++++++++++ hooks/useMobileSidebarGestures.ts | 94 ++++++++++++++ 5 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 hooks/useMobileSidebarGestures.test.mjs create mode 100644 hooks/useMobileSidebarGestures.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8762af..108fe4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,8 +44,10 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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. Center the wider session-title pill between the header controls. +- 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 inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs do not toggle the drawers. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. +- 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 be17be19..354259fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3427,11 +3427,9 @@ html[data-touch-targets="accessible"] .right-panel-toolbar { position: absolute; top: 0; bottom: 0; - /* Equal insets center the pill; max-width + auto margins keep it centered. */ + /* Equal insets center the content-sized pill between the control clusters. */ left: var(--topbar-side); right: var(--topbar-side); - max-width: 560px; - margin-inline: auto; pointer-events: none; } .shell-topbar-center > .shell-topbar-breadcrumb { @@ -4180,6 +4178,11 @@ html[data-touch-targets="accessible"] .right-panel-toolbar { overflow: hidden; transition: transform var(--dur-slow) var(--ease-out-warm), visibility 0s; } + /* Frame documents cannot bubble touches: keep the closing edge in the parent. */ + .right-panel-container iframe { + margin-left: 24px; + max-width: calc(100% - 24px); + } .right-panel-toolbar { padding-top: env(safe-area-inset-top, 0px); min-height: var(--shell-topbar-height) !important; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 26907d31..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"; @@ -996,6 +997,13 @@ export function AppShell() { 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(() => { @@ -2063,7 +2071,7 @@ export function AppShell() { color: "var(--text-muted)", whiteSpace: "nowrap", minWidth: 0, - width: "100%", + width: "fit-content", justifyContent: "center", flexShrink: 1, }} diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs new file mode 100644 index 00000000..748e764d --- /dev/null +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -0,0 +1,156 @@ +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(); 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 } = {}) { + 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] }, + }); + act(() => target.dispatchEvent(event)); + return event; +} +function swipe(x1, x2, { y1 = 300, y2 = 302, ...options } = {}) { + touch("touchstart", x1, y1, 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("edge pulls open and reverse pulls close each sidebar without opening the other", () => { + const hook = mount(); + const width = window.innerWidth; + const openLeft = swipe(14, 100); + assert.deepEqual(hook.result.current, { leftOpen: true, rightOpen: false }); + assert.equal(openLeft.move.defaultPrevented, true); + assert.equal(openLeft.end.defaultPrevented, true); + swipe(width - 14, width - 100); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); + swipe(width - 14, width - 100); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: true }); + swipe(14, 100); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("non-edge, short, wrong-direction and vertical pulls do not open drawers or steal scrolling", () => { + const hook = mount(); + assert.equal(swipe(120, 240).move.defaultPrevented, false); + assert.equal(swipe(14, 50).end.defaultPrevented, true); + assert.equal(swipe(20, 1).move.defaultPrevented, false); + assert.equal(swipe(14, 22, { y2: 410 }).move.defaultPrevented, false); + assert.equal(swipe(14, 100, { y2: 450 }).move.defaultPrevented, false); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); + +test("multi-touch, cancellation and a scroll already claimed by the browser 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); + touch("touchstart", 14, 300); + touch("touchmove", 100, 302, { cancelable: false }); + 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); + 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); +}); diff --git a/hooks/useMobileSidebarGestures.ts b/hooks/useMobileSidebarGestures.ts new file mode 100644 index 00000000..919e71ab --- /dev/null +++ b/hooks/useMobileSidebarGestures.ts @@ -0,0 +1,94 @@ +"use client"; + +import { useEffect } from "react"; + +const EDGE_WIDTH = 24; +const SWIPE_DISTANCE = 64; +const DIRECTION_SLOP = 12; + +type Options = { + enabled: boolean; + leftOpen: boolean; + rightOpen: boolean; + onLeftOpenChange: (open: boolean) => void; + onRightOpenChange: (open: boolean) => void; +}; + +/** Edge pulls reuse drawer state and motion without taking over vertical scrolling. */ +export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftOpenChange, onRightOpenChange }: Options) { + useEffect(() => { + if (!enabled) return; + let gesture: { id: number; x: number; y: number; direction: number; side: "left" | "right"; open: boolean; claimed: boolean } | null = null; + const cancel = () => { gesture = null; }; + const start = (event: TouchEvent) => { + 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; + const touch = event.touches[0]; + const fromLeft = touch.clientX <= EDGE_WIDTH; + const fromRight = touch.clientX >= window.innerWidth - EDGE_WIDTH; + let side: "left" | "right"; + let direction: number; + let open: boolean; + if (rightOpen && fromLeft) { + side = "right"; direction = 1; open = false; + } else if (!rightOpen && leftOpen && fromRight) { + side = "left"; direction = -1; open = false; + } else if (!leftOpen && !rightOpen && (fromLeft || fromRight)) { + side = fromLeft ? "left" : "right"; + direction = fromLeft ? 1 : -1; + open = true; + } else return; + // A nested dialog or its backdrop must not open/close the drawer behind it. + const owner = rightOpen ? "workspace-file-panel" : leftOpen ? "workspace-sidebar" : null; + for (const dialog of document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open]')) { + if (dialog.id === owner || dialog.closest('[inert], [hidden], [aria-hidden="true"]')) continue; + const style = getComputedStyle(dialog); + if (style.display !== "none" && style.visibility !== "hidden") return; + } + gesture = { id: touch.identifier, x: touch.clientX, y: touch.clientY, direction, side, open, claimed: false }; + }; + const move = (event: TouchEvent) => { + if (!gesture) return; + if (event.touches.length !== 1 || event.touches[0].identifier !== gesture.id || !event.cancelable) { + cancel(); + return; + } + const dx = event.touches[0].clientX - gesture.x; + const dy = Math.abs(event.touches[0].clientY - gesture.y); + if (!gesture.claimed) { + if (Math.max(Math.abs(dx), dy) < DIRECTION_SLOP) return; + if (dx * gesture.direction <= 0 || Math.abs(dx) < dy * 1.5) { + cancel(); + return; + } + gesture.claimed = true; + } + event.preventDefault(); + }; + const end = (event: TouchEvent) => { + const current = gesture; + cancel(); + if (!current || !current.claimed) return; + // Suppress the compatibility click, including a pull released before the threshold. + if (event.cancelable) event.preventDefault(); + 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); + if (dx * current.direction < SWIPE_DISTANCE || Math.abs(dx) < dy * 1.5) return; + if (current.side === "left") onLeftOpenChange(current.open); + else onRightOpenChange(current.open); + }; + 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 () => { + 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]); +} From 5ae8c8418d6e851571a13cef78586c21b2bc257e Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 09:31:33 +0000 Subject: [PATCH 11/14] fix: keep drawer gestures behind composer popovers --- CHANGELOG.md | 2 +- hooks/useMobileSidebarGestures.test.mjs | 14 ++++++++++++++ hooks/useMobileSidebarGestures.ts | 4 ++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 108fe4d4..74567809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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 inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs do not toggle the drawers. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. +- On mobile, swipe inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs or menus do not toggle the drawers. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. - 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. diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs index 748e764d..c0c5bdf0 100644 --- a/hooks/useMobileSidebarGestures.test.mjs +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -154,3 +154,17 @@ test("an edge pull inside the open header tools does not open a drawer over thos 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); +}); diff --git a/hooks/useMobileSidebarGestures.ts b/hooks/useMobileSidebarGestures.ts index 919e71ab..f8b584be 100644 --- a/hooks/useMobileSidebarGestures.ts +++ b/hooks/useMobileSidebarGestures.ts @@ -39,9 +39,9 @@ export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftO direction = fromLeft ? 1 : -1; open = true; } else return; - // A nested dialog or its backdrop must not open/close the drawer behind it. + // Dialogs and composer popovers must stay above the drawer behind them. const owner = rightOpen ? "workspace-file-panel" : leftOpen ? "workspace-sidebar" : null; - for (const dialog of document.querySelectorAll('[role="dialog"], [role="alertdialog"], dialog[open]')) { + for (const dialog of document.querySelectorAll('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"], dialog[open]')) { if (dialog.id === owner || dialog.closest('[inert], [hidden], [aria-hidden="true"]')) continue; const style = getComputedStyle(dialog); if (style.display !== "none" && style.visibility !== "hidden") return; From 73aa57f428151442bcbe73eb21ee280654604aea Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 10:39:25 +0000 Subject: [PATCH 12/14] fix: allow sidebar swipes over owned file collections --- CHANGELOG.md | 2 +- hooks/useMobileSidebarGestures.test.mjs | 18 ++++++++++++++++++ hooks/useMobileSidebarGestures.ts | 3 +++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74567809..13d018c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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 inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs or menus do not toggle the drawers. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. +- On mobile, swipe inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs or menus do not toggle the drawers; persistent lists inside the drawer, including Git changes, remain swipeable. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. - 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. diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs index c0c5bdf0..a232906b 100644 --- a/hooks/useMobileSidebarGestures.test.mjs +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -168,3 +168,21 @@ test("composer menus and pickers block drawer gestures until dismissed", () => { 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); +}); diff --git a/hooks/useMobileSidebarGestures.ts b/hooks/useMobileSidebarGestures.ts index f8b584be..e3cd236f 100644 --- a/hooks/useMobileSidebarGestures.ts +++ b/hooks/useMobileSidebarGestures.ts @@ -41,8 +41,11 @@ export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftO } else return; // Dialogs and composer popovers must stay above the drawer behind them. 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]')) { if (dialog.id === owner || dialog.closest('[inert], [hidden], [aria-hidden="true"]')) continue; + // Persistent file collections belong to the drawer, not to a popup over it. + if (dialog.getAttribute("role") === "listbox" && ownerElement?.contains(dialog)) continue; const style = getComputedStyle(dialog); if (style.display !== "none" && style.visibility !== "hidden") return; } From 68846b15bb0dce59cb05ec2950d474e5c3ffa7de Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 12:24:46 +0000 Subject: [PATCH 13/14] fix: recognize deliberate sidebar swipes away from device edges --- CHANGELOG.md | 2 +- app/globals.css | 5 - hooks/useMobileSidebarGestures.test.mjs | 120 +++++++++++++++++++++--- hooks/useMobileSidebarGestures.ts | 71 ++++++++------ 4 files changed, 148 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d018c1..77122fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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 inward from either screen edge to open that sidebar, then swipe from the opposite edge to close it. Short pulls, vertical scrolling, multi-touch, form editing, and other open dialogs or menus do not toggle the drawers; persistent lists inside the drawer, including Git changes, remain swipeable. Embedded file previews leave the closing edge outside their frame so swipe-to-close remains available. +- On mobile, use a clear horizontal 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 is required. Short or vertical pulls, 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. diff --git a/app/globals.css b/app/globals.css index 354259fc..f9828ce1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4178,11 +4178,6 @@ html[data-touch-targets="accessible"] .right-panel-toolbar { overflow: hidden; transition: transform var(--dur-slow) var(--ease-out-warm), visibility 0s; } - /* Frame documents cannot bubble touches: keep the closing edge in the parent. */ - .right-panel-container iframe { - margin-left: 24px; - max-width: calc(100% - 24px); - } .right-panel-toolbar { padding-top: env(safe-area-inset-top, 0px); min-height: var(--shell-topbar-height) !important; diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs index a232906b..db915045 100644 --- a/hooks/useMobileSidebarGestures.test.mjs +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -7,7 +7,7 @@ import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); const { useMobileSidebarGestures } = await jiti.import("./useMobileSidebarGestures.ts"); -afterEach(() => { cleanup(); document.body.replaceChildren(); }); +afterEach(() => { cleanup(); window.getSelection()?.removeAllRanges(); document.body.replaceChildren(); }); function mount(initial = {}) { return renderHook(({ enabled }) => { @@ -30,6 +30,8 @@ function touch(type, x, y, { target = document.body, count = 1, identifier = 1, } 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 }; @@ -37,28 +39,29 @@ function swipe(x1, x2, { y1 = 300, y2 = 302, ...options } = {}) { // jsdom covers recognition and state transitions; real browser touch input // must verify native scrolling, synthesized clicks, and drawer animations. -test("edge pulls open and reverse pulls close each sidebar without opening the other", () => { +test("intentional swipes from the screen interior open and reverse swipes close each sidebar", () => { const hook = mount(); - const width = window.innerWidth; - const openLeft = swipe(14, 100); + 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); - swipe(width - 14, width - 100); + 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(width - 14, width - 100); + swipe(240, 140); assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: true }); - swipe(14, 100); + swipe(140, 240); assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); }); -test("non-edge, short, wrong-direction and vertical pulls do not open drawers or steal scrolling", () => { +test("short, vertical and diagonal pulls do not open drawers or steal scrolling", () => { const hook = mount(); - assert.equal(swipe(120, 240).move.defaultPrevented, false); - assert.equal(swipe(14, 50).end.defaultPrevented, true); - assert.equal(swipe(20, 1).move.defaultPrevented, false); - assert.equal(swipe(14, 22, { y2: 410 }).move.defaultPrevented, false); - assert.equal(swipe(14, 100, { y2: 450 }).move.defaultPrevented, false); + assert.equal(swipe(140, 176).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 }); }); @@ -86,6 +89,18 @@ test("form editing and another visible dialog win over edge gestures", () => { 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); @@ -186,3 +201,82 @@ test("the drawer's persistent Git list allows closing while a nested popup still 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", 160, 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", 160, 301); + touch("touchmove", 240, 520); + touch("touchend", 240, 520); + assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); +}); diff --git a/hooks/useMobileSidebarGestures.ts b/hooks/useMobileSidebarGestures.ts index e3cd236f..9e5e6d27 100644 --- a/hooks/useMobileSidebarGestures.ts +++ b/hooks/useMobileSidebarGestures.ts @@ -2,7 +2,6 @@ import { useEffect } from "react"; -const EDGE_WIDTH = 24; const SWIPE_DISTANCE = 64; const DIRECTION_SLOP = 12; @@ -14,65 +13,75 @@ type Options = { onRightOpenChange: (open: boolean) => void; }; -/** Edge pulls reuse drawer state and motion without taking over vertical scrolling. */ +/** 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; direction: number; side: "left" | "right"; open: boolean; claimed: boolean } | null = null; + let gesture: { id: number; x: number; y: number; target: Element; direction: number; side: "left" | "right" | null; open: boolean } | null = null; const cancel = () => { gesture = null; }; - const start = (event: TouchEvent) => { - 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; - const touch = event.touches[0]; - const fromLeft = touch.clientX <= EDGE_WIDTH; - const fromRight = touch.clientX >= window.innerWidth - EDGE_WIDTH; - let side: "left" | "right"; - let direction: number; - let open: boolean; - if (rightOpen && fromLeft) { - side = "right"; direction = 1; open = false; - } else if (!rightOpen && leftOpen && fromRight) { - side = "left"; direction = -1; open = false; - } else if (!leftOpen && !rightOpen && (fromLeft || fromRight)) { - side = fromLeft ? "left" : "right"; - direction = fromLeft ? 1 : -1; - open = true; - } else return; - // Dialogs and composer popovers must stay above the drawer behind them. + 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]')) { if (dialog.id === owner || dialog.closest('[inert], [hidden], [aria-hidden="true"]')) continue; - // Persistent file collections belong to the drawer, not to a popup over it. if (dialog.getAttribute("role") === "listbox" && ownerElement?.contains(dialog)) continue; const style = getComputedStyle(dialog); - if (style.display !== "none" && style.visibility !== "hidden") return; + if (style.display !== "none" && style.visibility !== "hidden") return true; } - gesture = { id: touch.identifier, x: touch.clientX, y: touch.clientY, direction, side, open, claimed: false }; + return false; + }; + const start = (event: TouchEvent) => { + 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; + // Outside-touch handlers may dismiss a popup before the first move. + if (hasBlockingOverlay()) return; + const touch = event.touches[0]; + gesture = { id: touch.identifier, x: touch.clientX, y: touch.clientY, target: event.target, direction: 0, side: null, open: false }; }; const move = (event: TouchEvent) => { if (!gesture) return; - if (event.touches.length !== 1 || event.touches[0].identifier !== gesture.id || !event.cancelable) { + if (event.touches.length !== 1 || event.touches[0].identifier !== gesture.id || !event.cancelable || 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) { + if (!gesture.side) { if (Math.max(Math.abs(dx), dy) < DIRECTION_SLOP) return; - if (dx * gesture.direction <= 0 || Math.abs(dx) < dy * 1.5) { + if (Math.abs(dx) < dy * 1.5) { + cancel(); + return; + } + const direction = dx > 0 ? 1 : -1; + if ((rightOpen && direction < 0) || (!rightOpen && leftOpen && direction > 0)) { + cancel(); + 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; + gesture.direction = direction; + gesture.side = rightOpen ? "right" : leftOpen ? "left" : direction > 0 ? "left" : "right"; + gesture.open = !leftOpen && !rightOpen; } event.preventDefault(); }; const end = (event: TouchEvent) => { const current = gesture; cancel(); - if (!current || !current.claimed) return; + if (!current || !current.side || window.getSelection()?.isCollapsed === false) return; // Suppress the compatibility click, including a pull released before the threshold. if (event.cancelable) event.preventDefault(); const touch = event.changedTouches[0]; From fb5f536afc29fc5de56126b9a1597d9e25dcd5d7 Mon Sep 17 00:00:00 2001 From: Andre Brait Date: Mon, 5 Oct 2026 13:49:21 +0000 Subject: [PATCH 14/14] fix: tolerate natural curved mobile sidebar swipes --- CHANGELOG.md | 2 +- hooks/useMobileSidebarGestures.test.mjs | 104 ++++++++++++++++++++++-- hooks/useMobileSidebarGestures.ts | 70 +++++++++------- 3 files changed, 138 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77122fd2..77e29ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f - 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, use a clear horizontal 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 is required. Short or vertical pulls, 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. +- 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. diff --git a/hooks/useMobileSidebarGestures.test.mjs b/hooks/useMobileSidebarGestures.test.mjs index db915045..00bd46cd 100644 --- a/hooks/useMobileSidebarGestures.test.mjs +++ b/hooks/useMobileSidebarGestures.test.mjs @@ -18,13 +18,17 @@ function mount(initial = {}) { }, { initialProps: { enabled: true } }); } -function touch(type, x, y, { target = document.body, count = 1, identifier = 1, cancelable = 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; } @@ -59,13 +63,13 @@ test("intentional swipes from the screen interior open and reverse swipes close test("short, vertical and diagonal pulls do not open drawers or steal scrolling", () => { const hook = mount(); - assert.equal(swipe(140, 176).end.defaultPrevented, true); + 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, cancellation and a scroll already claimed by the browser abort a pending pull", () => { +test("multi-touch and touch cancellation abort a pending pull", () => { const hook = mount(); touch("touchstart", 14, 300); touch("touchmove", 100, 302, { count: 2 }); @@ -78,9 +82,6 @@ test("multi-touch, cancellation and a scroll already claimed by the browser abor touch("touchmove", 100, 302); touch("touchcancel", 100, 302); touch("touchend", 100, 302); - touch("touchstart", 14, 300); - touch("touchmove", 100, 302, { cancelable: false }); - touch("touchend", 100, 302); assert.deepEqual(hook.result.current, { leftOpen: false, rightOpen: false }); }); @@ -265,7 +266,7 @@ test("text selection begun during a swipe cancels navigation before or after int assert.equal(hook.result.current.leftOpen, false); window.getSelection().removeAllRanges(); touch("touchstart", 140, 300, { target: text }); - touch("touchmove", 160, 301, { 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 }); @@ -275,8 +276,95 @@ test("text selection begun during a swipe cancels navigation before or after int test("a horizontal start that curves into a mostly vertical drag does not navigate", () => { const hook = mount(); touch("touchstart", 140, 300); - touch("touchmove", 160, 301); + 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 index 9e5e6d27..bc882187 100644 --- a/hooks/useMobileSidebarGestures.ts +++ b/hooks/useMobileSidebarGestures.ts @@ -2,7 +2,7 @@ import { useEffect } from "react"; -const SWIPE_DISTANCE = 64; +const SWIPE_DISTANCE = 32; const DIRECTION_SLOP = 12; type Options = { @@ -17,12 +17,13 @@ type Options = { export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftOpenChange, onRightOpenChange }: Options) { useEffect(() => { if (!enabled) return; - let gesture: { id: number; x: number; y: number; target: Element; direction: number; side: "left" | "right" | null; open: boolean } | null = null; - const cancel = () => { gesture = null; }; + 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]')) { + 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); @@ -30,35 +31,35 @@ export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftO } 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; - // Outside-touch handlers may dismiss a popup before the first move. - if (hasBlockingOverlay()) 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, direction: 0, side: null, open: false }; + 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 || !event.cancelable || window.getSelection()?.isCollapsed === false) { + 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.side) { - if (Math.max(Math.abs(dx), dy) < DIRECTION_SLOP) return; - if (Math.abs(dx) < dy * 1.5) { - cancel(); - return; - } - const direction = dx > 0 ? 1 : -1; - if ((rightOpen && direction < 0) || (!rightOpen && leftOpen && direction > 0)) { - cancel(); - return; - } + 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; @@ -72,31 +73,42 @@ export function useMobileSidebarGestures({ enabled, leftOpen, rightOpen, onLeftO cancel(); return; } - gesture.direction = direction; - gesture.side = rightOpen ? "right" : leftOpen ? "left" : direction > 0 ? "left" : "right"; - gesture.open = !leftOpen && !rightOpen; + gesture.claimed = true; } - event.preventDefault(); + // 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 || !current.side || window.getSelection()?.isCollapsed === false) return; - // Suppress the compatibility click, including a pull released before the threshold. - if (event.cancelable) event.preventDefault(); + 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); - if (dx * current.direction < SWIPE_DISTANCE || Math.abs(dx) < dy * 1.5) return; - if (current.side === "left") onLeftOpenChange(current.open); - else onRightOpenChange(current.open); + 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);