diff --git a/FEATURES.md b/FEATURES.md index cb7d32e2..ede798f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -93,8 +93,8 @@ See [History synchronization](docs/HISTORY.md). ## Ranger Ranger is an **experimental** assistant that answers questions about your -workspaces and can manage them. Open it from the topbar, the mobile navigation -capsule, or its shortcut. +workspaces and can manage them. Open it from the topbar, the mobile **⋮** menu, +or its shortcut. - **Setup:** connect a model provider in **Settings** and choose which workspaces Ranger may access (none by default). See @@ -174,21 +174,37 @@ Drafts stay in this browser and re-anchor to changed files on refresh. - **Configuration > Appearance > Layout** selects Automatic, Mobile, or Desktop (`?layout=mobile|desktop|auto` overrides it). +- The bottom bar holds **Message…**, **Direct**, **Tabs** (with the workspace's + tab count, red when another agent needs you) and **⋮**, a menu of views and tools: Terminal, Files, + Changes, Commits, Annotations, History, Ranger, and Workspaces. - Touch scrolls without opening the keyboard. Long-press selects text for **Copy**, **Add comment**, or link actions. - Floating terminal shortcuts (a `2×8` grid plus side buttons) are customizable under **Configuration > Behavior > Mobile terminal shortcuts** and can be dragged to either edge. -- The **Type** button opens an input dock with two modes: **Composer** (edit a - draft with IME, dictation, and images; **Send** adds Enter) and **Direct** - (keys go straight to the terminal). Its tap raises the keyboard in either - mode; afterwards, sends, scrolling, and shortcut actions leave a dismissed - keyboard closed. -- The phone's Back gesture or button closes the tab sheet, then the input dock - or a mobile view such as Workspaces, Files, Changes or Ranger, one per - press, before leaving the page. -- The `>_` button in Composer offers slash commands for Claude Code, Codex, Pi, - Kimi Code, Grok Build, and Antigravity CLI. +- **Message…** is the editable draft in the bottom bar, with IME, + dictation, images, and multiline input. Tapping it raises the keyboard and + reveals **Attachment**, **Keyboard shortcuts**, and **Commands**, in that + order. Tools expand above the same field, without a separate Composer dock. + **Insert** adds text without Enter; **Send** adds Enter. The terminal button + beside Message activates **Direct** (keys go straight to the terminal). + File and image uploads in Direct paste their paths into the terminal without + Enter; in Message they insert into the draft. If the original Direct target + changes during an upload, its paths stay in that pane's draft with a warning. + While editing a multiline draft (including visual wraps), Message expands + and temporarily hides Direct, Tabs, and **⋮**. Leaving the field restores + the single-line preview and navigation; tapping Message resumes editing. + Drafts stay in the field when collapsed. Sends, scrolling, and shortcut + actions leave a dismissed keyboard closed. + Secondary pages (including Ranger settings, Files, and Workspaces) keep only + the bottom input/navigation row. Returning to Terminal restores the tools and + shortcuts as left, without changing the draft/caret or reopening the keyboard. + Ordinary Tabs and **⋮** popups keep the underlying page's input-tool visibility. +- The phone's Back gesture or button closes Tabs or **⋮**, then the visible input + tools or a mobile view such as Workspaces, Files, Changes or Ranger, one per + press, before leaving the page. Hidden input tools do not intercept a view's Back. +- The **Commands** button in the input toolbar offers slash commands for + Claude Code, Codex, Pi, Kimi Code, Grok Build, and Antigravity CLI. - Install as a PWA for an app-style window; see [installation steps](README.md#install-as-a-pwa). **A PWA is not offline access**: Roamgate must stay reachable. diff --git a/scripts/check-web-assets.mjs b/scripts/check-web-assets.mjs index e071ac55..978338ca 100644 --- a/scripts/check-web-assets.mjs +++ b/scripts/check-web-assets.mjs @@ -6,7 +6,7 @@ import { fileURLToPath, URL } from "node:url"; const publicRoot = fileURLToPath(new URL("../server/public/", import.meta.url)); const maxFileCount = 160; const maxTotalBytes = 14 * 1024 * 1024; -const maxInitialJsBytes = 804 * 1024; +const maxInitialJsBytes = 805 * 1024; const maxInitialJsGzipBytes = 256 * 1024; const maxInitialCssBytes = 196 * 1024; diff --git a/server/src/workspace/files.test.ts b/server/src/workspace/files.test.ts index 10a42a9a..2d593fee 100644 --- a/server/src/workspace/files.test.ts +++ b/server/src/workspace/files.test.ts @@ -15,6 +15,85 @@ async function withTempDir(fn: (dir: string) => Promise) { } describe("workspace file handlers", () => { + test("commit history reports a non-Git workspace without inventing an empty history", async () => { + const processes: string[][] = []; + const diagnostic = + "fatal: not a git repository (or any of the parent directories): .git"; + const handlers = createFileHandlers({ + herdr: { + call: async (method: string) => { + if (method === "workspace.get") + return { workspace: { cwd: "/preview" } }; + if (method === "pane.list") + return { + panes: [ + { workspace_id: "w1", focused: true, cwd: "/preview" }, + { workspace_id: "other", cwd: "/unrelated-repo" }, + ], + }; + throw new Error(`unexpected call ${method}`); + }, + } as any, + sshHost: () => undefined, + shQuote, + runProcessWithCodeTimeout: async (argv) => { + processes.push(argv); + return { code: 128, stdout: "", stderr: diagnostic }; + }, + }); + await expect( + handlers.readGitCommits({ workspace_id: "w1" }), + ).rejects.toThrow(diagnostic); + expect(processes).toEqual([ + ["git", "-C", "/preview", "rev-parse", "--show-toplevel"], + ]); + }); + + test("commit history uses the workspace checkout and renders an unborn HEAD as empty", async () => { + const processes: string[][] = []; + const handlers = createFileHandlers({ + herdr: { + call: async (method: string) => { + if (method === "workspace.get") + return { + workspace: { + worktree: { checkout_path: "/checkout" }, + cwd: "/preview", + }, + }; + throw new Error(`unexpected call ${method}`); + }, + } as any, + sshHost: () => undefined, + shQuote, + runProcessWithCodeTimeout: async (argv) => { + processes.push(argv); + return processes.length === 1 + ? { code: 0, stdout: "/checkout\n", stderr: "" } + : { code: 1, stdout: "", stderr: "" }; + }, + }); + await expect( + handlers.readGitCommits({ workspace_id: "w1" }), + ).resolves.toEqual({ + head: null, + commits: [], + has_more: false, + shallow: false, + }); + expect(processes).toHaveLength(2); + expect(processes[0]).toEqual([ + "git", + "-C", + "/checkout", + "rev-parse", + "--show-toplevel", + ]); + expect(processes[1]?.at(-1)).toContain( + "git -C '/checkout' rev-parse -q --verify 'HEAD^{commit}'", + ); + }); + test("PR/MR reads accept only available worktrees listed for the source repository, locally and over SSH", async () => { for (const host of [undefined, "user@server"]) { const processes: string[][] = []; diff --git a/web/src/App.tsx b/web/src/App.tsx index 7968b5f8..43780075 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ -import { createPortal, flushSync } from "react-dom"; +import { MobileBottomBar } from "./components/MobileBottomBar"; +import { createPortal } from "react-dom"; import { bridge } from "./api"; import { listenForTaskNotificationActivation, @@ -22,13 +23,6 @@ import { type ReviewAnnotation, } from "./annotations"; import { roamgateLocalStorage, subscribeLocalStorage } from "./browserStorage"; -import { - MOBILE_CONTROLS_PLACEMENT_STORAGE_KEY, - readMobileControlsPlacement, - writeMobileControlsPlacement, - type MobileControlsPlacement, -} from "./mobileControlsPlacement"; -import { useMobileControlsDrag } from "./components/useMobileControlsDrag"; import { LAYOUT_CHANGE_EVENT, useLayoutPreferences } from "./layoutPreferences"; import { useSystemBack } from "./systemBack"; import { @@ -37,7 +31,7 @@ import { useShortcutPreferences, } from "./shortcutPreferences"; import { SHORTCUT_NUMBERS } from "./shortcutBindings"; -import { activeTerminalInput } from "./activeTerminalInput"; +import { activeTerminalInput, blurActiveInput } from "./activeTerminalInput"; import { CheckCircle2, ChevronLeft, @@ -53,15 +47,10 @@ import { LoaderCircle, MessageSquareText, Minimize2, - MoreHorizontal, PanelTop, - SquarePen, - SquareStack, SquareTerminal, - X, } from "lucide-react"; import { - type CSSProperties, Suspense, useCallback, useEffect, @@ -89,7 +78,7 @@ import { type ThemePreference, } from "./appearance"; import { AgentIcon } from "./components/AgentIcon"; -import { paneHasAgentHistory } from "./components/agentSession"; +import { agentStateKind, paneHasAgentHistory } from "./components/agentSession"; import { CloseButton } from "./components/CloseButton"; import { focusIfUnchanged } from "./components/dialogFocus"; import { CommandCombobox } from "./components/CommandCombobox"; @@ -170,8 +159,6 @@ import { copyTextFromUserGesture } from "./terminalClipboard"; import { terminalPasteRequest } from "./terminalPaste"; import { activateTerminalComposerDraftScope, - readTerminalComposerDraft, - subscribeTerminalComposerDraft, terminalComposerDraftKey, } from "./terminalComposer"; import { terminalMountKey } from "./terminalConnection"; @@ -282,6 +269,8 @@ function PopupOverlay({ type TerminalViewProps = { paneId?: string; visible?: boolean; + mobileInputTarget?: HTMLElement | null; + onActivateTerminalSurface?: () => void; terminalTheme: ITheme; terminalFontFamily: string; terminalFontScale: number; @@ -680,22 +669,6 @@ function isEditableElement(target: EventTarget | null) { return target.isContentEditable; } -function blurActiveInput(event: React.PointerEvent) { - // A compact-row gesture may be a horizontal pan. Dismiss input only once - // its native click handler confirms navigation, not at the start of a swipe. - if ( - event.currentTarget.closest( - ".app[data-mobile-controls-compact] .mobile-controls-stack", - ) - ) - return; - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - const button = event.currentTarget; - window.setTimeout(() => button.blur(), 0); -} - function tabShortcutIndex(e: KeyboardEvent) { const number = SHORTCUT_NUMBERS.find((n) => shortcutMatches(e, `tab.${n}`)); return number === undefined ? null : number - 1; @@ -1006,6 +979,8 @@ function resizeTargetForSplit( // the old full terminal view. function TerminalPaneLayout({ visible, + mobileInputTarget, + onActivateTerminalSurface, terminalTheme, terminalFontFamily, terminalFontScale, @@ -1020,6 +995,8 @@ function TerminalPaneLayout({ onExitZenMode, }: { visible: boolean; + mobileInputTarget: HTMLElement | null; + onActivateTerminalSurface: () => void; terminalTheme: ITheme; terminalFontFamily: string; terminalFontScale: number; @@ -1079,6 +1056,8 @@ function TerminalPaneLayout({ mobileShortcuts={mobileShortcuts} mobileSideShortcuts={mobileSideShortcuts} visible={visible} + mobileInputTarget={mobileInputTarget} + onActivateTerminalSurface={onActivateTerminalSurface} composerOpen={composerOpen} onComposerOpenChange={onComposerOpenChange} agentHistoryOpen={agentHistoryOpen} @@ -1139,6 +1118,8 @@ function TerminalPaneLayout({ mobileShortcuts={mobileShortcuts} mobileSideShortcuts={mobileSideShortcuts} visible={visible} + mobileInputTarget={mobileInputTarget} + onActivateTerminalSurface={onActivateTerminalSurface} composerOpen={composerOpen} onComposerOpenChange={onComposerOpenChange} agentHistoryOpen={agentHistoryOpen} @@ -1250,6 +1231,8 @@ function TerminalPaneLayout({ mobileShortcuts={mobileShortcuts} mobileSideShortcuts={mobileSideShortcuts} visible={visible} + mobileInputTarget={mobileInputTarget} + onActivateTerminalSurface={onActivateTerminalSurface} composerOpen={isActive ? composerOpen : false} onComposerOpenChange={isActive ? onComposerOpenChange : undefined} agentHistoryOpen={isActive ? agentHistoryOpen : false} @@ -1389,31 +1372,26 @@ export default function App() { const [sidebarHidden, setSidebarHidden] = useState(loadZenMode); // What the sidebar was doing before Zen hid it, restored when Zen ends. const sidebarBeforeZenRef = useRef(false); - const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false); - const [mobileControlsPlacement, setMobileControlsPlacement] = useState( - readMobileControlsPlacement, - ); - const appRef = useRef(null); - const mobileControlsToggleRef = useRef(null); - const mobileControlsOffset = useMobileControlsDrag({ - enabled: mobile, - appRef, - toggleRef: mobileControlsToggleRef, - placement: mobileControlsPlacement, - onPlacementChange: (next: MobileControlsPlacement) => { - setMobileControlsPlacement(next); - writeMobileControlsPlacement(next); - }, - }); const [mobileTabSheetOpen, setMobileTabSheetOpen] = useState(false); + const [mobileMoreOpen, setMobileMoreOpen] = useState(false); + const [mobileInputTarget, setMobileInputTarget] = + useState(null); + useSystemBack( + mobile && mobileMoreOpen, + () => setMobileMoreOpen(false), + "popup", + ); + useEffect(() => setMobileMoreOpen(false), [mobileView]); const terminalComposerScopeKey = JSON.stringify([ s.activeConnectionId, s.connectionGeneration, ]); + useEffect(() => { + setMobileTabSheetOpen(false); + setMobileMoreOpen(false); + }, [mobile, terminalComposerScopeKey]); const [openTerminalComposerScopeKey, setOpenTerminalComposerScopeKey] = useState(null); - const [terminalComposerHasDraft, setTerminalComposerHasDraft] = - useState(false); const [paneJumpOpen, setPaneJumpOpen] = useState(false); const [paneJumpIndex, setPaneJumpIndex] = useState(0); // null keeps the most-recently-used list; a string switches to typed search. @@ -1522,10 +1500,20 @@ export default function App() { ? s.tabs.filter((tab) => tab.workspace_id === focusedWorkspace.workspace_id) .length : 0; + // Another pane in the focused workspace has an agent blocked on the user. + const focusedWorkspaceAttention = focusedWorkspace + ? s.panes.some( + (pane) => + pane.workspace_id === focusedWorkspace.workspace_id && + !pane.focused && + agentStateKind(pane.agent_status) === "blocked", + ) + : false; useEffect(() => { // Drop mobile-only controls when their context disappears so they cannot // stay active invisibly or resurface when the mobile layout returns. - if (!mobile || !focusedWorkspace) setMobileTabSheetOpen(false); + if (!mobile) setMobileTabSheetOpen(false); + if (!mobile) setMobileMoreOpen(false); if (!mobile) setOpenTerminalComposerScopeKey(null); }, [mobile, focusedWorkspace]); useEffect(() => { @@ -1545,7 +1533,6 @@ export default function App() { : null; const terminalComposerOpen = mobile && - !mobileTabSheetOpen && openTerminalComposerScopeKey === terminalComposerScopeKey && activeTerminalComposerDraftKey !== null; const setTerminalComposerOpen = useCallback( @@ -1554,18 +1541,6 @@ export default function App() { }, [terminalComposerScopeKey], ); - useEffect(() => { - if (!activeTerminalComposerDraftKey) { - setTerminalComposerHasDraft(false); - return; - } - const update = (draft: string) => setTerminalComposerHasDraft(draft !== ""); - update(readTerminalComposerDraft(activeTerminalComposerDraftKey)); - return subscribeTerminalComposerDraft( - activeTerminalComposerDraftKey, - update, - ); - }, [activeTerminalComposerDraftKey]); const paneJumpOptions = useMemo(() => { const snapshot = { layout: s.layout, @@ -1671,6 +1646,7 @@ export default function App() { [], ); const activateTerminalSurface = useCallback(() => { + setMobileMoreOpen(false); updateInspectorState((current) => current ? { ...current, open: false } : current, ); @@ -1951,10 +1927,10 @@ export default function App() { requestAnimationFrame(() => { const terminal = mobile ? null : activeTerminalInput(document); focusIfUnchanged( - mobile && mobileControlsCollapsed - ? mobileControlsToggleRef.current - : (terminal ?? - document.querySelector(".assistant-entry")), + terminal ?? + document.querySelector( + mobile ? ".mobile-appbar-more" : ".assistant-entry", + ), source, ); }); @@ -2809,10 +2785,6 @@ export default function App() { useEffect(() => { store.init(); }, []); - useEffect(() => { - if (mobile && mobileView === "annotations") - setMobileControlsCollapsed(false); - }, [mobile, mobileView]); useEffect(() => { if (!mobile) return; const current = inspectorStateRef.current; @@ -3535,8 +3507,6 @@ export default function App() { setAssistantFloating( roamgateLocalStorage.getItem("assistantPanelMode") !== "fixed", ); - if (key === null || key === MOBILE_CONTROLS_PLACEMENT_STORAGE_KEY) - setMobileControlsPlacement(readMobileControlsPlacement()); if ( key === null || key === MOBILE_TERMINAL_SHORTCUTS_STORAGE_KEY || @@ -3799,7 +3769,6 @@ export default function App() { : "Open Ranger" } aria-pressed={assistantVisible} - tabIndex={mobile && mobileControlsCollapsed ? -1 : 0} onPointerDown={mobile ? blurActiveInput : undefined} onClick={toggleAssistant} > @@ -3815,17 +3784,10 @@ export default function App() { ); return (
@@ -3914,18 +3876,48 @@ export default function App() { ) : null} -
- - setMobileTabSheetOpen(false)} - onShowSession={activateTerminalSurface} - /> -
- {activeTerminalComposerDraftKey && !terminalComposerOpen ? ( - - ) : null} - {s.updateInfo?.update_available || s.notice ? createPortal(
@@ -4279,6 +4180,8 @@ export default function App() { >
+ {mobile ? ( + + ) : null} {/* Before the tooltip layer, which shares the modal z-index. */} {shortcutCheatSheetOpen && !mobile ? ( diff --git a/web/src/activeTerminalInput.test.ts b/web/src/activeTerminalInput.test.ts index f2bb8739..6571b070 100644 --- a/web/src/activeTerminalInput.test.ts +++ b/web/src/activeTerminalInput.test.ts @@ -6,7 +6,7 @@ describe("activeTerminalInput", () => { test("leaves other inputs alone when no terminal is visible", async () => { const browser = new Window(); try { - browser.document.body.innerHTML = ``; + browser.document.body.innerHTML = `
`; expect( activeTerminalInput(browser.document as unknown as Document), ).toBeNull(); @@ -14,6 +14,32 @@ describe("activeTerminalInput", () => { await browser.happyDOM.close(); } }); + test.each(["composer", "direct", "closed", "other-pane"])( + "routes a portalled %s editor only to its active pane", + async (state) => { + const browser = new Window(); + const doc = browser.document; + try { + doc.body.innerHTML = `
+
+
+
+
+ +
+
`; + expect(activeTerminalInput(doc as unknown as Document)).toBe( + doc.querySelector( + state === "composer" + ? ".terminal-composer-input" + : ".is-active .terminal-view", + ) as unknown as HTMLElement | null, + ); + } finally { + await browser.happyDOM.close(); + } + }, + ); test.each(["single", "split", "switcher"])( "finds the selected %s terminal activation target or its composer draft", async (layout) => { diff --git a/web/src/activeTerminalInput.ts b/web/src/activeTerminalInput.ts index ba623e98..4812a361 100644 --- a/web/src/activeTerminalInput.ts +++ b/web/src/activeTerminalInput.ts @@ -1,12 +1,39 @@ +import type { PointerEvent } from "react"; + export function activeTerminalInput(root: ParentNode): HTMLElement | null { const shell = root.querySelector( ".pane-layout-cell.is-active .terminal-shell, .pane-switcher-layout > .terminal-shell, .workspace-terminal-surface > .terminal-shell", ); + if (!shell) return null; + const editor = shell.dataset.paneId + ? Array.from( + root.querySelectorAll( + '.mobile-bottom-bar .terminal-composer[data-open][data-input-mode="composer"]', + ), + ).find((composer) => composer.dataset.paneId === shell.dataset.paneId) + : undefined; return ( - shell?.querySelector( + editor?.querySelector(".terminal-composer-input") ?? + shell.querySelector( ".terminal-composer-input:not([hidden])", ) ?? - shell?.querySelector(".terminal-view") ?? + shell.querySelector(".terminal-view") ?? null ); } + +export function blurActiveInput(event: PointerEvent) { + // A compact-row gesture may be a horizontal pan. Dismiss input only once + // its native click handler confirms navigation, not at the start of a swipe. + if ( + event.currentTarget.closest( + ".app[data-mobile-controls-compact] .mobile-controls-stack", + ) + ) + return; + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + const button = event.currentTarget; + window.setTimeout(() => button.blur(), 0); +} diff --git a/web/src/components/AgentHistoryDrawer.css b/web/src/components/AgentHistoryDrawer.css index 1092b25f..be054b5d 100644 --- a/web/src/components/AgentHistoryDrawer.css +++ b/web/src/components/AgentHistoryDrawer.css @@ -635,6 +635,8 @@ flex: 1 1 auto; border-width: 0; transform: none; + /* The Inspector ends above the global rail, which owns the safe area. */ + padding-bottom: 0; } .agent-history-drawer-head { diff --git a/web/src/components/AnnotationPanel.css b/web/src/components/AnnotationPanel.css index d3c49948..40e347a9 100644 --- a/web/src/components/AnnotationPanel.css +++ b/web/src/components/AnnotationPanel.css @@ -262,7 +262,6 @@ min-width: 44px; } - .annotation-panel-footer { - padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); - } + /* Keep the normal footer padding: the global input rail below this page + already owns the bottom safe area. */ } diff --git a/web/src/components/AssistantPanel.css b/web/src/components/AssistantPanel.css index 2563cc1c..32328120 100644 --- a/web/src/components/AssistantPanel.css +++ b/web/src/components/AssistantPanel.css @@ -1328,12 +1328,10 @@ min-height: 44px; } +/* The global input rail below Ranger/Settings owns the bottom safe area; + retain only the page's normal spacing, not a second home-indicator inset. */ .assistant-panel.is-mobile .assistant-settings-save { - padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); -} - -.assistant-panel.is-mobile .assistant-panel-footer { - padding-bottom: max(10px, env(safe-area-inset-bottom, 0px)); + padding-bottom: 10px; } .assistant-panel.is-mobile .assistant-compose-actions > button { diff --git a/web/src/components/CommandCombobox.tsx b/web/src/components/CommandCombobox.tsx index 27552c68..54f4a6ea 100644 --- a/web/src/components/CommandCombobox.tsx +++ b/web/src/components/CommandCombobox.tsx @@ -1,3 +1,4 @@ +import { useCloseGlobalMenuForDialog } from "./useCloseGlobalMenuForDialog"; import { shortcutMatches, shortcutTitle, @@ -256,6 +257,7 @@ export function CommandCombobox({ shallowEqual, ); const [open, setOpen] = useState(false); + useCloseGlobalMenuForDialog(open, setOpen); const [search, setSearch] = useState(""); const [selectedActionValue, setSelectedActionValue] = useState(""); const [selectedActionSearch, setSelectedActionSearch] = useState(""); @@ -1066,6 +1068,10 @@ export function CommandCombobox({ { + if (document.querySelector(".modal-backdrop:not([hidden])")) + event.preventDefault(); + }} onKeyDownCapture={(event) => { runCommandNumberShortcut( event, diff --git a/web/src/components/ConfigMenu.tsx b/web/src/components/ConfigMenu.tsx index 6f379327..9558ba48 100644 --- a/web/src/components/ConfigMenu.tsx +++ b/web/src/components/ConfigMenu.tsx @@ -1,5 +1,7 @@ +import { useCloseGlobalMenuForDialog } from "./useCloseGlobalMenuForDialog"; import { lazyWithReload } from "../lazyWithReload"; import type { ReactNode } from "react"; +import { createPortal } from "react-dom"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { ChevronDown, @@ -83,6 +85,7 @@ export function ConfigMenu({ ? s.bridgeStatus?.clients : null; const [open, setOpen] = useState(false); + useCloseGlobalMenuForDialog(open, setOpen); const [expanded, setExpanded] = useState(false); const [configurationTab, setConfigurationTab] = useState(null); @@ -422,17 +425,24 @@ export function ConfigMenu({
) : null}
- {configurationTab ? ( - } - > - - - ) : null} + {/* Escape both topbar120 and the transformed phone app context. This + includes loading and all Configuration detail dialogs. */} + {configurationTab + ? createPortal( + + } + > + + , + document.body, + ) + : null} ); } diff --git a/web/src/components/ConnectionSwitcher.css b/web/src/components/ConnectionSwitcher.css index bfe15d0d..82113a2c 100644 --- a/web/src/components/ConnectionSwitcher.css +++ b/web/src/components/ConnectionSwitcher.css @@ -80,7 +80,8 @@ } .connection-switcher-popover { - z-index: 5000; + /* Global menu portal: above app chrome, below dialog portals (5000). */ + z-index: 140; width: min(380px, calc(100vw - 24px)); max-height: calc( var(--radix-popover-content-available-height, calc(100dvh - 24px)) / diff --git a/web/src/components/ConnectionSwitcher.tsx b/web/src/components/ConnectionSwitcher.tsx index dab26a8c..6b93b80f 100644 --- a/web/src/components/ConnectionSwitcher.tsx +++ b/web/src/components/ConnectionSwitcher.tsx @@ -1,3 +1,4 @@ +import { useCloseGlobalMenuForDialog } from "./useCloseGlobalMenuForDialog"; import { Check, ChevronDown, @@ -1138,6 +1139,7 @@ export function ConnectionSwitcher() { shallowEqual, ); const [open, setOpen] = useState(false); + useCloseGlobalMenuForDialog(open, setOpen); const [manageOpen, setManageOpen] = useState(false); const triggerRef = useRef(null); const menuRef = useRef(null); @@ -1283,6 +1285,13 @@ export function ConnectionSwitcher() { window.requestAnimationFrame(focusInitialMenuItem); }} onCloseAutoFocus={(event) => { + // A dialog owns focus, including dialogs inside the mobile app. + if (document.querySelector(".modal-backdrop:not([hidden])")) { + openingManager.current = false; + menuExitFocus.current = null; + event.preventDefault(); + return; + } if (openingManager.current) { openingManager.current = false; event.preventDefault(); diff --git a/web/src/components/MobileBottomBar.layout.test.ts b/web/src/components/MobileBottomBar.layout.test.ts new file mode 100644 index 00000000..01bbc406 --- /dev/null +++ b/web/src/components/MobileBottomBar.layout.test.ts @@ -0,0 +1,166 @@ +import { expect, test } from "bun:test"; + +const nav = await Bun.file( + new URL("../styles/layout/mobile-nav.css", import.meta.url), +).text(); +const terminal = await Bun.file( + new URL("./TerminalView.css", import.meta.url), +).text(); +const composer = await Bun.file( + new URL("./TerminalComposer.css", import.meta.url), +).text(); + +function rule(css: string, selector: string, occurrence = 0) { + let start = css.indexOf(`${selector} {`); + while (occurrence-- > 0 && start >= 0) + start = css.indexOf(`${selector} {`, start + 1); + expect(start).toBeGreaterThanOrEqual(0); + return css.slice(start, css.indexOf("}", start) + 1); +} + +const control = "max(44px, calc(44px / var(--ui-scale, 1)))"; +const message = "max(48px, calc(48px / var(--ui-scale, 1)))"; + +// These are CSS contract tests, not painted-layout tests. Device acceptance +// still checks actual rects/overlap, keyboard geometry and multiline focus. +test("Direct, Tabs and More use 44px controls with inverse zoom compensation", () => { + const buttons = rule(nav, ".mobile-appbar button"); + expect(buttons).toContain(`width: ${control};`); + expect(buttons).toContain(`height: ${control};`); + expect(buttons).toContain("flex: 0 0 auto;"); + const direct = rule(terminal, ".terminal-mobile-input-bar button"); + expect(direct).toContain(`min-width: ${control};`); + expect(direct).toContain(`min-height: ${control};`); + expect(direct).toContain("flex: 0 0 auto;"); + expect(direct).toContain("padding: 0;"); + + // CSS zoom scales the entire shell; inverse compensation is only needed + // below 100%. Check all supported percentage steps, including the extremes. + for (let percent = 80; percent <= 150; percent++) { + const scale = percent / 100; + const buttonPixels = Math.max(44, 44 / scale) * scale; + expect(buttonPixels).toBeGreaterThanOrEqual(44 - 1e-10); + expect(Math.max(48, 48 / scale) * scale).toBeGreaterThan(buttonPixels); + } +}); + +test("footer controls share quiet borderless styling without losing selected or keyboard states", () => { + const direct = rule(terminal, ".terminal-mobile-input-bar button"); + const navigation = rule(nav, ".mobile-appbar button"); + for (const button of [direct, navigation]) { + expect(button).toContain("border: 0;"); + expect(button).toContain("background: transparent;"); + expect(button).toContain("border-radius: 22px;"); + expect(button).toContain("color: var(--text-strong);"); + } + // Only Message retains the original input bubble, not Direct. + expect( + rule(terminal, ".terminal-mobile-input-bar .terminal-mobile-message"), + ).toContain( + "background: color-mix(in srgb, var(--text-strong) 8%, var(--terminal-bg));", + ); + expect(terminal).not.toContain( + '.terminal-mobile-direct[aria-pressed="true"]', + ); + expect(nav).toContain( + '.terminal-mobile-direct[aria-pressed="true"],\n .mobile-appbar button[aria-pressed="true"],\n .mobile-appbar button[aria-expanded="true"]', + ); + const selected = rule(nav, '.mobile-appbar button[aria-expanded="true"]'); + expect(selected).toContain("background: var(--accent-soft);"); + expect(selected).not.toContain("border"); + expect(nav).toContain( + ".terminal-mobile-direct:focus-visible,\n .mobile-appbar button:focus-visible", + ); + const focus = rule(nav, ".mobile-appbar button:focus-visible"); + expect(focus).toContain("outline: 2px solid var(--accent);"); + expect(focus).toContain("outline-offset: 2px;"); + expect(rule(nav, ".mobile-appbar button:focus-visible", 1)).toContain( + "outline-color: Highlight;", + ); + expect(nav).toContain("@media (forced-colors: active)"); + const highContrast = rule( + nav, + '.mobile-appbar button[aria-expanded="true"]', + 1, + ); + expect(highContrast).toContain("forced-color-adjust: none;"); + expect(highContrast).toContain("background: Highlight;"); + expect(highContrast).toContain("color: HighlightText;"); + expect(rule(nav, ".mobile-appbar button:disabled")).toContain("opacity:"); + expect( + rule(terminal, ".terminal-mobile-input-bar button:disabled"), + ).toContain("opacity:"); +}); + +test("navigation slots and centering track control size without shrinking Message", () => { + const row = rule(terminal, ".terminal-mobile-input-bar", 1); + expect(row).toContain(`padding-right: calc(2 * ${control} + 4px);`); + expect(row).toContain("align-items: center;"); + const editor = rule( + terminal, + ".terminal-mobile-input-bar .terminal-mobile-message", + ); + expect(editor).toContain(`min-width: ${message};`); + expect(editor).toContain("padding: 12px 8px;"); + expect(rule(composer, ".terminal-composer-input")).toContain( + `min-height: ${message};`, + ); + // The empty rail has the same 48px row even without a composer portal. + expect(rule(nav, ".mobile-bottom-bar")).toContain(message); + const appbar = rule(nav, ".mobile-appbar"); + expect(appbar.replace(/\s/g, "")).toContain( + `(${message} - ${control}) / 2`.replace(/\s/g, ""), + ); + expect(appbar).toContain("env(safe-area-inset-bottom, 0px)"); + expect(appbar).toContain("env(safe-area-inset-right, 0px)"); +}); + +test("tab counts and attention badges stay inside the narrower button", () => { + const badge = rule(nav, ".mobile-appbar-count"); + expect(badge).toContain("right: 2px;"); + expect(badge).toContain("max-width: calc(100% - 4px);"); + expect(badge).toContain("overflow: hidden;"); + expect(badge).toContain("text-overflow: ellipsis;"); + expect(rule(nav, ".mobile-appbar-count[data-attention]")).toContain( + "background: var(--red);", + ); +}); + +test("toolbar close stays right aligned when Direct hides the submit actions", () => { + expect(rule(composer, ".terminal-composer-close")).toContain( + "margin-left: auto;", + ); + const actions = rule(composer, ".terminal-composer-submit-actions"); + expect(actions).toContain("flex: 1 1 auto;"); + expect(actions).toContain("justify-content: flex-end;"); +}); + +test("popup backdrops cover viewport corners without inherited button rounding", () => { + const backdrop = rule(nav, ".mobile-more-backdrop"); + expect(backdrop).toContain("inset: 0;"); + expect(backdrop).toContain("border-radius: 0;"); +}); + +test("More remains anchored above the Message rail with keyboard and safe-area compensation", () => { + const menu = rule(nav, "body .app[data-mobile-more] .mobile-controls-stack"); + expect(menu).toContain(message); + expect(menu).toContain("env(safe-area-inset-bottom, 0px)"); + expect(menu).toContain("var(--keyboard-inset-composer-gap, 0px)"); + expect(menu).toContain( + "var(--keyboard-inset-content, var(--keyboard-inset-bottom, 0px))", + ); + expect(menu).toContain("overflow-y: auto;"); + // Focused multiline editing still borrows all three navigation slots. + expect( + rule( + nav, + ".mobile-bottom-bar:has(.terminal-composer[data-expanded]) .mobile-appbar", + ), + ).toContain("display: none;"); + expect( + rule( + composer, + ".terminal-composer[data-expanded] > .terminal-mobile-input-bar", + ), + ).toContain("padding-right: 0;"); +}); diff --git a/web/src/components/MobileBottomBar.tsx b/web/src/components/MobileBottomBar.tsx new file mode 100644 index 00000000..f5131be5 --- /dev/null +++ b/web/src/components/MobileBottomBar.tsx @@ -0,0 +1,86 @@ +import { EllipsisVertical, SquareStack } from "lucide-react"; +import { useEffect, type Ref } from "react"; +import { focusIfUnchanged } from "./dialogFocus"; +import { blurActiveInput } from "../activeTerminalInput"; + +// App owns this rail, outside every hideable mobile surface. Only the active +// TerminalView portals its shared editor and tools into the input surface. +export function MobileBottomBar({ + inputRef, + tabCount, + attention, + tabsOpen, + moreOpen, + onTabsOpenChange, + onMoreOpenChange, +}: { + inputRef: Ref; + tabCount: number; + attention: boolean; + tabsOpen: boolean; + moreOpen: boolean; + onTabsOpenChange: (open: boolean) => void; + onMoreOpenChange: (open: boolean) => void; +}) { + useEffect(() => { + if (!moreOpen) return; + const menu = document.getElementById("mobile-more-options"); + menu?.querySelector("button")?.focus({ preventScroll: true }); + const escape = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + event.preventDefault(); + event.stopPropagation(); + onMoreOpenChange(false); + }; + document.addEventListener("keydown", escape); + return () => { + document.removeEventListener("keydown", escape); + const source = document.activeElement; + if (source === document.body || (source && menu?.contains(source))) + focusIfUnchanged(document.querySelector(".mobile-appbar-more"), source); + }; + }, [moreOpen, onMoreOpenChange]); + return ( +
+
+ +
+ ); +} diff --git a/web/src/components/MobileTabSheet.css b/web/src/components/MobileTabSheet.css index bd069cd8..e3ae3640 100644 --- a/web/src/components/MobileTabSheet.css +++ b/web/src/components/MobileTabSheet.css @@ -1,35 +1,16 @@ /* Keep mobile styling in sync with the saved layout and URL override. */ :where(html[data-layout="mobile"]) { - .mobile-tab-sheet-backdrop { - align-items: flex-end; - justify-content: center; - padding: 0; - } - + /* Geometry, backdrop and head chrome are shared with More in mobile-nav. */ .mobile-tab-sheet { - display: flex; - flex-direction: column; gap: 8px; - width: 100%; - max-height: min(62dvh, 430px); - padding: 12px 12px calc(12px + env(safe-area-inset-bottom, 0px)); - background: var(--panel); - border-top: 1px solid var(--border); - border-radius: 16px 16px 0 0; - box-shadow: var(--shadow-lg); - outline: none; - } - - .mobile-tab-sheet-head { - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; + min-height: 0; } - .mobile-tab-sheet-head h3 { + .mobile-tab-sheet > p { margin: 0; - font-size: 14px; + padding: 0 16px; + color: var(--muted); + font-size: 13px; } .mobile-tab-sheet-list { @@ -39,6 +20,8 @@ gap: 4px; min-height: 0; overflow-y: auto; + overscroll-behavior-y: contain; + padding: 0 8px; } .mobile-tab-sheet-row { @@ -53,6 +36,7 @@ display: inline-flex; align-items: center; gap: 8px; + min-height: max(44px, calc(44px / var(--ui-scale, 1))); padding: 9px 10px; border: 1px solid var(--border); border-radius: 10px; @@ -76,8 +60,8 @@ } .mobile-tab-sheet-close { - width: 34px; - height: 34px; + width: max(44px, calc(44px / var(--ui-scale, 1))); + height: max(44px, calc(44px / var(--ui-scale, 1))); flex: 0 0 auto; display: inline-flex; align-items: center; @@ -94,6 +78,8 @@ align-items: center; justify-content: center; gap: 6px; + margin: 0 8px; + min-height: max(44px, calc(44px / var(--ui-scale, 1))); padding: 10px; border: 1px dashed var(--border); border-radius: 10px; diff --git a/web/src/components/MobileTabSheet.test.ts b/web/src/components/MobileTabSheet.test.ts new file mode 100644 index 00000000..f9b4d9c3 --- /dev/null +++ b/web/src/components/MobileTabSheet.test.ts @@ -0,0 +1,330 @@ +import { expect, test } from "bun:test"; + +const nav = await Bun.file( + new URL("../styles/layout/mobile-nav.css", import.meta.url), +).text(); +const app = await Bun.file(new URL("../App.tsx", import.meta.url)).text(); +const sheet = await Bun.file( + new URL("./MobileTabSheet.tsx", import.meta.url), +).text(); + +test("Tabs and More share popup geometry and chrome inside the app stacking context", () => { + expect(nav).toMatch( + /body \.app \.mobile-tab-sheet,\s*body \.app\[data-mobile-more\] \.mobile-controls-stack \{/, + ); + const popup = nav.slice( + nav.indexOf("body .app .mobile-tab-sheet,"), + nav.indexOf("body .app .mobile-popup-head"), + ); + for (const declaration of [ + "position: fixed", + "z-index: 132", + "272px", + "100% -", + "border-radius: 14px", + "background: var(--panel)", + "box-shadow: var(--shadow-lg)", + "overflow-y: auto", + "overscroll-behavior-y: contain", + "env(safe-area-inset-top", + "var(--keyboard-inset-content", + "var(--ui-scale, 1)", + ]) + expect(popup).toContain(declaration); + expect(nav).toMatch(/\.mobile-bottom-bar\s*\{[^}]*z-index: 131/); + expect(nav).toMatch(/\.mobile-more-backdrop\s*\{[^}]*z-index: 130/); + expect(nav).toContain(".mobile-popup-head .close-button:focus-visible"); + expect(nav).toContain("outline-color: Highlight"); + expect(sheet).not.toContain("createPortal"); + expect(sheet).not.toContain('aria-modal="true"'); + expect(sheet).toContain( + 'className="mobile-more-backdrop mobile-tab-sheet-backdrop"', + ); + expect(app.indexOf(" { + // Exercise App's actual visibility predicate, not a second implementation in + // the harness. Popup focus/blur remains independent of toolbar visibility. + const expression = app.match( + /const terminalComposerOpen =\s*([\s\S]*?);/, + )?.[1]; + expect(expression).toBeDefined(); + const isOpen = new Function( + "mobile", + "mobileTabSheetOpen", + "mobileMoreOpen", + "openTerminalComposerScopeKey", + "terminalComposerScopeKey", + "activeTerminalComposerDraftKey", + `return (${expression});`, + ); + for (const tabs of [false, true]) { + for (const more of [false, true]) { + // Open tools/shortcuts stay open for either popup; previously closed + // tools, stale connections/panes and absent active panes stay closed. + expect( + isOpen(true, tabs, more, "connection:pane", "connection:pane", "draft"), + ).toBe(true); + expect(isOpen(true, tabs, more, null, "connection:pane", "draft")).toBe( + false, + ); + expect( + isOpen(true, tabs, more, "old:pane", "connection:pane", "draft"), + ).toBe(false); + expect( + isOpen(true, tabs, more, "connection:old", "connection:pane", "draft"), + ).toBe(false); + expect( + isOpen(true, tabs, more, "connection:pane", "connection:pane", null), + ).toBe(false); + expect( + isOpen( + false, + tabs, + more, + "connection:pane", + "connection:pane", + "draft", + ), + ).toBe(false); + } + } +}); + +if (process.env.ROAMGATE_TAB_POPUP_DOM_TEST !== "1") { + test("tab popup DOM interactions in an isolated runtime", async () => { + const child = Bun.spawn([process.execPath, "test", import.meta.path], { + env: { ...process.env, ROAMGATE_TAB_POPUP_DOM_TEST: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`${stdout}\n${stderr}`); + expect(code).toBe(0); + }, 15_000); +} else { + const { mock } = await import("bun:test"); + const { Window } = await import("happy-dom"); + const browser = new Window({ url: "http://localhost" }); + for (const [key, value] of Object.entries({ + window: browser, + document: browser.document, + navigator: browser.navigator, + HTMLElement: browser.HTMLElement, + Element: browser.Element, + Node: browser.Node, + IS_REACT_ACT_ENVIRONMENT: true, + })) + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + const { act, createElement, StrictMode, useState } = await import("react"); + const { createRoot } = await import("react-dom/client"); + const pins = new Set(["pinned"]); + const state = { + activeConnectionId: "local", + connectionGeneration: 1, + panes: [], + workspaces: [{ workspace_id: "ws", focused: true }], + tabs: [ + { tab_id: "one", workspace_id: "ws", name: "First", focused: true }, + { tab_id: "pinned", workspace_id: "ws", name: "Pinned", focused: false }, + ], + }; + let complete: (() => void) | undefined; + const focusTab = mock( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const createTab = mock( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const requestCloseTab = mock(() => {}); + const setTabPinned = mock(() => {}); + mock.module("../store", () => ({ + store: { focusTab, createTab }, + useStoreSelector: (select: (s: typeof state) => unknown) => select(state), + shallowEqual: () => true, + useEndpointCreationReason: () => null, + })); + mock.module("./TabBar", () => ({ + requestCloseTab, + tabName: (t: { name: string }) => t.name, + })); + mock.module("../tabPins", () => ({ + useTabPins: () => pins, + setTabPinned, + orderTabsForDisplay: (tabs: unknown[]) => tabs, + })); + mock.module("./agentSession", () => ({ summarizeTabAgents: () => null })); + mock.module("./AgentStatusIcon", () => ({ AgentStatusIcon: () => null })); + const { MobileTabSheet } = await import("./MobileTabSheet"); + const { MobileBottomBar } = await import("./MobileBottomBar"); + const container = document.createElement("div"); + container.className = "app"; + document.body.append(container); + const root = createRoot(container); + let shown = 0; + function Harness() { + const [tabs, setTabs] = useState(false); + const [more, setMore] = useState(false); + return createElement( + "div", + null, + createElement(MobileTabSheet, { + open: tabs, + onClose: () => setTabs(false), + onShowSession: () => { + shown++; + }, + }), + more + ? createElement( + "div", + { id: "mobile-more-options" }, + createElement("button", null, "More action"), + ) + : null, + createElement(MobileBottomBar, { + inputRef: null, + tabCount: state.tabs.length, + attention: false, + tabsOpen: tabs, + moreOpen: more, + onTabsOpenChange: setTabs, + onMoreOpenChange: setMore, + }), + ); + } + const render = () => + root.render(createElement(StrictMode, null, createElement(Harness))); + const button = (label: string) => + Array.from(container.querySelectorAll("button")).find( + (item) => + item.getAttribute("aria-label") === label || item.textContent === label, + )!; + const click = async (label: string) => act(async () => button(label).click()); + const escape = async () => + act(async () => + browser.dispatchEvent( + new browser.KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ), + ); + test("nonmodal actions, pending guard, focus safety and one-click mutual switching", async () => { + try { + await act(async () => render()); + await click("Show tabs"); + const popup = container.querySelector('[role="dialog"]')!; + expect(popup.parentElement?.closest(".app")).toBe(container); + expect(popup.hasAttribute("aria-modal")).toBe(false); + expect(document.activeElement).toBe(popup.querySelector(".close-button")); + await click("More options"); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement?.textContent).toBe("More action"); + await click("Show tabs"); + expect(container.querySelector("#mobile-more-options")).toBeNull(); + expect(document.activeElement?.classList.contains("close-button")).toBe( + true, + ); + await click("Close First"); + expect(requestCloseTab).toHaveBeenCalledWith("one"); + await click("Unpin Pinned"); + expect(setTabPinned).toHaveBeenCalledWith("local", "pinned", false); + const modal = document.createElement("div"); + modal.className = "modal-backdrop"; + document.body.append(modal); + await escape(); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + modal.remove(); + await click("First"); + await click("First"); + await click("New Tab"); + await click("Close tab switcher"); + await escape(); + expect(focusTab).toHaveBeenCalledTimes(1); + expect(createTab).toHaveBeenCalledTimes(0); + expect( + container.querySelector('[role="dialog"]')?.getAttribute("aria-busy"), + ).toBe("true"); + await act(async () => complete!()); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(shown).toBe(1); + expect(document.activeElement).toBe(button("Show tabs")); + await click("Show tabs"); + await click("New Tab"); + expect(createTab).toHaveBeenCalledWith("ws"); + await act(async () => complete!()); + expect(shown).toBe(2); + // Navigation stays usable during a request; its eventual completion must + // not close More, steal its focus, or activate a stale session. + await click("Show tabs"); + await click("First"); + await click("More options"); + const moreFocus = document.activeElement; + await act(async () => complete!()); + expect(shown).toBe(2); + expect(container.querySelector("#mobile-more-options")).not.toBeNull(); + expect(document.activeElement).toBe(moreFocus); + await click("Show tabs"); + await click("First"); + await click("More options"); + await click("Show tabs"); + await act(async () => complete!()); + expect(shown).toBe(2); + expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + await escape(); + await click("Show tabs"); + await click("First"); + // A reconnect on the same ID also invalidates the original completion. + state.connectionGeneration++; + await act(async () => render()); + await act(async () => complete!()); + expect(shown).toBe(2); + await escape(); + // Escape never restores focus into an editor or steals unrelated focus. + await click("Show tabs"); + button("More options").focus(); + await escape(); + expect(document.activeElement).toBe(button("More options")); + // Empty and single-tab workspaces retain their create affordance. + state.tabs = []; + await click("Show tabs"); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(0); + expect(button("New Tab").disabled).toBe(false); + await escape(); + state.tabs = [ + { tab_id: "one", workspace_id: "ws", name: "Only", focused: true }, + ]; + await click("Show tabs"); + expect(container.querySelectorAll('[role="listitem"]')).toHaveLength(1); + await escape(); + state.workspaces = []; + await click("Show tabs"); + expect(container.textContent).toContain("Select or create a workspace"); + expect(button("New Tab").disabled).toBe(true); + } finally { + await act(async () => root.unmount()); + await browser.happyDOM.abort(); + } + }); +} diff --git a/web/src/components/MobileTabSheet.tsx b/web/src/components/MobileTabSheet.tsx index 58205f5b..fbb526df 100644 --- a/web/src/components/MobileTabSheet.tsx +++ b/web/src/components/MobileTabSheet.tsx @@ -1,6 +1,5 @@ import { Pin, Plus, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { shallowEqual, store, @@ -10,15 +9,15 @@ import { import { AgentStatusIcon } from "./AgentStatusIcon"; import { summarizeTabAgents } from "./agentSession"; import { CloseButton } from "./CloseButton"; -import { focusDialogElement } from "./dialogFocus"; +import { focusIfUnchanged } from "./dialogFocus"; import { requestCloseTab, tabName } from "./TabBar"; import { orderTabsForDisplay, setTabPinned, useTabPins } from "../tabPins"; import "./MobileTabSheet.css"; /** - * Bottom-sheet tab switcher for narrow layouts. The tab strip hides itself on - * mobile when a workspace has a single tab, so this sheet is the touch - * affordance for creating, switching, and closing tabs there. + * Non-modal tab popup beside More, inside the app stacking context so the + * persistent navigation rail remains usable. Also provides tab actions when + * the single-tab strip is hidden on narrow layouts. */ export function MobileTabSheet({ open, @@ -32,6 +31,7 @@ export function MobileTabSheet({ const s = useStoreSelector( (state) => ({ activeConnectionId: state.activeConnectionId, + connectionGeneration: state.connectionGeneration, panes: state.panes, tabs: state.tabs, workspaces: state.workspaces, @@ -42,6 +42,24 @@ export function MobileTabSheet({ const onCloseRef = useRef(onClose); const transitionPendingRef = useRef(false); const [transitionPending, setTransitionPending] = useState(false); + // A completion belongs to this opening and connection, not a later popup + // (or More) reached while the request was in flight. + const scope = JSON.stringify([ + s.activeConnectionId, + s.connectionGeneration, + s.workspaces.find((w) => w.focused)?.workspace_id, + ]); + const openingRef = useRef({ open, scope }); + if (openingRef.current.open !== open || openingRef.current.scope !== scope) + openingRef.current = { open, scope }; + + const mountedRef = useRef(false); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); const closeIfIdle = () => { if (!transitionPendingRef.current) onClose(); @@ -52,8 +70,15 @@ export function MobileTabSheet({ if (transitionPendingRef.current) return; transitionPendingRef.current = true; setTransitionPending(true); + const opening = openingRef.current; try { await operation(); + if ( + !mountedRef.current || + opening !== openingRef.current || + !openingRef.current.open + ) + return; onClose(); onShowSession(); } finally { @@ -77,52 +102,57 @@ export function MobileTabSheet({ useEffect(() => { if (!open) return; - const cancelFocus = focusDialogElement(sheetRef.current); + const sheet = sheetRef.current; + // Focus once, without retries that could steal subsequent keyboard or + // navigation focus. Never focus an editor (or reopen the phone keyboard). + sheet + ?.querySelector("button") + ?.focus({ preventScroll: true }); const onKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; // Let stacked dialogs (e.g. the close-tab confirmation) handle Escape. - if ( - document.querySelector( - ".modal-backdrop:not(.mobile-tab-sheet-backdrop)", - ) - ) - return; + if (document.querySelector(".modal-backdrop")) return; event.preventDefault(); event.stopPropagation(); onCloseRef.current(); }; window.addEventListener("keydown", onKeyDown, { capture: true }); return () => { - cancelFocus(); window.removeEventListener("keydown", onKeyDown, { capture: true }); + const source = document.activeElement; + if (source === document.body || (source && sheet?.contains(source))) + focusIfUnchanged(document.querySelector(".mobile-appbar-tabs"), source); }; }, [open]); - if (!open || !focusedWs) return null; + if (!open) return null; - return createPortal( -
+ return ( + <> +
-
, - document.body, + ); } diff --git a/web/src/components/TerminalComposer.css b/web/src/components/TerminalComposer.css index cda97eb0..f459807b 100644 --- a/web/src/components/TerminalComposer.css +++ b/web/src/components/TerminalComposer.css @@ -5,33 +5,28 @@ /* Keep mobile styling in sync with the saved layout and URL override. */ :where(html[data-layout="mobile"]) { .terminal-composer { - /* In-flow at the bottom of the column shell. The app shell owns the main - keyboard lift; only iOS gets a small additional form-accessory-bar gap. */ + /* The global bottom bar owns safe-area and keyboard-accessory spacing. */ position: relative; - /* Above the mobile capsule controls (z-index 72-74). */ - z-index: 75; - margin-bottom: var(--keyboard-inset-composer-gap, 0px); display: flex; flex-direction: column; gap: 6px; - padding: 8px calc(8px + env(safe-area-inset-right, 0px)) - calc(12px + env(safe-area-inset-bottom, 0px)) - calc(8px + env(safe-area-inset-left, 0px)); - border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); - background: color-mix(in srgb, var(--panel) 86%, transparent); - -webkit-backdrop-filter: blur(14px); - backdrop-filter: blur(14px); + min-width: 0; + padding: 0; + border: 0; + background: transparent; } .terminal-composer-input { width: 100%; - min-height: 38px; - max-height: 32dvh; + flex: 1 1 auto; + min-width: 0; + min-height: max(48px, calc(48px / var(--ui-scale, 1))); + max-height: min(160px, calc(var(--app-viewport-height, 100dvh) * 0.25)); resize: none; - padding: 8px 10px; + padding: 12px 10px; border: 1px solid var(--border); - border-radius: 8px; - background: var(--panel-2); + border-radius: 24px; + background: color-mix(in srgb, var(--text-strong) 8%, var(--terminal-bg)); color: var(--text-strong); font-size: 16px; line-height: 1.4; @@ -48,85 +43,44 @@ } .terminal-composer-toolbar { + order: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } - .terminal-composer-mode { - display: flex; - flex-shrink: 0; - min-width: 0; - margin: 0 auto 0 0; - padding: 2px; - border: 1px solid var(--border); - border-radius: 9px; - background: var(--panel-2); + .terminal-composer[data-reserved] { + visibility: hidden; } - .terminal-composer-mode label { - position: relative; - cursor: pointer; + .terminal-composer:not([data-expanded]) .terminal-composer-input, + .terminal-composer:not([data-open]) .terminal-composer-input, + .terminal-composer[data-input-mode="direct"] .terminal-composer-input { + max-height: max(48px, calc(48px / var(--ui-scale, 1))); + overflow-y: hidden; } - .terminal-composer-mode legend { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; + .terminal-composer > .terminal-mobile-input-bar { + order: 3; } - .terminal-composer-mode input { - position: absolute; - opacity: 0; - width: 1px; - height: 1px; + .terminal-composer[data-expanded] > .terminal-mobile-input-bar { + padding-right: 0; } - .terminal-composer-mode span { - display: flex; - align-items: center; - justify-content: center; - min-height: 24px; - padding: 0 8px; - border: 1px solid transparent; - border-radius: 6px; - color: var(--muted); - font-size: 11px; - font-weight: 600; - line-height: 15px; - touch-action: manipulation; - } - - .terminal-composer-mode input:checked + span { - border-color: var(--accent); - background: var(--accent-soft); - color: var(--text-strong); - } - - .terminal-composer-mode input:focus-visible + span { - outline: 2px solid var(--accent); - outline-offset: 2px; - } - - .terminal-composer-mode input:disabled + span { - opacity: 0.45; - cursor: default; - } - - .terminal-composer-direct { - color: var(--muted); - font-size: 12px; + .terminal-composer[data-expanded] .terminal-mobile-direct { + display: none; } - .terminal-composer-actions { + .terminal-composer-submit-actions { display: flex; - flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; align-items: center; + justify-content: flex-end; gap: 6px; + margin-left: auto; } .terminal-composer-hint { @@ -140,7 +94,6 @@ white-space: nowrap; } - .terminal-composer-actions button, .terminal-composer-toolbar button { height: 30px; display: inline-flex; @@ -160,7 +113,6 @@ -webkit-tap-highlight-color: transparent; } - .terminal-composer-actions button:disabled, .terminal-composer-toolbar button:disabled { opacity: 0.45; } @@ -174,6 +126,10 @@ flex: 0 0 auto; } + .terminal-composer-close { + margin-left: auto; + } + .terminal-composer-submit { padding: 0 10px; flex: 0 0 auto; @@ -190,23 +146,20 @@ background: var(--accent-soft); } - .terminal-composer-actions button:active:not(:disabled), .terminal-composer-toolbar button:active:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); } .terminal-composer-commands { - /* Float above the dock without resizing the draft or terminal. */ - position: absolute; - bottom: calc(100% + 6px); - left: calc(8px + env(safe-area-inset-left, 0px)); - right: calc(8px + env(safe-area-inset-right, 0px)); + /* Grow upward inside the shared Message area, without a second dock. */ + order: 1; + position: static; display: flex; flex-direction: column; gap: 6px; min-width: 0; - max-height: min(320px, calc(var(--app-viewport-height, 100dvh) * 0.4)); + max-height: min(220px, calc(var(--app-viewport-height, 100dvh) * 0.2)); overflow-y: auto; overscroll-behavior: contain; padding: 8px; @@ -276,6 +229,7 @@ } .terminal-composer-shortcuts { + order: 1; width: 100%; min-width: 0; display: grid; diff --git a/web/src/components/TerminalComposer.dom.test.ts b/web/src/components/TerminalComposer.dom.test.ts new file mode 100644 index 00000000..be05e41f --- /dev/null +++ b/web/src/components/TerminalComposer.dom.test.ts @@ -0,0 +1,402 @@ +import { expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, createElement } from "react"; + +// Hook-spy tests in other files must not replace React in this DOM runtime. +if (process.env.ROAMGATE_COMPOSER_DOM_TEST !== "1") { + test("composer completed touch activation in an isolated React DOM", async () => { + const child = Bun.spawn([process.execPath, "test", import.meta.path], { + env: { ...process.env, ROAMGATE_COMPOSER_DOM_TEST: "1" }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (code !== 0) throw new Error(`${stdout}\n${stderr}`); + expect(code).toBe(0); + }, 15000); +} else { + test("Insert/Send survive focus and viewport changes without premature or duplicate sends", async () => { + const browser = new Window({ url: "http://localhost" }); + const originals = new Map(); + for (const [key, value] of Object.entries({ + window: browser, + document: browser.document, + navigator: browser.navigator, + HTMLElement: browser.HTMLElement, + HTMLButtonElement: browser.HTMLButtonElement, + HTMLTextAreaElement: browser.HTMLTextAreaElement, + Element: browser.Element, + Node: browser.Node, + localStorage: browser.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + })) { + originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + } + const { createRoot } = await import("react-dom/client"); + const { TerminalComposer } = await import("./TerminalComposer"); + const drafts = await import("../terminalComposer"); + const key = "one-tap-dom"; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const calls: Array<[string, boolean]> = []; + let finish: (() => void) | undefined; + let fail = false; + let mode: "composer" | "direct" = "composer"; + let accessoriesVisible = true; + const props = { + draftKey: key, + mode, + onModeChange() {}, + onFocusDirect() {}, + directDisabled: false, + shortcutRows: [], + onRunShortcut() {}, + onClose() {}, + onSubmit: async (text: string, enter: boolean) => { + calls.push([text, enter]); + if (fail) throw new Error("Disconnected"); + await new Promise((resolve) => { + finish = resolve; + }); + }, + onUploadImage: async () => "/image.png", + onUploadFile: async () => "/file.txt", + onError() {}, + }; + const render = () => + act(() => + root.render( + createElement(TerminalComposer, { + ...props, + mode, + accessoriesVisible, + }), + ), + ); + const seed = async () => + act(() => drafts.writeTerminalComposerDraft(key, "hello")); + const button = (enter: boolean) => + container.querySelector( + enter ? ".is-primary" : ".terminal-composer-submit", + )!; + const editor = () => container.querySelector("textarea")!; + let clock = 1000; + const pointer = ( + target: Element, + type: string, + options: { + x?: number; + y?: number; + elapsed?: number; + pointerType?: string; + id?: number; + } = {}, + ) => { + clock += options.elapsed ?? 10; + const event = new browser.PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerType: options.pointerType ?? "touch", + pointerId: options.id ?? 1, + isPrimary: true, + clientX: options.x ?? 120, + clientY: options.y ?? 120, + }); + Object.defineProperty(event, "timeStamp", { value: clock }); + target.dispatchEvent(event as unknown as Event); + return event; + }; + const click = (target: Element, detail = 1) => + target.dispatchEvent( + new browser.MouseEvent("click", { + bubbles: true, + cancelable: true, + detail, + clientX: detail ? 120 : 0, + clientY: detail ? 120 : 0, + }) as unknown as Event, + ); + const bounds = (target: HTMLButtonElement) => { + target.getBoundingClientRect = () => ({ + left: 100, + right: 180, + top: 100, + bottom: 140, + width: 80, + height: 40, + x: 100, + y: 100, + toJSON() {}, + }); + }; + const settle = async () => { + await act(async () => { + finish?.(); + await Promise.resolve(); + }); + finish = undefined; + }; + try { + await render(); + for (const enter of [false, true]) { + await seed(); + const action = button(enter); + bounds(action); + await act(() => editor().focus()); + editor().setSelectionRange(1, 3); + // Missing keyboard geometry must not trigger document-capture blur. + document.documentElement.classList.remove("keyboard-open"); + let down: ReturnType | undefined; + await act(() => { + down = pointer(action.querySelector("svg")!, "pointerdown"); + }); + expect(down!.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(editor()); + expect(editor().selectionStart).toBe(1); + expect(calls.length).toBe(enter ? 1 : 0); + // Even if the OS blurs/resizes between press and release, the completed + // release on the original SVG invokes exactly one action, without click. + await act(() => { + editor().blur(); + window.dispatchEvent(new browser.Event("resize") as unknown as Event); + action.getBoundingClientRect = () => ({ + left: 100, + right: 180, + top: 300, + bottom: 340, + width: 80, + height: 40, + x: 100, + y: 300, + toJSON() {}, + }); + pointer(action.querySelector("svg")!, "pointerup"); + }); + expect(calls[calls.length - 1]).toEqual(["hello", enter]); + const count = calls.length; + await settle(); + await seed(); + await act(() => click(action.querySelector("path") ?? action)); + expect(calls.length).toBe(count); // compatibility click after unlock + expect(document.activeElement).not.toBe(editor()); // no keyboard reopen + } + // Cancellation stays latched, including an out-and-back or long hold. + for (const gesture of [ + "cancel", + "drag", + "outside", + "hold", + "contextmenu", + ]) { + const action = button(true); + bounds(action); + const count = calls.length; + await act(() => { + pointer(action, "pointerdown"); + if (gesture === "cancel") pointer(action, "pointercancel"); + if (gesture === "drag") { + pointer(action, "pointermove", { x: 160 }); + pointer(action, "pointermove"); + } + if (gesture === "contextmenu") + action.dispatchEvent( + new browser.MouseEvent("contextmenu", { + bubbles: true, + }) as unknown as Event, + ); + pointer(action, "pointerup", { + x: gesture === "outside" ? 181 : 120, + elapsed: gesture === "hold" ? 600 : 10, + }); + if (gesture !== "outside") click(action); + }); + expect(calls.length).toBe(count); + expect(drafts.readTerminalComposerDraft(key)).toBe("hello"); + } + // IME, upload and shared submission locks remain authoritative. + for (const lock of ["ime", "upload", "submission"]) { + const count = calls.length; + await act(() => { + if (lock === "ime") + editor().dispatchEvent( + new browser.CompositionEvent("compositionstart", { + bubbles: true, + }) as unknown as Event, + ); + if (lock === "upload") drafts.beginTerminalComposerUpload(key); + if (lock === "submission") + drafts.beginTerminalComposerSubmission(key); + }); + expect(button(true).disabled).toBe(true); + await act(() => { + pointer(button(true), "pointerdown"); + pointer(button(true), "pointerup"); + click(button(true)); + }); + expect(calls.length).toBe(count); + await act(() => { + if (lock === "ime") + editor().dispatchEvent( + new browser.CompositionEvent("compositionend", { + bubbles: true, + }) as unknown as Event, + ); + if (lock === "upload") drafts.finishTerminalComposerUpload(key); + if (lock === "submission") + drafts.finishTerminalComposerSubmission(key); + }); + } + // A second contact anywhere cancels the original tap permanently. + bounds(button(true)); + const beforeMulti = calls.length; + await act(() => { + pointer(button(true), "pointerdown"); + pointer(document.body, "pointerdown", { id: 2 }); + pointer(button(true), "pointerup"); + click(button(true)); + }); + expect(calls.length).toBe(beforeMulti); + // An IME lock at down cannot become an unlocked release submission. + await act(() => + editor().dispatchEvent( + new browser.CompositionEvent("compositionstart", { + bubbles: true, + }) as unknown as Event, + ), + ); + await act(() => pointer(button(true), "pointerdown")); + await act(() => + editor().dispatchEvent( + new browser.CompositionEvent("compositionend", { + bubbles: true, + }) as unknown as Event, + ), + ); + await act(() => { + pointer(button(true), "pointerup"); + click(button(true)); + }); + expect(calls.length).toBe(beforeMulti); + // Visiting a secondary surface permanently cancels the pending release, + // even when Terminal is restored before the finger lifts. The delayed + // compatibility click must not revive that cancelled Send/Insert either. + for (const enter of [false, true]) { + const action = button(enter); + bounds(action); + const beforeSurface = calls.length; + const originalEditor = editor(); + await act(() => pointer(action, "pointerdown")); + accessoriesVisible = false; + await render(); + expect( + container.querySelector(".terminal-composer-toolbar")! + .hidden, + ).toBe(true); + expect(editor()).toBe(originalEditor); + await act(() => click(action, 0)); + expect(calls.length).toBe(beforeSurface); + accessoriesVisible = true; + await render(); + await act(() => { + pointer(action, "pointerup"); + click(action); + }); + expect(calls.length).toBe(beforeSurface); + expect(drafts.readTerminalComposerDraft(key)).toBe("hello"); + } + // A pane/mode transition cannot complete an old press. + bounds(button(true)); + await act(() => pointer(button(true), "pointerdown")); + mode = "direct"; + await render(); + const beforeTransition = calls.length; + await act(() => pointer(button(true), "pointerup")); + expect(calls.length).toBe(beforeTransition); + mode = "composer"; + await render(); + // A failed release restores the draft before its delayed compatibility + // click, but must still not retry an uncertain terminal write. + fail = true; + const beforeFailure = calls.length; + bounds(button(true)); + await act(async () => { + pointer(button(true), "pointerdown"); + pointer(button(true), "pointerup"); + await Promise.resolve(); + }); + expect(calls.length).toBe(beforeFailure + 1); + expect(drafts.readTerminalComposerDraft(key)).toBe("hello"); + await act(() => click(button(true))); + expect(calls.length).toBe(beforeFailure + 1); + await act(() => + button(true).dispatchEvent( + new browser.PointerEvent("click", { + bubbles: true, + cancelable: true, + pointerType: "touch", + pointerId: 1, + detail: 1, + }) as unknown as Event, + ), + ); + expect(calls.length).toBe(beforeFailure + 1); + await act(() => + button(false).dispatchEvent( + new browser.PointerEvent("click", { + bubbles: true, + cancelable: true, + pointerType: "touch", + pointerId: 1, + detail: 1, + }) as unknown as Event, + ), + ); + expect(calls.length).toBe(beforeFailure + 1); + // A click with no touch identity is not rejected merely for detail=1. + await act(async () => { + button(false).dispatchEvent( + new browser.MouseEvent("click", { + bubbles: true, + detail: 1, + }) as unknown as Event, + ); + await Promise.resolve(); + }); + expect(calls.length).toBe(beforeFailure + 2); + // Keyboard/AT click still works following touch, and mouse down starts + // a fresh native click sequence. Failure retains the draft. + fail = true; + await act(async () => { + click(button(false), 0); + await Promise.resolve(); + }); + expect(calls[calls.length - 1]).toEqual(["hello", false]); + expect(drafts.readTerminalComposerDraft(key)).toBe("hello"); + fail = false; + await act(() => { + pointer(button(true), "pointerdown", { pointerType: "mouse" }); + click(button(true)); + }); + expect(calls[calls.length - 1]).toEqual(["hello", true]); + await settle(); + } finally { + await act(() => root.unmount()); + container.remove(); + for (const [key, descriptor] of originals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + await browser.happyDOM.close(); + } + }); +} diff --git a/web/src/components/TerminalComposer.test.ts b/web/src/components/TerminalComposer.test.ts index 0b578f74..0fe93e14 100644 --- a/web/src/components/TerminalComposer.test.ts +++ b/web/src/components/TerminalComposer.test.ts @@ -297,7 +297,9 @@ test.each(["claude", "grok-build", "agy"])( expect(writeStorage).not.toHaveBeenCalled(); expect(commands().props["aria-label"]).toBe("Commands"); expect((commands().props.children as Element).type).toBe(SquareTerminal); - const textarea = nodes.get("terminal-composer-input")!; + const textarea = nodes.get( + "terminal-composer-input terminal-mobile-message", + )!; // Touch while editing: retain focus, and announce browse navigation too. textarea.focus(); invoke(commands(), "onClick", { detail: 1 }); @@ -432,12 +434,18 @@ test.each(["claude", "grok-build", "agy"])( expect(onSubmit).not.toHaveBeenCalled(); expect(onRunShortcut).not.toHaveBeenCalled(); - // Native radio switches keep the draft/caret; Composer waits for input. - const changeMode = (next: TerminalInputMode, detail = 1) => - invoke(find("value", next), "onChange", { nativeEvent: { detail } }); + // Message editing and the explicit Direct button share the same draft. + const direct = () => + find("aria-label", "Type directly into the terminal"); + const changeMode = (next: TerminalInputMode) => { + if (next === "composer") { + textarea.focus(); + invoke(editor(), "onClick"); + } else invoke(direct(), "onClick"); + }; textarea.setSelectionRange(2, 5); // Hiding both shortcut rows preserves the draft, caret, and native focus. - expect(shortcutPanel().props.hidden).toBe(false); + expect(shortcutPanel().props.hidden).toBe(true); expect((shortcutToggle().props.children as React.ReactElement).type).toBe( Keyboard, ); @@ -448,6 +456,11 @@ test.each(["claude", "grok-build", "agy"])( invoke(shortcutToggle(), "onMouseDown", togglePointer); expect(togglePointer.preventDefault).toHaveBeenCalledTimes(1); invoke(shortcutToggle(), "onClick"); + expect(shortcutPanel().props.hidden).toBe(false); + expect(elements.some((element) => element.props.role === "option")).toBe( + false, + ); + invoke(shortcutToggle(), "onClick"); expect(shortcutToggle().props["aria-expanded"]).toBe(false); expect(shortcutPanel().props.hidden).toBe(true); expect(activeElement).toBe(textarea); @@ -458,8 +471,8 @@ test.each(["claude", "grok-build", "agy"])( expect(onRunShortcut).not.toHaveBeenCalled(); changeMode("direct"); expect(modeChoices).toEqual(["direct"]); - expect(find("value", "direct").props.checked).toBe(true); - expect(editor().props.hidden).toBe(true); + expect(direct().props["aria-pressed"]).toBe(true); + expect(editor().props.hidden).toBeUndefined(); expect(activeElement).toBe(directInput); expect(draft).toBe("new upload or edit"); expect(shortcutPanel().props.hidden).toBe(true); @@ -483,17 +496,15 @@ test.each(["claude", "grok-build", "agy"])( ), ).toHaveLength(shortcutRows.flat().filter((slot) => !slot).length); activeElement = {}; - const composerRadioFocus = activeElement; changeMode("composer"); - expect(activeElement).toBe(composerRadioFocus); + expect(activeElement).toBe(textarea); expect(textarea.selectionStart).toBe(2); expect(textarea.selectionEnd).toBe(5); expect(draft).toBe("new upload or edit"); - // Keyboard navigation stays on the radio; native arrows need no custom keys. + // Activating the Direct button by keyboard is also an explicit handoff. activeElement = {}; - const radioFocus = activeElement; - changeMode("direct", 0); - expect(activeElement).toBe(radioFocus); + changeMode("direct"); + expect(activeElement).toBe(directInput); expect( elements.some( (element) => @@ -522,7 +533,7 @@ test.each(["claude", "grok-build", "agy"])( // An IME confirmation cannot switch modes or send terminal Enter. textarea.focus(); invoke(editor(), "onCompositionStart"); - expect(find("value", "direct").props.disabled).toBe(true); + expect(direct().props.disabled).toBe(true); expect(find("aria-label", "Send Enter").props.disabled).toBe(true); changeMode("direct"); invoke(find("aria-label", "Send Enter"), "onClick"); @@ -535,7 +546,7 @@ test.each(["claude", "grok-build", "agy"])( expect(draft).toBe("new upload or edit"); directDisabled = true; render(); - expect(find("value", "direct").props.disabled).toBe(true); + expect(direct().props.disabled).toBe(true); changeMode("direct"); expect(modeChoices).toEqual(choicesBeforeCommands); // Neither Composer nor an unavailable Direct mode forwards dock focus. diff --git a/web/src/components/TerminalComposer.tsx b/web/src/components/TerminalComposer.tsx index 53dbf45f..a0853c95 100644 --- a/web/src/components/TerminalComposer.tsx +++ b/web/src/components/TerminalComposer.tsx @@ -5,10 +5,10 @@ import { } from "../shortcutPreferences"; import { CornerDownLeft, - CornerDownRight, Keyboard, Paperclip, SquareTerminal, + TextCursorInput, X, } from "lucide-react"; import { type CSSProperties, useEffect, useId, useRef, useState } from "react"; @@ -26,6 +26,7 @@ import { finishTerminalComposerUpload, insertIntoTerminalComposerDraft, readTerminalComposerDraft, + retainTerminalComposerUploadPaths, readTerminalComposerSelection, subscribeTerminalComposerDraft, subscribeTerminalComposerSubmission, @@ -71,11 +72,15 @@ let unsavedShortcutsOpen: boolean | undefined; * xterm's input channel; the native editor stays mounted to preserve its caret. * * Files arrive through clipboard paste, drop, or the file picker, upload once, and - * land in the draft as plain paths at the caret; they reach the terminal only - * through an explicit Insert or Send like any other text. + * land at the draft caret in Composer. Direct captures a guarded terminal + * insertion at the initiating gesture, without changing the draft or sending Enter. */ export function TerminalComposer({ draftKey, + paneId, + open = true, + accessoriesVisible = true, + reserved = false, mode, onModeChange, onFocusDirect, @@ -88,9 +93,15 @@ export function TerminalComposer({ onSubmit, onUploadImage, onUploadFile, + onPreparePathInsertion, onError, }: { draftKey: string; + paneId?: string; + open?: boolean; + // Presentation only: secondary App surfaces keep the editor and input session. + accessoriesVisible?: boolean; + reserved?: boolean; mode: TerminalInputMode; onModeChange: (mode: TerminalInputMode, remember?: boolean) => void; onFocusDirect: () => void; @@ -103,6 +114,7 @@ export function TerminalComposer({ onSubmit: (text: string, submit: boolean) => Promise; onUploadImage: (file: File) => Promise; onUploadFile: (file: File) => Promise; + onPreparePathInsertion?: () => (text: string) => Promise; onError: (message: string) => void; }) { useShortcutPreferences(); @@ -114,6 +126,12 @@ export function TerminalComposer({ terminalComposerUploadCount(draftKey), ); const [composing, setComposing] = useState(false); + const [editorFocused, setEditorFocused] = useState(false); + // Latch expansion for this focus session: expanding changes the wrapping + // width, so deriving it continuously from scrollHeight would oscillate. + const [expanded, setExpanded] = useState(false); + const [toolPointerPending, setToolPointerPending] = useState(false); + const composerRef = useRef(null); const [shortcutsOpen, setShortcutsOpen] = useState( () => unsavedShortcutsOpen ?? @@ -149,6 +167,8 @@ export function TerminalComposer({ ? filterComposerCommands(commands, /^\/[^\s]*/.exec(text)?.[0] ?? "") : commands; const pickerOpen = pickerMode !== null && commands.length > 0; + const accessoriesOpen = open && accessoriesVisible; + const presentedPickerOpen = accessoriesOpen && pickerOpen; const selectedCommand = matches[activeCommand]; // Agent metadata can arrive while editing. Reset only its command UI, not @@ -160,11 +180,11 @@ export function TerminalComposer({ }, [agent]); useEffect(() => { - if (pickerOpen) + if (presentedPickerOpen) commandListRef.current?.children[activeCommand]?.scrollIntoView({ block: "nearest", }); - }, [activeCommand, pickerOpen, text]); + }, [activeCommand, presentedPickerOpen, text]); // Async uploads or edits must not be discarded by a stale confirmation. useEffect(() => { @@ -175,23 +195,64 @@ export function TerminalComposer({ const composingRef = useRef(false); useEffect(() => { - const blurDismissedKeyboard = (event: PointerEvent) => { + if (open) return; + textareaRef.current?.blur(); + setPickerMode(null); + setReplacement(null); + }, [open]); + + useEffect(() => { + const blurOutsideEditor = (event: PointerEvent) => { const textarea = textareaRef.current; - // Dismissing a phone keyboard can leave its editor focused. Retire that - // focus before another touch can reopen it; an input tap stays native. + // xterm prevents native focus transfer on terminal touches. End editing + // explicitly in capture, but never for caret drags, scrolling, IME, or + // composer tools. Tools keep their DOM/geometry through blur and click; + // native file pickers may blur without an async completion refocusing. + const inside = composerRef.current?.contains(event.target as Node); + if (!inside) setToolPointerPending(false); + else if (event.target !== textarea) setToolPointerPending(true); + // Submit must not depend on keyboard geometry: an absent/stale signal + // would blur in capture, restore the header and move the release target. + const submitting = + event.target instanceof Element && + event.target.closest(".terminal-composer-submit"); if ( - event.pointerType === "touch" && - event.target !== textarea && - !document.documentElement.classList.contains("keyboard-open") && - document.activeElement === textarea + document.activeElement === textarea && + (!inside || + (event.pointerType === "touch" && + event.target !== textarea && + !submitting && + !document.documentElement.classList.contains("keyboard-open"))) ) textarea?.blur(); }; - document.addEventListener("pointerdown", blurDismissedKeyboard, true); - return () => - document.removeEventListener("pointerdown", blurDismissedKeyboard, true); + let releaseTimer: ReturnType | undefined; + const release = () => { + clearTimeout(releaseTimer); + setToolPointerPending(false); + }; + // Native touch blur can precede click. Keep the row's geometry until the + // pending tool has received that click (including opening a file picker). + const pointerUp = () => { + releaseTimer = setTimeout(release, 500); + }; + document.addEventListener("pointerdown", blurOutsideEditor, true); + document.addEventListener("click", release); + document.addEventListener("pointerup", pointerUp); + document.addEventListener("pointercancel", release); + return () => { + clearTimeout(releaseTimer); + document.removeEventListener("pointerdown", blurOutsideEditor, true); + document.removeEventListener("click", release); + document.removeEventListener("pointerup", pointerUp); + document.removeEventListener("pointercancel", release); + }; }, []); + useEffect(() => { + if (!editorFocused && !toolPointerPending) setExpanded(false); + }, [editorFocused, toolPointerPending]); + // Load the incoming pane's draft and subscribe to updates from async work // that may outlive an earlier composer mount for this pane. useEffect(() => { @@ -210,13 +271,37 @@ export function TerminalComposer({ return subscribeTerminalComposerUpload(draftKey, setUploadCount); }, [draftKey]); - // Autosize within the CSS max-height. + // Measure at the collapsed width first, including soft wraps. CSS caps the + // unfocused preview to one row; scrollHeight still reports its full content. useEffect(() => { const textarea = textareaRef.current; if (!textarea || mode !== "composer") return; - textarea.style.height = "0px"; - textarea.style.height = `${textarea.scrollHeight}px`; - }, [text, mode]); + const measure = () => { + textarea.style.height = "0px"; + const height = textarea.scrollHeight; + textarea.style.height = `${height}px`; + if (!editorFocused || !open || expanded) return; + const style = window.getComputedStyle(textarea); + const oneRow = + Number.parseFloat(style.lineHeight) + + Number.parseFloat(style.paddingTop) + + Number.parseFloat(style.paddingBottom); + if (/\r|\n/.test(text) || height > oneRow + 1) setExpanded(true); + }; + measure(); + // Keyboard/viewport rotation and UI scaling can introduce a soft wrap + // without changing the draft. Do not refocus or replace the editor. + if (typeof ResizeObserver === "undefined") return; + let measuredWidth = textarea.getBoundingClientRect().width; + const observer = new ResizeObserver(() => { + const width = textarea.getBoundingClientRect().width; + if (width === measuredWidth) return; + measuredWidth = width; + measure(); + }); + observer.observe(textarea); + return () => observer.disconnect(); + }, [text, mode, open, editorFocused, expanded]); // Restore the shared selection after a programmatic insertion. The shared // value belongs to the draft key, so an upload started by an older mount can @@ -253,33 +338,69 @@ export function TerminalComposer({ ); }; + const preparePathInsertion = () => + onPreparePathInsertion?.() ?? + (async (path: string) => { + insertIntoTerminalComposerDraft(draftKey, path); + }); + const prepareUpload = () => ({ + key: draftKey, + upload: onUploadFile, + insert: preparePathInsertion(), + }); + const pickerCleanupRef = useRef<(() => void) | null>(null); + const uploadAndInsert = async ( files: File[], upload: (file: File) => Promise, + insert = preparePathInsertion(), + uploadDraftKey = draftKey, ) => { if (files.length === 0) return; - const uploadDraftKey = draftKey; - if (!beginTerminalComposerUpload(uploadDraftKey)) return; + if ( + composingRef.current || + terminalComposerSubmissionPending(uploadDraftKey) || + !beginTerminalComposerUpload(uploadDraftKey) + ) + return; + const paths: string[] = []; try { - const paths: string[] = []; for (const file of files) paths.push(await upload(file)); - insertIntoTerminalComposerDraft( - uploadDraftKey, - terminalUploadedPathsText(paths), - ); + if (composingRef.current) + throw new Error( + "Path insertion deferred during text composition; path retained in Message draft", + ); + await insert(terminalUploadedPathsText(paths)); + paths.length = 0; } catch (error) { + // Keep successful earlier files when a later upload fails. Delivery + // itself owns its fallback, so do not retry an uncertain PTY write. + if (paths.length) { + retainTerminalComposerUploadPaths( + uploadDraftKey, + terminalUploadedPathsText(paths), + ); + } onError(error instanceof Error ? error.message : "File upload failed"); } finally { finishTerminalComposerUpload(uploadDraftKey); } }; + const pickFiles = (input: HTMLInputElement, context = prepareUpload()) => { + const files = Array.from(input.files ?? []); + input.value = ""; + if (files.length > 0) + void uploadAndInsert(files, context.upload, context.insert, context.key); + }; + const submit = async (sendEnter: boolean) => { const draft = text; const submittedDraftKey = draftKey; if ( + !accessoriesOpen || !draft || - uploadCount > 0 || + terminalComposerUploadCount(submittedDraftKey) > 0 || composingRef.current || !beginTerminalComposerSubmission(submittedDraftKey) ) { @@ -319,6 +440,151 @@ export function TerminalComposer({ e.preventDefault(); }; + // Only these two destructive actions need a touch release fallback. Native + // focus transfer can close the keyboard and move the button before click; + // cancelling pointerdown protects focus but some WebKit versions then omit + // click. Never send on down, and consume the later compatibility click even + // if the request has already completed (or failed and restored the draft). + const submitTouchRef = useRef<{ + id: number; + button: HTMLButtonElement; + key: string; + x: number; + y: number; + bounds: DOMRect; + time: number; + cancelled: boolean; + } | null>(null); + const submitClickTouchRef = useRef<{ + id: number; + button: HTMLButtonElement; + x: number; + y: number; + } | null>(null); + useEffect(() => { + const cancelAdditionalPointer = (event: PointerEvent) => { + const touch = submitTouchRef.current; + if (touch && event.pointerId !== touch.id) touch.cancelled = true; + }; + document.addEventListener("pointerdown", cancelAdditionalPointer, true); + return () => + document.removeEventListener( + "pointerdown", + cancelAdditionalPointer, + true, + ); + }, []); + useEffect(() => { + // Invalidate even an away-and-back transition before the finger lifts. + submitTouchRef.current = null; + }, [draftKey, paneId, mode, open, accessoriesVisible]); + const submitPointerDown = (event: React.PointerEvent) => { + if (event.pointerType !== "touch") { + submitTouchRef.current = null; + submitClickTouchRef.current = null; + return; + } + event.preventDefault(); + // A second finger cannot replace the first press with a fresh tap. + if (!event.isPrimary) { + cancelSubmitTouch(); + return; + } + submitTouchRef.current = null; + submitClickTouchRef.current = { + id: event.pointerId, + button: event.currentTarget, + x: event.clientX, + y: event.clientY, + }; + // Do not focus the textarea: a keyboard dismissed by Back stays dismissed. + if ( + event.currentTarget.disabled || + composingRef.current || + terminalComposerUploadCount(draftKey) > 0 || + terminalComposerSubmissionPending(draftKey) + ) + return; + submitTouchRef.current = { + id: event.pointerId, + button: event.currentTarget, + key: draftKey, + x: event.clientX, + y: event.clientY, + bounds: event.currentTarget.getBoundingClientRect(), + time: event.timeStamp, + cancelled: false, + }; + }; + const submitPointerMove = (event: React.PointerEvent) => { + const touch = submitTouchRef.current; + if (!touch || touch.id !== event.pointerId) return; + const { left, right, top, bottom } = touch.bounds; + // Latch cancellation: moving out and back is not a completed tap. Use the + // press-time bounds so a viewport resize alone is not mistaken for a drag. + if ( + Math.hypot(event.clientX - touch.x, event.clientY - touch.y) > 10 || + event.clientX < left || + event.clientX > right || + event.clientY < top || + event.clientY > bottom + ) + touch.cancelled = true; + }; + const cancelSubmitTouch = () => { + if (submitTouchRef.current) submitTouchRef.current.cancelled = true; + }; + const submitPointerUp = ( + event: React.PointerEvent, + sendEnter: boolean, + ) => { + const touch = submitTouchRef.current; + if (!touch || touch.id !== event.pointerId) return; + submitPointerMove(event); + if (submitClickTouchRef.current) { + submitClickTouchRef.current.x = event.clientX; + submitClickTouchRef.current.y = event.clientY; + } + submitTouchRef.current = null; + if ( + touch.cancelled || + event.timeStamp - touch.time >= 500 || + touch.button !== event.currentTarget || + touch.key !== draftKey || + event.currentTarget.disabled || + !accessoriesOpen || + mode !== "composer" + ) + return; + void submit(sendEnter); + }; + const submitClick = ( + event: React.MouseEvent, + sendEnter: boolean, + ) => { + const touch = submitClickTouchRef.current; + const native = event.nativeEvent; + // Modern touch clicks carry their pointer identity. Older WebKit emits a + // MouseEvent instead; match its button and coordinates. A known touch ID + // is consumed even if layout retargets click to the other submit button. + // A fresh mouse/pen pointerdown clears the association. + const duplicate = + touch && + (("pointerType" in native && + native.pointerType === "touch" && + "pointerId" in native && + native.pointerId === touch.id) || + (!("pointerType" in native) && + touch.button === event.currentTarget && + event.detail > 0 && + Math.hypot(event.clientX - touch.x, event.clientY - touch.y) < 1)); + if (duplicate) { + event.preventDefault(); + return; + } + void submit(sendEnter); + }; + const dismissCommands = () => { setPickerMode(null); setReplacement(null); @@ -377,7 +643,7 @@ export function TerminalComposer({ const commandKeyDown = (e: React.KeyboardEvent) => { if ( - !pickerOpen || + !presentedPickerOpen || e.nativeEvent.isComposing || composingRef.current || e.keyCode === 229 @@ -414,18 +680,36 @@ export function TerminalComposer({ }; const busy = submissionPending || uploadCount > 0; + useEffect(() => { + if (busy || composing) cancelSubmitTouch(); + }, [busy, composing]); const submitDisabled = !text || busy || composing; const hasShortcuts = shortcutRows.some((row) => row.some((shortcut) => shortcut !== null), ); const shortcutColumns = Math.max(1, ...shortcutRows.map((row) => row.length)); + const activateMessage = (remember: boolean) => { + if (composingRef.current) return; + flushSync(() => onModeChange("composer", remember)); + }; return ( <>
{ @@ -435,37 +719,39 @@ export function TerminalComposer({ if (mode === "direct" && !directDisabled) onFocusDirect(); }} > -
-
- Input mode - {(["composer", "direct"] as const).map((nextMode) => ( - - ))} -
+