diff --git a/FEATURES.md b/FEATURES.md
index e4c440fc..cb7d32e2 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -184,6 +184,9 @@ Drafts stay in this browser and re-anchor to changed files on refresh.
(keys go straight to the terminal). Its tap raises the keyboard in either
mode; afterwards, sends, scrolling, and shortcut actions leave a dismissed
keyboard closed.
+- The phone's Back gesture or button closes the tab sheet, then the input dock
+ or a mobile view such as Workspaces, Files, Changes or Ranger, one per
+ press, before leaving the page.
- The `>_` button in Composer offers slash commands for Claude Code, Codex, Pi,
Kimi Code, Grok Build, and Antigravity CLI.
- 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 0b51727d..e071ac55 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 = 803 * 1024;
+const maxInitialJsBytes = 804 * 1024;
const maxInitialJsGzipBytes = 256 * 1024;
const maxInitialCssBytes = 196 * 1024;
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 6f0e11ad..7968b5f8 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -30,6 +30,7 @@ import {
} from "./mobileControlsPlacement";
import { useMobileControlsDrag } from "./components/useMobileControlsDrag";
import { LAYOUT_CHANGE_EVENT, useLayoutPreferences } from "./layoutPreferences";
+import { useSystemBack } from "./systemBack";
import {
shortcutMatches,
shortcutTitle,
@@ -280,6 +281,7 @@ function PopupOverlay({
type TerminalViewProps = {
paneId?: string;
+ visible?: boolean;
terminalTheme: ITheme;
terminalFontFamily: string;
terminalFontScale: number;
@@ -1003,6 +1005,7 @@ function resizeTargetForSplit(
// Render the active tab's Herdr pane layout; single-pane and zoomed tabs keep
// the old full terminal view.
function TerminalPaneLayout({
+ visible,
terminalTheme,
terminalFontFamily,
terminalFontScale,
@@ -1016,6 +1019,7 @@ function TerminalPaneLayout({
zenMode,
onExitZenMode,
}: {
+ visible: boolean;
terminalTheme: ITheme;
terminalFontFamily: string;
terminalFontScale: number;
@@ -1074,6 +1078,7 @@ function TerminalPaneLayout({
terminalFontScale={terminalFontScale}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
+ visible={visible}
composerOpen={composerOpen}
onComposerOpenChange={onComposerOpenChange}
agentHistoryOpen={agentHistoryOpen}
@@ -1133,6 +1138,7 @@ function TerminalPaneLayout({
terminalFontScale={terminalFontScale}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
+ visible={visible}
composerOpen={composerOpen}
onComposerOpenChange={onComposerOpenChange}
agentHistoryOpen={agentHistoryOpen}
@@ -1243,6 +1249,7 @@ function TerminalPaneLayout({
showMobileKeys={isActive}
mobileShortcuts={mobileShortcuts}
mobileSideShortcuts={mobileSideShortcuts}
+ visible={visible}
composerOpen={isActive ? composerOpen : false}
onComposerOpenChange={isActive ? onComposerOpenChange : undefined}
agentHistoryOpen={isActive ? agentHistoryOpen : false}
@@ -1675,6 +1682,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.
+ useSystemBack(
+ mobileTabSheetOpen,
+ () => setMobileTabSheetOpen(false),
+ "popup",
+ );
+ useSystemBack(mobileView !== "session", activateTerminalSurface, "panel");
const toggleSidebar = useCallback(() => {
setMobileView("session");
setSidebarHidden((value) => !value);
@@ -4264,6 +4279,7 @@ export default function App() {
>
void)[] = [];
+ const replaceLocation = browser.history.replaceState.bind(browser.history);
+ Object.defineProperty(browser, "history", {
+ value: {
+ get state() {
+ return entries[historyIndex]!.state;
+ },
+ pushState(state: object | null, _unused: string, url?: string) {
+ entries.splice(historyIndex + 1);
+ entries.push({ state, url: url ?? entries[historyIndex]!.url });
+ historyIndex++;
+ replaceLocation(state, "", entries[historyIndex]!.url);
+ },
+ replaceState(state: object | null, _unused: string, url?: string) {
+ entries[historyIndex] = {
+ state,
+ url: url ?? entries[historyIndex]!.url,
+ };
+ replaceLocation(state, "", entries[historyIndex]!.url);
+ },
+ back() {
+ traversals.push(() => {
+ if (historyIndex === 0) return;
+ const entry = entries[--historyIndex]!;
+ replaceLocation(entry.state, "", entry.url);
+ browser.dispatchEvent(
+ new browser.PopStateEvent("popstate", { state: entry.state }),
+ );
+ });
+ },
+ },
+ });
+ async function settleHistory() {
+ for (let turn = 0; turn < 8; turn++) {
+ await act(async () => {
+ await Promise.resolve();
+ for (const complete of traversals.splice(0)) complete();
+ });
+ if (!traversals.length) return;
+ }
+ throw new Error("history traversal loop");
+ }
+ let mobileLayout = true;
const noop = () => {};
const disposable = () => ({ dispose: noop });
type Pane = {
@@ -160,7 +208,8 @@ async function registerDomTests() {
},
}));
mock.module("../layoutPreferences", () => ({
- isMobileLayout: () => true,
+ isMobileLayout: () => mobileLayout,
+ useLayoutPreferences: () => ({ mobile: mobileLayout }),
LAYOUT_CHANGE_EVENT: "test-layout-change",
}));
mock.module("./CreateWorkspaceDialog", () => ({
@@ -258,6 +307,7 @@ async function registerDomTests() {
}
mock.module("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
const { TerminalView } = await import("./TerminalView");
+ const { useSystemBack } = await import("../systemBack");
const { TERMINAL_INPUT_MODE_STORAGE_KEY } = await import(
"../terminalInputMode"
);
@@ -277,6 +327,10 @@ async function registerDomTests() {
let root: Root | null = null;
let container: HTMLDivElement;
let open = false;
+ let visible = true;
+ let filesOpen = false;
+ let tabsOpen = false;
+ let viewKey = "initial";
let strict = false;
let explicitPane: string | undefined;
let terminalFontScale = 100;
@@ -292,7 +346,12 @@ async function registerDomTests() {
localStorage.clear();
sessionStorage.clear();
open = false;
+ visible = true;
+ mobileLayout = true;
+ filesOpen = false;
+ tabsOpen = false;
strict = false;
+ viewKey = "initial";
explicitPane = undefined;
terminalFontScale = 100;
mobileShortcuts = [[], []];
@@ -300,6 +359,7 @@ async function registerDomTests() {
afterEach(async () => {
document.documentElement.classList.remove("keyboard-open");
if (root) await act(async () => root?.unmount());
+ await settleHistory();
root = null;
clearTerminalComposerDraft(draftKey);
document.documentElement.classList.remove("keyboard-open");
@@ -314,8 +374,35 @@ async function registerDomTests() {
else Reflect.deleteProperty(globalThis, key);
}
});
+ // Model App's mobile panels around the real TerminalView, which stays
+ // mounted when CSS hides the terminal surface behind Files.
+ function MobilePanels({
+ children,
+ }: {
+ children?: import("react").ReactNode;
+ }) {
+ useSystemBack(
+ tabsOpen,
+ () => {
+ tabsOpen = false;
+ render();
+ },
+ "popup",
+ );
+ useSystemBack(
+ filesOpen,
+ () => {
+ filesOpen = false;
+ visible = true;
+ render();
+ },
+ "panel",
+ );
+ return children;
+ }
function render() {
const view = createElement(TerminalView, {
+ key: viewKey,
paneId: explicitPane,
terminalTheme: {},
terminalFontFamily: "monospace",
@@ -323,13 +410,33 @@ async function registerDomTests() {
showMobileKeys: false,
mobileShortcuts,
mobileSideShortcuts: [],
+ visible,
composerOpen: open,
onComposerOpenChange(next: boolean) {
open = next;
render();
},
});
- root!.render(strict ? createElement(StrictMode, null, view) : view);
+ // App replaces its mobile single-pane branch with a desktop split tree.
+ const layout = mobileLayout
+ ? view
+ : createElement(
+ "div",
+ { key: "split" },
+ view,
+ createElement(TerminalView, {
+ paneId: "pane-b",
+ terminalTheme: {},
+ terminalFontFamily: "monospace",
+ terminalFontScale,
+ showMobileKeys: false,
+ mobileShortcuts,
+ mobileSideShortcuts: [],
+ composerOpen: false,
+ }),
+ );
+ const panels = createElement(MobilePanels, null, layout);
+ root!.render(strict ? createElement(StrictMode, null, panels) : panels);
}
async function mount(initiallyOpen = false, strictMode = false) {
open = initiallyOpen;
@@ -406,6 +513,96 @@ async function registerDomTests() {
});
}
+ async function back() {
+ browser.history.back();
+ await settleHistory();
+ }
+
+ test.each([false, true])(
+ "only a visible mounted Composer consumes Back (visible %s)",
+ async (surfaceVisible) => {
+ browser.history.pushState(null, "", "/previous");
+ browser.history.pushState(null, "", "/terminal");
+ visible = surfaceVisible;
+ await mount(true);
+ const term = currentTerminal();
+ expect(container.querySelector(".terminal-composer")).not.toBeNull();
+ await back();
+ expect(open).toBe(!surfaceVisible);
+ expect(browser.location.pathname).toBe(
+ surfaceVisible ? "/terminal" : "/previous",
+ );
+ expect(currentTerminal()).toBe(term);
+ },
+ );
+
+ test("multi-pane mobile to desktop replacement removes the Composer guard", async () => {
+ browser.history.pushState(null, "", "/previous");
+ browser.history.pushState(null, "", "/terminal");
+ await mount(true, true);
+ const mobileTerminal = currentTerminal();
+ await act(async () => {
+ mobileLayout = false;
+ render();
+ });
+ await settleHistory();
+ expect(currentTerminal()).not.toBe(mobileTerminal);
+ expect(browser.history.state).not.toHaveProperty("roamgateBack");
+ expect(open).toBe(true);
+ await back();
+ expect(browser.location.pathname).toBe("/previous");
+ expect(open).toBe(true);
+ });
+
+ test.each([false, true])(
+ "Type, Files, Tabs, Back, Back restores Composer (hidden pane remount %s)",
+ async (remount) => {
+ browser.history.pushState(null, "", "/terminal");
+ writeTerminalComposerDraft(draftKey, "unsent draft");
+ await mount();
+ await typeGesture();
+ let term = currentTerminal();
+ await act(async () => {
+ visible = false;
+ filesOpen = true;
+ render();
+ });
+ if (remount) {
+ // A tab/pane replacement while Files covers the terminal must not
+ // re-register its still-open, but invisible Composer above Files.
+ await act(async () => {
+ viewKey = "replacement";
+ render();
+ });
+ expect(currentTerminal()).not.toBe(term);
+ term = currentTerminal();
+ }
+ await act(async () => {
+ tabsOpen = true;
+ render();
+ });
+ await back();
+ expect(tabsOpen).toBe(false);
+ expect(filesOpen).toBe(true);
+ expect(open).toBe(true);
+ await back();
+ expect(filesOpen).toBe(false);
+ expect(visible).toBe(true);
+ expect(open).toBe(true);
+ expect(currentTerminal()).toBe(term);
+ expect(
+ container.querySelector(
+ '[aria-label="Terminal input draft"]',
+ )!.value,
+ ).toBe("unsent draft");
+ expect(readTerminalComposerDraft(draftKey)).toBe("unsent draft");
+ expect(selectedMode()).toBe("composer");
+ expectInputBlocked();
+ await back();
+ expect(open).toBe(false);
+ },
+ );
+
test.each(["closed", "direct", "composer"] as const)(
"terminal focus activates %s input after touch on a coarse-pointer desktop",
async (mode) => {
diff --git a/web/src/components/TerminalView.tsx b/web/src/components/TerminalView.tsx
index 791c2aad..63761015 100644
--- a/web/src/components/TerminalView.tsx
+++ b/web/src/components/TerminalView.tsx
@@ -17,6 +17,7 @@ import { isMobileLayout, LAYOUT_CHANGE_EVENT } from "../layoutPreferences";
import { terminalFontOptions } from "../appearance";
import { detectShortcutPlatform } from "../shortcutBindings";
import { PaneDragHandle, PaneMoveMenu } from "./PaneMoveMenu";
+import { useSystemBack } from "../systemBack";
import {
getShortcutSnapshot,
shortcutMatches,
@@ -312,6 +313,7 @@ export type TerminalWorkspaceFileRequest = {
export function TerminalView({
paneId,
+ visible = true,
terminalTheme,
terminalFontFamily,
terminalFontScale,
@@ -325,6 +327,7 @@ export function TerminalView({
onOpenWorkspaceFile,
}: {
paneId?: string;
+ visible?: boolean;
terminalTheme: ITheme;
terminalFontFamily: string;
terminalFontScale: number;
@@ -600,6 +603,11 @@ export function TerminalView({
},
[controlledComposerOpen, onComposerOpenChange],
);
+ useSystemBack(
+ visible && composerOpen && isActivePane,
+ () => setComposerOpen(false),
+ "panel",
+ );
const agentHistoryOpen = controlledAgentHistoryOpen ?? localAgentHistoryOpen;
const setAgentHistoryOpen = useCallback(
(open: boolean) => {
diff --git a/web/src/components/ThemedSelect.test.ts b/web/src/components/ThemedSelect.test.ts
index 19cde185..bd09901a 100644
--- a/web/src/components/ThemedSelect.test.ts
+++ b/web/src/components/ThemedSelect.test.ts
@@ -51,7 +51,7 @@ async function registerDomTest() {
cancelAnimationFrame: browser.cancelAnimationFrame.bind(browser),
IS_REACT_ACT_ENVIRONMENT: true,
});
- const { act, createElement } = await import("react");
+ const { act, createElement, StrictMode, useState } = await import("react");
const { createRoot } = await import("react-dom/client");
const { ThemedSelect } = await import("./ThemedSelect");
afterAll(async () => browser.happyDOM.close());
@@ -116,6 +116,68 @@ async function registerDomTest() {
}
});
+ test("mobile Back closes the real select before its underlying panel in StrictMode", async () => {
+ const { updateLayoutPreferences } = await import("../layoutPreferences");
+ const { useSystemBack } = await import("../systemBack");
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ let panelCloses = 0;
+ function Panel() {
+ const [open, setOpen] = useState(true);
+ useSystemBack(
+ open,
+ () => {
+ panelCloses++;
+ setOpen(false);
+ },
+ "panel",
+ );
+ return open
+ ? createElement(ThemedSelect, {
+ value: "first",
+ options: [{ value: "first", label: "First" }],
+ onChange: () => {},
+ "aria-label": "Mobile select",
+ })
+ : null;
+ }
+ const back = async () => {
+ await act(async () => {
+ // A browser Back lands on the unguarded base before firing popstate.
+ browser.history.replaceState(null, "");
+ browser.dispatchEvent(
+ new browser.PopStateEvent("popstate", { state: null }),
+ );
+ });
+ await act(async () => browser.happyDOM.whenAsyncComplete());
+ };
+ try {
+ await act(async () => updateLayoutPreferences({ mode: "mobile" }));
+ await act(async () =>
+ root.render(createElement(StrictMode, null, createElement(Panel))),
+ );
+ const trigger = container.querySelector("button")!;
+ await click(trigger);
+ expect(document.querySelector(".themed-select-content")).not.toBeNull();
+ await back();
+ expect(document.querySelector(".themed-select-content")).toBeNull();
+ expect(panelCloses).toBe(0);
+ expect(document.activeElement).toBe(trigger);
+ expect(
+ browser.history.state && "roamgateBack" in browser.history.state,
+ ).toBe(true);
+ await back();
+ expect(panelCloses).toBe(1);
+ expect(container.querySelector("button")).toBeNull();
+ } finally {
+ await act(async () => root.unmount());
+ await act(async () => updateLayoutPreferences({ mode: "desktop" }));
+ container.remove();
+ await browser.happyDOM.whenAsyncComplete();
+ }
+ });
+
const cleanups: (() => Promise)[] = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup();
diff --git a/web/src/components/ThemedSelect.tsx b/web/src/components/ThemedSelect.tsx
index ac40781e..889a81ea 100644
--- a/web/src/components/ThemedSelect.tsx
+++ b/web/src/components/ThemedSelect.tsx
@@ -1,5 +1,7 @@
import { Check, ChevronDown } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
+import { useLayoutPreferences } from "../layoutPreferences";
+import { useSystemBack } from "../systemBack";
import { cn } from "../utils";
import {
Command,
@@ -58,11 +60,14 @@ export function ThemedSelect({
const [highlighted, setHighlighted] = useState(value);
const selected = useRef(false);
const listRef = useRef(null);
+ const { mobile } = useLayoutPreferences();
+ useSystemBack(open && !disabled, () => setOpen(false), "popup");
useEffect(() => {
+ if (mobile) return;
const close = () => setOpen(false);
window.addEventListener("popstate", close);
return () => window.removeEventListener("popstate", close);
- }, []);
+ }, [mobile]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
diff --git a/web/src/systemBack.test.ts b/web/src/systemBack.test.ts
new file mode 100644
index 00000000..ca205995
--- /dev/null
+++ b/web/src/systemBack.test.ts
@@ -0,0 +1,295 @@
+import { afterAll, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import type { ReactNode } from "react";
+
+// History and popstate are global, and the hook installs a module-level
+// popstate listener; keep this DOM out of the shared test runner.
+if (process.env.ROAMGATE_SYSTEM_BACK_DOM_TEST !== "1") {
+ test("system Back regressions in an isolated runtime", async () => {
+ const child = Bun.spawn([process.execPath, "test", import.meta.path], {
+ env: { ...process.env, ROAMGATE_SYSTEM_BACK_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 {
+ await registerDomTests();
+}
+
+async function registerDomTests() {
+ 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,
+ Event: browser.Event,
+ CustomEvent: browser.CustomEvent,
+ localStorage: browser.localStorage,
+ sessionStorage: browser.sessionStorage,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ }))
+ Object.defineProperty(globalThis, key, {
+ value,
+ writable: true,
+ configurable: true,
+ });
+ afterAll(() => browser.happyDOM.abort());
+
+ const { act, createElement, Fragment, StrictMode } = await import("react");
+ const { createRoot } = await import("react-dom/client");
+ const { initializeLayoutPreferences, updateLayoutPreferences } = await import(
+ "./layoutPreferences"
+ );
+ // Model only standard history operations. Traversals complete on an event,
+ // never a wall-clock sleep (happy-dom does not implement session history).
+ let entries: { state: object | null; url: string }[] = [
+ { state: null, url: "/" },
+ ];
+ let index = 0;
+ const traversals: (() => void)[] = [];
+ const history = {
+ get state() {
+ return entries[index]!.state;
+ },
+ get length() {
+ return entries.length;
+ },
+ pushState(state: object | null, _unused: string, url?: string) {
+ entries = entries.slice(0, index + 1);
+ entries.push({ state, url: url ?? entries[index]!.url });
+ index++;
+ },
+ replaceState(state: object | null, _unused: string, url?: string) {
+ entries[index] = { state, url: url ?? entries[index]!.url };
+ },
+ back() {
+ move(-1);
+ },
+ forward() {
+ move(1);
+ },
+ };
+ function move(delta: number) {
+ traversals.push(() => {
+ const next = index + delta;
+ if (next < 0 || next >= entries.length) return;
+ index = next;
+ browser.dispatchEvent(
+ new browser.PopStateEvent("popstate", { state: history.state }),
+ );
+ });
+ }
+ Object.defineProperty(browser, "history", { value: history });
+ // Simulate a refresh starting on a guard, before the module is imported.
+ history.pushState(null, "", "/a");
+ history.pushState(null, "", "/b");
+ history.pushState({ roamgateBack: true }, "");
+ const { useSystemBack } = await import("./systemBack");
+
+ type Name = "sheet" | "composer" | "files";
+ const levels = { sheet: "popup", composer: "panel", files: "panel" } as const;
+ let open = new Set();
+ let closed: Name[] = [];
+ const container = document.createElement("div");
+ document.body.append(container);
+ let root = createRoot(container);
+
+ // Composer is a child of the sheet so React runs its effects first when
+ // both activate in one commit; stacking must not depend on that order.
+ function Layer({ name, children }: { name: Name; children?: ReactNode }) {
+ useSystemBack(
+ open.has(name),
+ () => {
+ closed.push(name);
+ show([...open].filter((candidate) => candidate !== name));
+ },
+ levels[name],
+ );
+ return createElement(Fragment, null, children);
+ }
+ function render() {
+ root.render(
+ createElement(
+ StrictMode,
+ null,
+ createElement(
+ Layer,
+ { name: "sheet" },
+ createElement(Layer, { name: "composer" }),
+ ),
+ createElement(Layer, { name: "files" }),
+ ),
+ );
+ }
+ function show(names: Name[]) {
+ open = new Set(names);
+ render();
+ }
+ const settle = async () => {
+ // Each traversal can schedule a React commit and one guard reconcile.
+ for (let turn = 0; turn < 8; turn++) {
+ await act(async () => {
+ await Promise.resolve();
+ for (const complete of traversals.splice(0)) complete();
+ });
+ if (!traversals.length) {
+ await Promise.resolve();
+ if (!traversals.length) return;
+ }
+ }
+ throw new Error("history traversal loop");
+ };
+ async function set(names: Name[]) {
+ await act(async () => show(names));
+ await settle();
+ }
+ async function userBack() {
+ browser.history.back();
+ await settle();
+ }
+ const guarded = () =>
+ typeof browser.history.state === "object" &&
+ browser.history.state !== null &&
+ "roamgateBack" in browser.history.state;
+ const at = () => entries[index]!.url;
+ // Every case starts on /b, reached from /a, with nothing open.
+ async function start() {
+ await set([]);
+ browser.history.pushState(null, "", "/a");
+ browser.history.pushState(null, "", "/b");
+ closed = [];
+ }
+
+ initializeLayoutPreferences();
+ updateLayoutPreferences({ mode: "mobile" });
+
+ test("refresh on an empty guard returns to its base without a duplicate Back stop", async () => {
+ await settle();
+ expect([at(), guarded()]).toEqual(["/b", false]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("one Back leaves the page after layers close together", async () => {
+ await start();
+ await set(["files", "composer"]);
+ expect(guarded()).toBe(true);
+ await set([]);
+ expect([at(), guarded()]).toEqual(["/b", false]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("Back closes one layer at a time, mixed with on-screen closes", async () => {
+ await start();
+ await set(["composer"]);
+ await set(["composer", "sheet"]);
+ await userBack();
+ expect(closed).toEqual(["sheet"]);
+ expect([open.has("composer"), guarded()]).toEqual([true, true]);
+ await set([]);
+ expect([at(), guarded()]).toEqual(["/b", false]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("the higher level closes first when layers register together", async () => {
+ await start();
+ await set(["sheet", "composer"]);
+ await act(async () => updateLayoutPreferences({ mode: "desktop" }));
+ await settle();
+ expect(guarded()).toBe(false);
+ await act(async () => updateLayoutPreferences({ mode: "mobile" }));
+ await settle();
+ await userBack();
+ expect(closed).toEqual(["sheet"]);
+ expect(open.has("composer")).toBe(true);
+ await set([]);
+ });
+
+ test("a layer opened while a Back is pending keeps its entry", async () => {
+ await start();
+ await set(["sheet"]);
+ await act(async () => show([]));
+ expect(traversals).toHaveLength(1);
+ await act(async () => show(["files"]));
+ await settle();
+ expect([open.has("files"), guarded(), at()]).toEqual([true, true, "/b"]);
+ await userBack();
+ expect(closed).toEqual(["files"]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("Forward at the end still lets one Back leave the empty guard", async () => {
+ await start();
+ await set(["composer"]);
+ await set([]);
+ const length = history.length;
+ history.forward();
+ await settle();
+ expect([at(), guarded(), history.length]).toEqual(["/b", true, length]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("Forward and Back cross an empty guard without trapping later entries", async () => {
+ await start();
+ await set(["composer"]);
+ // A later same-document entry can remain beyond the obsolete guard.
+ history.pushState({ later: true }, "", "/c");
+ await set([]);
+ await userBack();
+ expect([at(), guarded()]).toEqual(["/b", false]);
+ expect(entries[entries.length - 1]).toEqual({
+ state: { later: true },
+ url: "/c",
+ });
+ history.forward();
+ await settle();
+ expect(at()).toBe("/c");
+ await userBack();
+ expect([at(), guarded()]).toEqual(["/b", false]);
+ await userBack();
+ expect(at()).toBe("/a");
+ });
+
+ test("pagehide does not traverse during document departure", async () => {
+ await start();
+ await set(["composer"]);
+ browser.dispatchEvent(new browser.Event("pagehide"));
+ await act(async () => root.unmount());
+ await settle();
+ expect(guarded()).toBe(true);
+ browser.dispatchEvent(new browser.Event("pageshow"));
+ await settle();
+ expect(guarded()).toBe(false);
+ root = createRoot(container);
+ });
+
+ test("StrictMode unmount and layout remount remove an unused guard", async () => {
+ await start();
+ await set(["composer"]);
+ await act(async () => root.unmount());
+ await settle();
+ expect([guarded(), at()]).toEqual([false, "/b"]);
+ root = createRoot(container);
+ await act(async () => updateLayoutPreferences({ mode: "desktop" }));
+ await set(["composer"]);
+ expect(guarded()).toBe(false);
+ await userBack();
+ expect(at()).toBe("/a");
+ await act(async () => updateLayoutPreferences({ mode: "mobile" }));
+ await set([]);
+ });
+}
diff --git a/web/src/systemBack.ts b/web/src/systemBack.ts
new file mode 100644
index 00000000..553321de
--- /dev/null
+++ b/web/src/systemBack.ts
@@ -0,0 +1,167 @@
+import { useEffect, useRef } from "react";
+import { useLayoutPreferences } from "./layoutPreferences";
+
+/**
+ * How a layer stacks for the phone's Back: a panel (Composer, a mobile view
+ * such as Workspaces or Files) sits below a popup (the tab sheet). Back
+ * closes the highest open layer; equal levels close newest first.
+ */
+export type SystemBackLevel = "panel" | "popup";
+const LEVELS: Record = { panel: 0, popup: 1 };
+
+type Layer = { level: number; order: number; close: () => void };
+
+/**
+ * Lets the phone's Back gesture or button close an open mobile layer before
+ * it leaves the page. While any layer is open, the current history entry is
+ * one guard entry at the current URL. Back pops it and closes only the top
+ * layer; the guard returns while layers remain. When the last layer closes
+ * on screen, the guard is removed again, so the next Back leaves the page in
+ * one press.
+ */
+export function useSystemBack(
+ open: boolean,
+ close: () => void,
+ level: SystemBackLevel,
+) {
+ const { mobile } = useLayoutPreferences();
+ const closeRef = useRef(close);
+ closeRef.current = close;
+ const active = mobile && open;
+
+ useEffect(() => {
+ if (!active) return;
+ const layer: Layer = {
+ level: LEVELS[level],
+ order: ++openings,
+ close: () => closeRef.current(),
+ };
+ layers.add(layer);
+ scheduleReconcile();
+ return () => {
+ layers.delete(layer);
+ scheduleReconcile();
+ };
+ }, [active, level]);
+}
+
+const GUARD = "roamgateBack";
+const BASE = "roamgateBackBase";
+const layers = new Set();
+let openings = 0;
+let guards = 0;
+let previousState: unknown;
+let forwardBase: string | undefined;
+// A real document departure is not an on-screen React unmount.
+let leaving = false;
+let reconcileScheduled = false;
+// history.back() is asynchronous; until our own Back lands, the current entry
+// is stale and a popstate is ours, not the user's.
+let backPending = false;
+
+function isGuard(state: unknown) {
+ return typeof state === "object" && state !== null && GUARD in state;
+}
+
+function baseId(state: unknown): string | undefined {
+ if (typeof state !== "object" || state === null || !(BASE in state)) return;
+ const value = (state as Record)[BASE];
+ return typeof value === "string" ? value : undefined;
+}
+
+// Effects of one React commit run together; reconcile once after all of them,
+// so layers that open and close in the same commit settle as one change.
+function scheduleReconcile() {
+ if (reconcileScheduled) return;
+ reconcileScheduled = true;
+ queueMicrotask(reconcile);
+}
+
+function reconcile() {
+ reconcileScheduled = false;
+ if (backPending || leaving) return;
+ const guarded = isGuard(window.history.state);
+ if (layers.size > 0) forwardBase = undefined;
+ if (layers.size > 0 && !guarded) {
+ const state: unknown = window.history.state;
+ // Pair the entries so an obsolete guard can be crossed in either direction.
+ const base = {
+ ...(typeof state === "object" && state ? state : {}),
+ [BASE]: `${Date.now()}:${++guards}`,
+ };
+ window.history.replaceState(base, "");
+ window.history.pushState({ ...base, [GUARD]: true }, "");
+ previousState = window.history.state;
+ } else if (layers.size === 0 && guarded && forwardBase === undefined) {
+ backPending = true;
+ window.history.back();
+ }
+}
+
+if (typeof window !== "undefined") {
+ previousState = window.history.state;
+ window.addEventListener("pagehide", () => {
+ leaving = true;
+ forwardBase = undefined;
+ });
+ window.addEventListener("pageshow", () => {
+ leaving = false;
+ previousState = window.history.state;
+ scheduleReconcile();
+ });
+ // A restored/reloaded guard has no UI state of its own. Return to its base
+ // without pushing another entry or truncating valid Forward history.
+ scheduleReconcile();
+ window.addEventListener("popstate", (event) => {
+ const from = previousState;
+ previousState = event.state;
+ if (backPending) {
+ backPending = false;
+ reconcile();
+ return;
+ }
+ if (forwardBase !== undefined) {
+ const id = forwardBase;
+ forwardBase = undefined;
+ if (
+ layers.size === 0 &&
+ !isGuard(event.state) &&
+ baseId(event.state) === id
+ ) {
+ backPending = true;
+ window.history.back();
+ return;
+ }
+ }
+ const id = baseId(event.state);
+ if (
+ layers.size === 0 &&
+ isGuard(event.state) &&
+ id !== undefined &&
+ !isGuard(from) &&
+ baseId(from) === id
+ ) {
+ // Continue Forward past an empty guard. At the end, its next Back skips
+ // the paired base instead of consuming an extra press.
+ forwardBase = id;
+ window.history.forward();
+ return;
+ }
+ if (isGuard(event.state) || layers.size === 0) {
+ scheduleReconcile();
+ return;
+ }
+ let top: Layer | undefined;
+ for (const layer of layers)
+ if (
+ !top ||
+ layer.level > top.level ||
+ (layer.level === top.level && layer.order > top.order)
+ )
+ top = layer;
+ // The user's Back already removed the guard: nothing to undo.
+ layers.delete(top!);
+ top!.close();
+ scheduleReconcile();
+ });
+}