From 26e67b2de76d055fa47cce14dd7c4fdb4803ef99 Mon Sep 17 00:00:00 2001 From: Yaswanth Narvaneni Date: Fri, 16 Oct 2026 12:42:48 +0100 Subject: [PATCH 1/4] Move phone Tabs and view controls into the app bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On phones, three floating capsules (views, Ranger/Tabs/Workspaces, and Terminal/Files/Changes) and a collapse toggle sat over the terminal output. The app bar now holds Tabs, with the workspace's tab count turning red when another agent is blocked on the user, and a ⋮ button. ⋮ opens a menu that shows the same controls as labelled 48px rows; choosing one, the backdrop or Close dismisses it. The capsules no longer float, and the brand name and version yield their space to the app bar. The initial JS budget rises from 802 to 804 KiB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWodzpMyqdBcX25EE9HhDG --- FEATURES.md | 7 +- web/src/App.tsx | 97 ++++++++++++- web/src/styles/layout/mobile-nav.css | 201 +++++++++++++++++++++++++++ 3 files changed, 301 insertions(+), 4 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index cb7d32e2..78c40686 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,6 +174,9 @@ 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 app bar holds **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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 7968b5f8..6c6d4ad5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -53,6 +53,7 @@ import { LoaderCircle, MessageSquareText, Minimize2, + EllipsisVertical, MoreHorizontal, PanelTop, SquarePen, @@ -89,7 +90,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"; @@ -1406,6 +1407,7 @@ export default function App() { }, }); const [mobileTabSheetOpen, setMobileTabSheetOpen] = useState(false); + const [mobileMoreOpen, setMobileMoreOpen] = useState(false); const terminalComposerScopeKey = JSON.stringify([ s.activeConnectionId, s.connectionGeneration, @@ -1522,10 +1524,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) setMobileMoreOpen(false); if (!mobile) setOpenTerminalComposerScopeKey(null); }, [mobile, focusedWorkspace]); useEffect(() => { @@ -3821,6 +3833,7 @@ export default function App() { } ${mobile && assistantVisible ? "assistant-view" : ""} ${mobileControlsCollapsed ? "mobile-controls-collapsed" : ""} ${ mobileControlsPlacement.side === "left" ? "mobile-controls-left" : "" }`} + data-mobile-more={mobile && mobileMoreOpen ? "open" : undefined} style={ { "--mobile-controls-offset-y": `${mobileControlsOffset}px`, @@ -3844,6 +3857,59 @@ export default function App() {
+ {mobile ? ( + // Phones: Tabs and the ⋮ menu of view and tool controls sit in + // the app bar instead of floating over the terminal. +
+ + +
+ ) : null} {mobile ? null : (
- {activeTerminalComposerDraftKey && !terminalComposerOpen ? ( - - ) : null} - {s.updateInfo?.update_available || s.notice ? createPortal(
diff --git a/web/src/components/TerminalView.css b/web/src/components/TerminalView.css index 2ae033eb..dee854bc 100644 --- a/web/src/components/TerminalView.css +++ b/web/src/components/TerminalView.css @@ -444,6 +444,68 @@ } } +.terminal-mobile-input-bar { + display: none; +} + +/* Phones: a messaging-style input bar in flow below the output, so it never + covers terminal text. */ +:where(html[data-layout="mobile"]) { + .terminal-main:has(> .terminal-mobile-input-bar) { + flex-direction: column; + } + + .terminal-mobile-input-bar { + display: flex; + flex: 0 0 auto; + gap: 8px; + padding: 6px calc(8px + env(safe-area-inset-right, 0px)) + calc(8px + env(safe-area-inset-bottom, 0px)) + calc(8px + env(safe-area-inset-left, 0px)); + background: var(--terminal-bg); + } + + /* Hidden for a touch selection, still holding its height. */ + .terminal-mobile-input-bar[data-reserved] { + visibility: hidden; + } + + .terminal-mobile-input-bar button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + min-width: max(48px, calc(48px / var(--ui-scale, 1))); + min-height: max(48px, calc(48px / var(--ui-scale, 1))); + padding: 0; + color: var(--text-strong); + border: 1px solid transparent; + border-radius: 24px; + background: color-mix(in srgb, var(--text-strong) 8%, var(--terminal-bg)); + touch-action: manipulation; + } + + .terminal-mobile-input-bar .terminal-mobile-message { + flex: 1 1 auto; + min-width: 0; + justify-content: flex-start; + padding: 0 18px; + color: var(--muted); + font-size: 16px; + } + + .terminal-mobile-message-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .terminal-mobile-input-bar button:disabled { + opacity: 0.5; + } +} + .terminal-touch-selection-ui { zoom: calc(1 / var(--ui-scale, 1)); user-select: none; diff --git a/web/src/components/TerminalView.inputMode.test.ts b/web/src/components/TerminalView.inputMode.test.ts index 9f2f9a35..38313b87 100644 --- a/web/src/components/TerminalView.inputMode.test.ts +++ b/web/src/components/TerminalView.inputMode.test.ts @@ -486,9 +486,9 @@ async function registerDomTests() { ).toHaveLength(before); } async function typeGesture() { - // Match App's Type click: open+commit, then synchronously focus the dock - // root, whose handler routes Direct focus, and in Composer the draft, - // all in one gesture. + // Match a tap that opens the dock: open+commit, then synchronously focus + // the dock root, whose handler routes Direct focus, and in Composer the + // draft, all in one gesture. await act(async () => { flushSync(() => { open = true; @@ -1189,6 +1189,33 @@ async function registerDomTests() { expect(currentTerminal().focusCount).toBe(0); }); + test("the phone input bar opens Composer with its draft focused, or Direct", async () => { + await mount(); + const label = () => + container.querySelector(".terminal-mobile-message-label")?.textContent; + expect(label()).toBe("Message claude…"); + await click(".terminal-mobile-message"); + expect(container.querySelector(".terminal-mobile-input-bar")).toBeNull(); + expect(selectedMode()).toBe("composer"); + expect(savedMode()).toBe("composer"); + expect(document.activeElement).toBe( + container.querySelector('[aria-label="Terminal input draft"]'), + ); + expectInputBlocked(); + await click('[aria-label="Close terminal input"]'); + await act(async () => writeTerminalComposerDraft(draftKey, "draft")); + expect(label()).toBe("Continue draft…"); + expect( + container + .querySelector(".terminal-mobile-message") + ?.getAttribute("aria-label"), + ).toBe("Open terminal input, unsent draft"); + await click(".terminal-mobile-direct"); + expect(selectedMode()).toBe("direct"); + expect(savedMode()).toBe("direct"); + expect(document.activeElement).toBe(currentTerminal().textarea); + }); + test("manual mode choices survive close and reopen without opening stdin", async () => { await mount(); await setOpen(true); diff --git a/web/src/components/TerminalView.tsx b/web/src/components/TerminalView.tsx index 63761015..9999b902 100644 --- a/web/src/components/TerminalView.tsx +++ b/web/src/components/TerminalView.tsx @@ -1,4 +1,4 @@ -import { createPortal } from "react-dom"; +import { createPortal, flushSync } from "react-dom"; import { createReviewAnnotation, MAX_QUOTE_LENGTH, @@ -35,6 +35,7 @@ import { installTerminalCompositionRepair } from "../terminalComposition"; import { Columns2, Keyboard, + SquareTerminal, Maximize2, Minimize2, Rows2, @@ -48,6 +49,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import "@xterm/xterm/css/xterm.css"; import { bridge, type ConnectionClient } from "../api"; @@ -78,6 +80,8 @@ import { terminalComposerDraftKey, terminalComposerDraftPaneIds, terminalComposerRequest, + readTerminalComposerDraft, + subscribeTerminalComposerDraft, } from "../terminalComposer"; import { registerTerminalConnectionDisposer, @@ -515,6 +519,24 @@ export function TerminalView({ const activePaneId = selectedPaneInLayout ?? s.layout?.focused_pane_id ?? null; const isActivePane = !!pane && (!paneId || pane.pane_id === activePaneId); + // The phone input bar names an unsent Composer draft. + const inputBarDraftKey = pane + ? terminalComposerDraftKey( + s.activeConnectionId, + s.connectionGeneration, + pane.pane_id, + ) + : null; + const composerHasDraft = useSyncExternalStore( + (listener) => + inputBarDraftKey + ? subscribeTerminalComposerDraft(inputBarDraftKey, listener) + : () => {}, + () => + inputBarDraftKey + ? readTerminalComposerDraft(inputBarDraftKey) !== "" + : false, + ); const canShowAgentHistory = isActivePane && paneHasAgentHistory(pane); const canClosePane = !!pane && paneCanClose(s.panes, pane.pane_id); const paneZoomed = @@ -3506,6 +3528,72 @@ export function TerminalView({ if (event.target === event.currentTarget) focusTerminalInput(); }} /> + {isMobileLayout() && !composerOpen && isActivePane ? ( + // On phones, a messaging-style input bar in flow below the + // output. A touch selection hides it but keeps its height, so + // the terminal does not resize and drop the selection. +
0 || undefined} + > + + +
+ ) : null} {!isMobileLayout() && touchHandles.length === 0 && isActivePane ? (
Date: Sun, 18 Oct 2026 00:20:55 +0100 Subject: [PATCH 3/4] =?UTF-8?q?Close=20the=20phone's=20=E2=8B=AE=20menu=20?= =?UTF-8?q?with=20Back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⋮ menu is a popup layer like the tab sheet, so the phone's Back closes it before the input dock or a mobile view. Combined with the system Back hook, the initial JS budget rises to 805 KiB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AWodzpMyqdBcX25EE9HhDG --- FEATURES.md | 6 +++--- scripts/check-web-assets.mjs | 2 +- web/src/App.tsx | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 95ec1ae3..3d83a50b 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -189,9 +189,9 @@ Drafts stay in this browser and re-anchor to changed files on refresh. bar sits in the layout, so it never covers output, and shows **Continue draft…** while a draft is unsent. 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 phone's Back gesture or button closes the tab sheet or the **⋮** menu, + 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. - Install as a PWA for an app-style window; see 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/web/src/App.tsx b/web/src/App.tsx index 15e1b1e8..9ab30b2b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1677,13 +1677,14 @@ export default function App() { }); } }, [mobile, updateInspectorState]); - // The phone's Back closes the tab sheet, then returns from Workspaces, - // Files, Changes, Assistant or Annotations to the session. + // The phone's Back closes the tab sheet or the ⋮ menu, then returns from + // Workspaces, Files, Changes, Assistant or Annotations to the session. useSystemBack( mobileTabSheetOpen, () => setMobileTabSheetOpen(false), "popup", ); + useSystemBack(mobileMoreOpen, () => setMobileMoreOpen(false), "popup"); useSystemBack(mobileView !== "session", activateTerminalSurface, "panel"); const toggleSidebar = useCallback(() => { setMobileView("session"); From 8c90f8ded945e0d366883c23ca3b3bbcfb3059e4 Mon Sep 17 00:00:00 2001 From: Wangshuyi Date: Sun, 18 Oct 2026 21:09:52 +0800 Subject: [PATCH 4/4] Unify mobile input and navigation Keep one Message editor in the global bottom bar with Direct, Tabs and More. Preserve pane-scoped drafts and caret, explicit input authorization, IME and upload locks, one-touch Insert/Send, and guarded Direct upload destinations. Match the Tabs and More popups, hide input tools on secondary pages without resetting them, and fill the mobile Commits page without duplicating bottom safe-area space. Retain desktop layout and honest non-Git diagnostics. Migrate the legacy floating-row test to the unified editor's outside-pointer blur contract while checking draft, caret and IME-lock preservation. Manual validation: Chrome against the isolated backend, narrow/landscape and 80-150% UI scales, modeled safe area and short viewport; the preview was accepted by the user. Native iOS keyboard, IME and picker acceptance was not independently completed. --- FEATURES.md | 39 +- server/src/workspace/files.test.ts | 79 ++ web/src/App.tsx | 246 ++---- web/src/activeTerminalInput.test.ts | 28 +- web/src/activeTerminalInput.ts | 31 +- web/src/components/AgentHistoryDrawer.css | 2 + web/src/components/AnnotationPanel.css | 5 +- web/src/components/AssistantPanel.css | 8 +- web/src/components/CommandCombobox.tsx | 6 + web/src/components/ConfigMenu.tsx | 32 +- web/src/components/ConnectionSwitcher.css | 3 +- web/src/components/ConnectionSwitcher.tsx | 9 + .../components/MobileBottomBar.layout.test.ts | 166 ++++ web/src/components/MobileBottomBar.tsx | 86 ++ web/src/components/MobileTabSheet.css | 40 +- web/src/components/MobileTabSheet.test.ts | 330 ++++++++ web/src/components/MobileTabSheet.tsx | 85 +- web/src/components/TerminalComposer.css | 126 +-- .../components/TerminalComposer.dom.test.ts | 402 +++++++++ web/src/components/TerminalComposer.test.ts | 41 +- web/src/components/TerminalComposer.tsx | 766 +++++++++++++----- .../TerminalComposer.uploads.test.ts | 136 +++- web/src/components/TerminalView.css | 52 +- .../components/TerminalView.inputMode.test.ts | 695 +++++++++++++++- web/src/components/TerminalView.tsx | 414 ++++++---- web/src/components/menuLayers.test.ts | 68 ++ .../secondaryViewport.layout.test.ts | 149 ++++ .../components/useCloseGlobalMenuForDialog.ts | 34 + .../useMobileControlsDrag.keyboard.test.ts | 35 +- web/src/styles/layout/app.css | 21 +- web/src/styles/layout/mobile-nav.css | 180 +++- web/src/styles/layout/sidebar.css | 3 +- web/src/systemBack.test.ts | 30 +- web/src/terminalComposer.ts | 28 +- web/src/terminalImageUpload.test.ts | 19 +- web/src/terminalImageUpload.ts | 3 +- 36 files changed, 3526 insertions(+), 871 deletions(-) create mode 100644 web/src/components/MobileBottomBar.layout.test.ts create mode 100644 web/src/components/MobileBottomBar.tsx create mode 100644 web/src/components/MobileTabSheet.test.ts create mode 100644 web/src/components/TerminalComposer.dom.test.ts create mode 100644 web/src/components/menuLayers.test.ts create mode 100644 web/src/components/secondaryViewport.layout.test.ts create mode 100644 web/src/components/useCloseGlobalMenuForDialog.ts diff --git a/FEATURES.md b/FEATURES.md index 3d83a50b..ede798f7 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -174,26 +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 app bar holds **Tabs** (with the workspace's tab count, red when another - agent needs you) and **⋮**, a menu of views and tools: Terminal, Files, +- 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. -- An input bar below the terminal output opens an input dock with two modes: - **Message…** opens **Composer** (edit a draft with IME, dictation, and - images; **Send** adds Enter) with the keyboard raised, and the terminal - button beside it opens **Direct** (keys go straight to the terminal). The - bar sits in the layout, so it never covers output, and shows - **Continue draft…** while a draft is unsent. Afterwards, sends, scrolling, - and shortcut actions leave a dismissed keyboard closed. -- The phone's Back gesture or button closes the tab sheet or the **⋮** menu, - 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/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 9ab30b2b..43780075 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,3 +1,4 @@ +import { MobileBottomBar } from "./components/MobileBottomBar"; import { createPortal } from "react-dom"; import { bridge } from "./api"; import { @@ -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, - EllipsisVertical, - MoreHorizontal, PanelTop, - SquareStack, SquareTerminal, - X, } from "lucide-react"; import { - type CSSProperties, Suspense, useCallback, useEffect, @@ -280,6 +269,8 @@ function PopupOverlay({ type TerminalViewProps = { paneId?: string; visible?: boolean; + mobileInputTarget?: HTMLElement | null; + onActivateTerminalSurface?: () => void; terminalTheme: ITheme; terminalFontFamily: string; terminalFontScale: number; @@ -678,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; @@ -1004,6 +979,8 @@ function resizeTargetForSplit( // the old full terminal view. function TerminalPaneLayout({ visible, + mobileInputTarget, + onActivateTerminalSurface, terminalTheme, terminalFontFamily, terminalFontScale, @@ -1018,6 +995,8 @@ function TerminalPaneLayout({ onExitZenMode, }: { visible: boolean; + mobileInputTarget: HTMLElement | null; + onActivateTerminalSurface: () => void; terminalTheme: ITheme; terminalFontFamily: string; terminalFontScale: number; @@ -1077,6 +1056,8 @@ function TerminalPaneLayout({ mobileShortcuts={mobileShortcuts} mobileSideShortcuts={mobileSideShortcuts} visible={visible} + mobileInputTarget={mobileInputTarget} + onActivateTerminalSurface={onActivateTerminalSurface} composerOpen={composerOpen} onComposerOpenChange={onComposerOpenChange} agentHistoryOpen={agentHistoryOpen} @@ -1137,6 +1118,8 @@ function TerminalPaneLayout({ mobileShortcuts={mobileShortcuts} mobileSideShortcuts={mobileSideShortcuts} visible={visible} + mobileInputTarget={mobileInputTarget} + onActivateTerminalSurface={onActivateTerminalSurface} composerOpen={composerOpen} onComposerOpenChange={onComposerOpenChange} agentHistoryOpen={agentHistoryOpen} @@ -1248,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} @@ -1387,28 +1372,24 @@ 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 [paneJumpOpen, setPaneJumpOpen] = useState(false); @@ -1531,7 +1512,7 @@ export default function App() { 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]); @@ -1552,7 +1533,6 @@ export default function App() { : null; const terminalComposerOpen = mobile && - !mobileTabSheetOpen && openTerminalComposerScopeKey === terminalComposerScopeKey && activeTerminalComposerDraftKey !== null; const setTerminalComposerOpen = useCallback( @@ -1666,6 +1646,7 @@ export default function App() { [], ); const activateTerminalSurface = useCallback(() => { + setMobileMoreOpen(false); updateInspectorState((current) => current ? { ...current, open: false } : current, ); @@ -1677,14 +1658,13 @@ export default function App() { }); } }, [mobile, updateInspectorState]); - // The phone's Back closes the tab sheet or the ⋮ menu, then returns from - // Workspaces, Files, Changes, Assistant or Annotations to the session. + // The phone's Back closes the tab sheet, then returns from Workspaces, + // Files, Changes, Assistant or Annotations to the session. useSystemBack( mobileTabSheetOpen, () => setMobileTabSheetOpen(false), "popup", ); - useSystemBack(mobileMoreOpen, () => setMobileMoreOpen(false), "popup"); useSystemBack(mobileView !== "session", activateTerminalSurface, "panel"); const toggleSidebar = useCallback(() => { setMobileView("session"); @@ -1947,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, ); }); @@ -2805,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; @@ -3531,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 || @@ -3795,7 +3769,6 @@ export default function App() { : "Open Ranger" } aria-pressed={assistantVisible} - tabIndex={mobile && mobileControlsCollapsed ? -1 : 0} onPointerDown={mobile ? blurActiveInput : undefined} onClick={toggleAssistant} > @@ -3811,18 +3784,10 @@ export default function App() { ); return (
@@ -3841,59 +3806,6 @@ export default function App() {
- {mobile ? ( - // Phones: Tabs and the ⋮ menu of view and tool controls sit in - // the app bar instead of floating over the terminal. -
- - -
- ) : null} {mobile ? null : (
- setMobileTabSheetOpen(false)} - onShowSession={activateTerminalSurface} - /> -
@@ -4321,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) => ( - - ))} -
+