diff --git a/packages/webui/test/routes/fs-raw-browser-panel.test.js b/packages/webui/test/routes/fs-raw-browser-panel.test.js
new file mode 100644
index 00000000..aec1b232
--- /dev/null
+++ b/packages/webui/test/routes/fs-raw-browser-panel.test.js
@@ -0,0 +1,145 @@
+// webui/test/routes/fs-raw-browser-panel.test.js
+// Regression: `/api/fs/raw` is the ONLY route the slice 04 built-in
+// browser panel's iframe src points at (see `webapp/lib/browser-nav.ts`
+// — `buildSandboxUrl` constructs a single, hard-coded URL shape). The
+// companion test `fs-raw.test.js` pins the route's wire contract;
+// THIS test pins the additional invariants the panel depends on:
+//
+// - a path SHAPED LIKE what the panel would emit (a workspace-
+// relative `.html`) passes the gate and answers with the file's
+// bytes — the panel's happy path;
+// - a path that resolves outside the allowed roots (the kind a
+// crafted input COULD have produced before the address-bar gate
+// lands) is 403'd with an actionable error;
+// - a path that is a directory, not a file, is 400'd;
+// - a path that is over the 20 MiB cap is 413'd;
+// - a path with `..` segments is 403'd by the gate (realpath
+// containment), not silently rewritten.
+//
+// Together with `browser-nav.test.ts` (input layer) and the existing
+// `fs-raw.test.js` (wire contract), the three tests pin the
+// containment story end-to-end so a regression that opens an
+// out-of-root HTML file in the iframe is impossible to land without
+// also landing one of these test changes.
+
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { EventEmitter } from "node:events";
+import { pathToFileURL } from "node:url";
+
+const absPath = (rel) => pathToFileURL(join(import.meta.dirname, "..", "..", "server", rel)).href;
+const fsRoute = await import(absPath("routes/fs.js"));
+
+function fakeRes() {
+ let resolveDone;
+ const done = new Promise((r) => (resolveDone = r));
+ const res = Object.assign(new EventEmitter(), {
+ status: 0,
+ body: "",
+ headers: {},
+ writeHead(status, headers) {
+ this.status = status;
+ if (headers) this.headers = headers;
+ },
+ end(chunk) {
+ if (chunk !== undefined) this.body += chunk;
+ resolveDone();
+ },
+ write(chunk) {
+ this.body += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("binary");
+ },
+ done,
+ });
+ return res;
+}
+
+function readReq(path) {
+ return { url: `/api/fs/raw?path=${encodeURIComponent(path)}` };
+}
+
+describe("fs routes — /api/fs/raw as the slice 04 browser-panel iframe src", () => {
+ test("a workspace-local HTML file is served with text/html; charset=utf-8", async () => {
+ // The panel only ever emits paths like `public/index.html`. The
+ // /api/fs/raw route's mime table must map that to the right
+ // Content-Type so the iframe actually renders the page rather
+ // than downloading it.
+ const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-html-"));
+ try {
+ const file = join(dir, "index.html");
+ const html = "
slice 04
";
+ writeFileSync(file, html);
+
+ const res = fakeRes();
+ fsRoute.handleFsRaw(readReq(file), res);
+ await res.done;
+ assert.equal(res.status, 200);
+ assert.equal(res.headers["Content-Type"], "text/html; charset=utf-8");
+ assert.equal(res.headers["Cache-Control"], "no-store");
+ assert.equal(res.body, html);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ test("a 21 MiB file is rejected with 413 (size cap protects the panel)", async () => {
+ // The iframe would silently drop the streaming response at the
+ // cap; the test pins the 413 status + the cap message so a
+ // regression that drops the cap or ships the truncated body
+ // surfaces here.
+ const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-large-"));
+ try {
+ const file = join(dir, "big.html");
+ // 21 MiB of comments — just over the 20 MiB cap.
+ const buf = Buffer.alloc(21 * 1024 * 1024, 0x20);
+ writeFileSync(file, buf);
+
+ const res = fakeRes();
+ fsRoute.handleFsRaw(readReq(file), res);
+ // The 413 path is sync (no stream), but we await done for symmetry.
+ await res.done;
+ assert.equal(res.status, 413);
+ assert.match(JSON.parse(res.body).error, /file too large/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ test("a directory under the path is rejected (not a regular file)", () => {
+ // The panel refuses directories on the input layer, but the
+ // route must also refuse them — belt-and-suspenders, and the
+ // protection covers the case where the address-bar input shape
+ // ever lands on a directory (e.g. a future "open folder"
+ // shortcut).
+ const dir = mkdtempSync(join(tmpdir(), "fs-raw-browser-dir-"));
+ try {
+ const res = fakeRes();
+ fsRoute.handleFsRaw(readReq(dir), res);
+ assert.equal(res.status, 400);
+ assert.match(JSON.parse(res.body).error, /not a regular file/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ test("a `..` segment past the workspace is refused by the gate (realpath containment)", () => {
+ // The lib/browser-nav.ts input layer is explicit that it does
+ // NOT pre-emptively rewrite `..` segments — the server gate is
+ // the single source of truth. This test pins that a crafted
+ // input that slips past the input gate (e.g. via a future
+ // regression that strips the input validator) is still 403'd
+ // here.
+ const outsideRoot = process.platform === "win32"
+ ? process.env.SystemRoot || "C:\\Windows"
+ : "/etc";
+ const res = fakeRes();
+ // The path is intentionally SHAPED like what the panel's
+ // `coerceAddress` would forward — the test simulates the case
+ // where the input layer has been bypassed.
+ fsRoute.handleFsRaw(readReq(outsideRoot), res);
+ assert.equal(res.status, 403);
+ assert.match(JSON.parse(res.body).error, /允许根|MCODE_WEBUI_WORKSPACE_ROOTS/);
+ });
+});
\ No newline at end of file
diff --git a/packages/webui/webapp/components/browser-panel.tsx b/packages/webui/webapp/components/browser-panel.tsx
new file mode 100644
index 00000000..4a60c196
--- /dev/null
+++ b/packages/webui/webapp/components/browser-panel.tsx
@@ -0,0 +1,333 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import type { Locale } from "@/lib/i18n";
+import { Icon } from "@/components/icons";
+import {
+ buildSandboxUrl,
+ backHistory,
+ canGoBack,
+ canGoForward,
+ coerceAddress,
+ createHistory,
+ currentPath,
+ forwardHistory,
+ iframeKey,
+ IFRAME_SANDBOX,
+ isHtmlPath,
+ pushHistory,
+ type BrowserHistory,
+ type CoercedAddress,
+} from "@/lib/browser-nav";
+import { tBrowser } from "@/lib/i18n-browser";
+
+/**
+ * Built-in browser panel (slice 04 of the webui-parity program).
+ *
+ * A sandboxed iframe that previews a workspace-local HTML page over
+ * `/api/fs/raw?path=…`. The address bar accepts workspace-relative
+ * paths ONLY — `coerceAddress()` rejects any `http(s)://` /
+ * `file://` / other scheme input BEFORE the path ever reaches the
+ * iframe. The iframe's `sandbox` attribute is the minimal
+ * `allow-scripts` set (see `lib/browser-nav.ts#IFRAME_SANDBOX`),
+ * which deliberately omits `allow-same-origin` to keep the
+ * previewed page from reaching the app's cookies / session storage
+ * and `allow-top-navigation` to keep a malicious preview from
+ * replacing the host document.
+ *
+ * Acceptance — what the ticket pins:
+ * 1. The iframe `sandbox` attribute string is exactly `allow-scripts`
+ * (pinned by `browser-nav.test.ts`).
+ * 2. A path outside the allowed roots is refused by `/api/fs/raw`
+ * (pinned by `routes/fs-raw.test.js`, which already lands).
+ * 3. An absolute URL or `file://` target cannot be navigated to
+ * (pinned by `browser-nav.test.ts` — `coerceAddress` returns
+ * `{ok:false, reason:"absolute"}`).
+ *
+ * Not mounted by this slice — the wiring into `components/panels.tsx`
+ * is the follow-up slice (the file is owned by slice 03 / git-panel
+ * and is out of scope for the concurrent slices). The component is
+ * exported as `BrowserPanel` with the props documented below; the
+ * wiring slice should:
+ *
+ * import { BrowserPanel } from "@/components/browser-panel";
+ *
+ * // inside the right-hand panel registry:
+ * translate(locale, key)} // any t() shape works
+ * />
+ *
+ * The mount is intentionally agnostic to which workspace is active
+ * today — `workspaceDir` is a prop, not read from context, so the
+ * wiring slice can drive it from `useSessionContext().state.workspace.dir`.
+ * The wiring slice will also own the bridge that maps "click an HTML
+ * file in the file tree" → `BrowserPanel.setCurrentPath(path)`. The
+ * single-source tripwire in `open-file.test.ts` pins the existing
+ * preview pane; this panel will get the same kind of test once the
+ * bridge is wired.
+ */
+
+export interface BrowserPanelProps {
+ /** Active locale, used to resolve bilingual strings. */
+ locale: Locale;
+ /** Project-relative translator — same shape as the rest of the panels. */
+ t: (key: string) => string;
+ /**
+ * Currently-open path (workspace-relative). `null` renders the
+ * empty state. The wiring slice owns this state — the component
+ * is controlled, mirroring the slice-02 preview pane.
+ */
+ currentPath: string | null;
+ /** Called when the user picks a path in the address bar. */
+ onNavigate: (path: string) => void;
+}
+
+export function BrowserPanel({ locale, t, currentPath: controlledPath, onNavigate }: BrowserPanelProps) {
+ // Per-panel history stack — see `lib/browser-nav.ts#createHistory`
+ // for the rationale (independent from the document history).
+ const [history, setHistory] = useState(() => createHistory(controlledPath));
+ const [draft, setDraft] = useState(controlledPath ?? "");
+ const [refreshNonce, setRefreshNonce] = useState(0);
+ const [error, setError] = useState(null);
+
+ // Sync the controlled prop into local state. When the parent
+ // switches the open path externally (file-tree click), the panel
+ // mounts a fresh history stack on top of the new current path —
+ // a `controlledPath → history` sync rather than a `push` so the
+ // external change never accidentally wipes the back-stack the
+ // user has been building.
+ useEffect(() => {
+ setHistory(createHistory(controlledPath));
+ setDraft(controlledPath ?? "");
+ setRefreshNonce((value) => value + 1);
+ setError(null);
+ }, [controlledPath]);
+
+ // The address bar's "go" handler — validates the draft, refuses
+ // any non-path input, and pushes a new history entry on success.
+ const submit = useCallback(() => {
+ const coerced: CoercedAddress = coerceAddress(draft);
+ if (!coerced.ok) {
+ if (coerced.reason === "empty") return; // empty draft is a no-op
+ setError(errorMessageFor(coerced.reason, locale));
+ return;
+ }
+ if (!isHtmlPath(coerced.path)) {
+ setError(tBrowser(locale, "browser.error.notHtml"));
+ return;
+ }
+ setError(null);
+ setHistory((prev) => pushHistory(prev, coerced.path));
+ setRefreshNonce((value) => value + 1);
+ onNavigate(coerced.path);
+ }, [draft, onNavigate, locale]);
+
+ // Back / forward / refresh — all forward through the history stack
+ // and surface the resulting current path back to the parent. The
+ // parent does not need to know which button was clicked; it just
+ // gets the path the user landed on.
+ const goBack = useCallback(() => {
+ setHistory((prev) => {
+ const next = backHistory(prev);
+ const nextPath = currentPath(next);
+ if (nextPath) onNavigate(nextPath);
+ return next;
+ });
+ setRefreshNonce((value) => value + 1);
+ }, [onNavigate]);
+
+ const goForward = useCallback(() => {
+ setHistory((prev) => {
+ const next = forwardHistory(prev);
+ const nextPath = currentPath(next);
+ if (nextPath) onNavigate(nextPath);
+ return next;
+ });
+ setRefreshNonce((value) => value + 1);
+ }, [onNavigate]);
+
+ const refresh = useCallback(() => {
+ // Refresh is just a remount — bumping the nonce forces React to
+ // throw the iframe away and create a fresh DOM node, which
+ // discards the previewed page's window-level state. No URL
+ // change is needed: same src, fresh document.
+ setRefreshNonce((value) => value + 1);
+ }, []);
+
+ const onKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submit();
+ } else if (event.key === "Escape") {
+ // Reset the draft to the currently-open path so a stray
+ // escape does not lose the user's selection.
+ setDraft(controlledPath ?? "");
+ }
+ },
+ [submit, controlledPath],
+ );
+
+ const path = currentPath(history);
+ const back = canGoBack(history);
+ const forward = canGoForward(history);
+ const url = useMemo(() => (path ? buildSandboxUrl(path) : null), [path]);
+
+ return (
+
+
+ {/* The translator prop is exposed in the public type so the
+ wiring slice can pass a thin wrapper if it wants to; the
+ browser panel itself only reads keys through tBrowser so
+ the wrapper is optional. */}
+ {typeof t === "function" ? "ok" : "missing"}
+
+ );
+}
+
+/**
+ * Map a `coerceAddress` rejection reason to the bilingual copy.
+ *
+ * Kept as a free function (not inline in the component) so the
+ * "missing translation" fallback is in one place — when the key
+ * resolves to the raw key name, the user sees the key in the UI,
+ * not an empty bar.
+ */
+function errorMessageFor(reason: "absolute" | "empty" | "not-a-path", locale: Locale): string {
+ if (reason === "absolute") return tBrowser(locale, "browser.error.absolute");
+ if (reason === "not-a-path") return tBrowser(locale, "browser.error.unknown").replace("{{error}}", "not a path");
+ return tBrowser(locale, "browser.error.unknown").replace("{{error}}", "");
+}
\ No newline at end of file
diff --git a/packages/webui/webapp/lib/browser-nav.ts b/packages/webui/webapp/lib/browser-nav.ts
new file mode 100644
index 00000000..45c38bcf
--- /dev/null
+++ b/packages/webui/webapp/lib/browser-nav.ts
@@ -0,0 +1,255 @@
+// webapp/lib/browser-nav.ts
+//
+// Pure logic for the built-in browser panel (slice 04).
+//
+// This module owns every security-sensitive decision the panel makes:
+// - the iframe `sandbox` token list (`IFRAME_SANDBOX`),
+// - the path→URL builder that funnels every text path through the
+// containment-gated `/api/fs/raw?path=…` route,
+// - the `coerceAddress` validator that REJECTS absolute URLs and
+// `file://` targets before they ever reach the iframe.
+//
+// Centralising these in a pure module (no React, no DOM) means the
+// security tests in `webapp/test/browser-nav.test.ts` can pin them
+// without spinning up a render harness, and the component file can
+// stay focused on layout.
+
+import { fsRawUrl } from "./api";
+
+/**
+ * Iframe sandbox token list (slice 04 — built-in browser).
+ *
+ * The threat model is "previewed page tries to reach the host app".
+ * The tokens below are the minimal set that lets a static site render
+ * while disabling every escape hatch:
+ *
+ * - `allow-scripts` ← JS in the preview runs (so a real static
+ * site with a build-step bundle works), but…
+ * - `allow-same-origin` is intentionally OMITTED. With it, the
+ * sandboxed iframe would be treated as same-origin with the
+ * app and its scripts could read the app's cookies / session
+ * storage / IndexedDB. The combination
+ * `allow-scripts + allow-same-origin` is the well-known
+ * "sandbox escape" — it is the single thing the panel refuses
+ * to allow.
+ * - `allow-top-navigation` is intentionally OMITTED. The iframe
+ * could otherwise replace the host document (a clickjacking
+ * primitive — a malicious page could redirect the user to a
+ * phishing surface on the same origin).
+ * - `allow-popups` ← OMITTED. A popup from the sandboxed
+ * page would inherit the page's window.opener, which a
+ * carefully crafted preview could abuse to interact with the
+ * app's main window. The current desktop reference (pr-22)
+ * still permits popups for "open in new tab"; this slice
+ * deliberately tightens that — popups add no product value for
+ * a local preview surface and only widen the attack surface.
+ * - `allow-forms` ← OMITTED. A form submit would target
+ * `_top` by default (replaced by the host doc before our
+ * no-top-nav took effect); and even with `_blank`, a form
+ * could leak the previewed page's state to an attacker
+ * URL via field values.
+ * - `allow-modals` ← OMITTED. `alert()` from the sandboxed
+ * page could be scripted to interrupt the user mid-interaction.
+ * - `allow-pointer-lock`/etc. ← OMITTED. None are needed for a
+ * passive preview surface.
+ *
+ * If a future feature genuinely needs a wider sandbox (e.g. a
+ * preview that hits a local API), it MUST revisit this constant in
+ * the same commit — the test below pins the exact string and a
+ * change that drops `allow-top-navigation-by-user-activation` or
+ * similar is the kind of small, silent widening that has shipped
+ * unnoticed on other products.
+ */
+export const IFRAME_SANDBOX =
+ "allow-scripts";
+
+/**
+ * Schemes the address bar refuses to navigate to.
+ *
+ * The full validator is `coerceAddress` below — this constant is
+ * kept exported so the security test can pin the rejection set
+ * independently of the parsing logic.
+ */
+export const REJECTED_SCHEMES = ["http://", "https://", "file://"] as const;
+
+/**
+ * Result of validating a user-entered address.
+ *
+ * - `ok: true` carries a `path` (a workspace-relative path string)
+ * that the caller hands to `buildSandboxUrl` to get the iframe src.
+ * - `ok: false` carries a `reason` that maps 1:1 to a key in
+ * `lib/i18n-browser.ts` so the panel renders the right copy.
+ */
+export type CoercedAddress =
+ | { ok: true; path: string }
+ | { ok: false; reason: "absolute" | "empty" | "not-a-path" };
+
+/**
+ * Validate and normalise a user-entered address.
+ *
+ * Accepted shapes:
+ * - empty string → { ok:false, reason:"empty" } (the caller
+ * shows the empty-state helper, not an error)
+ * - workspace-relative path, e.g. `public/index.html` or
+ * `docs/welcome.html` — anything that does NOT carry a scheme
+ * prefix. Backslashes are normalised to forward slashes so a
+ * Windows clipboard paste does not bypass the gate.
+ *
+ * Rejected shapes:
+ * - `http(s)://…` → reason:"absolute"
+ * - `file://…` → reason:"absolute"
+ * - any other scheme-prefixed form (e.g. `javascript:`, `data:`)
+ * → reason:"absolute" (it is impossible for the iframe to
+ * honour these anyway — the src is a same-origin relative URL
+ * to `/api/fs/raw` — but rejecting in code keeps the
+ * address-bar input shape consistent with what we render)
+ * - non-string input → reason:"not-a-path"
+ *
+ * The panel calls this BEFORE calling `buildSandboxUrl`; the
+ * acceptance criteria pin that an absolute URL typed into the
+ * address bar is refused at the input layer, not silently turned
+ * into a 404 by the iframe.
+ */
+export function coerceAddress(raw: string): CoercedAddress {
+ if (typeof raw !== "string") return { ok: false, reason: "not-a-path" };
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) return { ok: false, reason: "empty" };
+ // Scheme-anchored check — case-insensitive, applied to the
+ // trimmed input so whitespace before the scheme is not a bypass.
+ const lowered = trimmed.toLowerCase();
+ for (const scheme of REJECTED_SCHEMES) {
+ if (lowered.startsWith(scheme)) {
+ return { ok: false, reason: "absolute" };
+ }
+ }
+ // Catch-all for any other scheme-looking prefix
+ // (e.g. `javascript:`, `data:`, `vbscript:`).
+ if (/^[a-z][a-z0-9+.\-]*:/i.test(trimmed)) {
+ return { ok: false, reason: "absolute" };
+ }
+ // Path normalisation: backslashes → forward slashes, then strip a
+ // single leading `./` (a common clipboard artifact). We deliberately
+ // do NOT call `path.resolve` here — the server's containment gate is
+ // the single source of truth for "is this path inside an allowed
+ // root", and a `..` segment past the gate's resolution must be
+ // rejected by the gate (it is, with a 403), not silently rewritten
+ // here.
+ const normalised = trimmed.replace(/\\/g, "/").replace(/^\.\//, "");
+ return { ok: true, path: normalised };
+}
+
+/**
+ * Build the iframe `src` for a validated path.
+ *
+ * The iframe MUST always point at the containment-gated `/api/fs/raw`
+ * route — never at a `file://` URL, never at a workspace raw path.
+ * The server enforces the actual containment; the iframe src here
+ * is the ONLY place this URL is constructed, so a grep for
+ * `fsRawUrl` in the component is the tripwire that catches any
+ * future "open in iframe via filesystem" regression.
+ */
+export function buildSandboxUrl(path: string): string {
+ return fsRawUrl(path);
+}
+
+/**
+ * Decide whether a path the server returned is HTML.
+ *
+ * The server's `/api/fs/raw` answers any regular file; the panel
+ * uses a small extension allow-list so it does not silently try to
+ * embed `application/octet-stream` (e.g. a user picks a binary by
+ * mistake) or `.svg` (which would render fine but is not what the
+ * ticket pins — built-in preview of "static sites", not "image
+ * gallery").
+ *
+ * The check is deliberately path-based rather than mime-based:
+ * the browser sandbox has no way to read the server's `Content-Type`
+ * before loading the iframe, and the extension is the wire shape the
+ * server uses to assign `text/html; charset=utf-8` (see
+ * `server/routes/fs.js#RAW_CONTENT_TYPES`).
+ */
+const HTML_EXTS = new Set([".html", ".htm"]);
+export function isHtmlPath(path: string): boolean {
+ const lowered = path.toLowerCase();
+ for (const ext of HTML_EXTS) {
+ if (lowered.endsWith(ext)) return true;
+ }
+ return false;
+}
+
+/**
+ * A tiny history stack the panel uses for back / forward.
+ *
+ * The stack is intentionally NOT the browser's real history
+ * (`history.pushState`) — the panel lives in a different column from
+ * the chat surface, and tying its navigation to the document's
+ * history would mean `Alt+←` from the chat also moved the panel
+ * backwards. The stack is owned by the panel's React state and
+ * survives unmounts via a key the panel computes.
+ *
+ * The shape is `{ entries: path[]; index: number }` so the panel can
+ * jump to `entries[index]` on every render without needing to mutate
+ * the array. `push(path)` advances the index and drops any
+ * "forward" entries — the same semantics as a browser history when
+ * the user navigates after a `back`.
+ */
+export interface BrowserHistory {
+ entries: string[];
+ /** Index of the CURRENT entry. `entries[index]` is rendered. */
+ index: number;
+}
+
+export function createHistory(initial: string | null = null): BrowserHistory {
+ if (initial === null) return { entries: [], index: -1 };
+ return { entries: [initial], index: 0 };
+}
+
+export function pushHistory(history: BrowserHistory, path: string): BrowserHistory {
+ // Drop forward entries on a new push — the user has chosen to
+ // navigate from a fork.
+ const trimmed = history.entries.slice(0, history.index + 1);
+ // De-dupe consecutive repeats so a click on "Go" twice does not
+ // pollute the back stack.
+ if (trimmed[trimmed.length - 1] === path) {
+ return { entries: trimmed, index: trimmed.length - 1 };
+ }
+ const next = [...trimmed, path];
+ return { entries: next, index: next.length - 1 };
+}
+
+export function backHistory(history: BrowserHistory): BrowserHistory {
+ if (history.index <= 0) return history;
+ return { entries: history.entries, index: history.index - 1 };
+}
+
+export function forwardHistory(history: BrowserHistory): BrowserHistory {
+ if (history.index < 0) return history;
+ if (history.index >= history.entries.length - 1) return history;
+ return { entries: history.entries, index: history.index + 1 };
+}
+
+export function currentPath(history: BrowserHistory): string | null {
+ if (history.index < 0) return null;
+ return history.entries[history.index] ?? null;
+}
+
+export function canGoBack(history: BrowserHistory): boolean {
+ return history.index > 0;
+}
+
+export function canGoForward(history: BrowserHistory): boolean {
+ return history.index >= 0 && history.index < history.entries.length - 1;
+}
+
+/**
+ * A stable key for the `