From 422400369d824e1c941ee1cc495e3137ae659cfd Mon Sep 17 00:00:00 2001 From: Tran Ngoc Bao Date: Mon, 5 Oct 2026 09:51:11 +0700 Subject: [PATCH] feat(nav): add in-app navigate back/forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-style back/forward over visited chat views on omp-web's own stack (not the browser history, which belongs to the mobile back-gesture machinery). ⌘[/⌘] on macOS, Alt+←/→ on Windows/Linux, plus the mouse back/forward buttons; sidebar header buttons hide below a 240px sidebar, and an exhausted stack swallows the keystroke so the app is never backed out of. Deleted sessions are skipped; ?session= URL handling is unchanged. --- AGENTS.md | 33 +++ app/globals.css | 20 ++ components/AppShell.tsx | 99 ++++++++- components/SessionSidebar.navigation.test.mjs | 80 ++++++++ components/SessionSidebar.tsx | 38 +++- hooks/useKeyboardShortcuts.ts | 32 ++- hooks/useNavigationHistory.test.mjs | 108 ++++++++++ hooks/useNavigationHistory.ts | 89 +++++++++ lib/i18n/locales/en.json | 2 + lib/i18n/locales/ja.json | 2 + lib/i18n/locales/zh-CN.json | 2 + lib/navigation-history.test.mjs | 189 ++++++++++++++++++ lib/navigation-history.ts | 152 ++++++++++++++ 13 files changed, 839 insertions(+), 7 deletions(-) create mode 100644 components/SessionSidebar.navigation.test.mjs create mode 100644 hooks/useNavigationHistory.test.mjs create mode 100644 hooks/useNavigationHistory.ts create mode 100644 lib/navigation-history.test.mjs create mode 100644 lib/navigation-history.ts diff --git a/AGENTS.md b/AGENTS.md index f3d5dc4d6..9ec914cfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ lib/ tool-presets.ts PRESET_NONE/DEFAULT/FULL + getToolNamesForPreset() types.ts shared TypeScript types normalize.ts normalizeToolCalls() — field name mismatch between file format and our types + navigation-history.ts pure back/forward view-history stack + shortcut matcher (⌘[/⌘], Alt+←/→) word-prediction.ts pure ghost-text arithmetic (advance/accept) for composer word prediction worktree.ts project/worktree resolution and git worktree operations @@ -147,6 +148,7 @@ hooks/ useIsMobile.ts responsive breakpoint hook usePrefersReducedMotion.ts OS reduce-motion preference (SMIL-safe) useTheme.ts theme state (localStorage key "omp-theme") + useNavigationHistory.ts in-app back/forward stack (record/peek/commit/drop) for visited chat views useWordPrediction.ts debounced omp predict_word ghost text + feedback useBtw.ts /btw records/active panel/history dialog fed by btw_* SSE frames ``` @@ -495,6 +497,37 @@ during the wait. - The Models panel reads and writes `models.yml` in the omp agent directory (`~/.omp/agent/models.yml`, `.yaml` fallback). - API-key status endpoints must never return the raw key. +### Navigate back / forward (`lib/navigation-history.ts`, `hooks/useNavigationHistory.ts`) +- Browser-style back/forward over visited chat views (sessions + the new-chat + composer), in-memory per page load. It is **omp-web's own stack**, never the + browser History API — the app only ever `router.replace`s `?session=`, and + the real history stack belongs to the mobile back-gesture / exit-guard + machinery (`useSidebarHistory` + the popstate bridge). +- AppShell records views from one effect keyed on + `(selectedSession?.id, newSessionCwd)`: recording the entry the cursor + already sits on is a no-op, which is what makes back/forward + self-suppressing — `navigateInHistory` commits the step, the view lands, the + effect re-records the target, nothing is pushed. Any other view change + (sidebar/palette select, new chat, session created, fork, project-switch + close) pushes normally and truncates the forward branch, browser-style. +- Applying a step: peek → resolve the session id via `/api/sessions` → + commit + `handleSelectSession`, or `handleNewSession` for new-chat entries. + A dead id (deleted session) drops that entry and tries the next one in the + same direction; a failed list fetch aborts without dropping. A view change + during the await (versioned ref) aborts the navigation so a slow fetch + never yanks the chat away. +- Shortcuts live in `useGlobalKeyboardShortcuts`: ⌘[/⌘] (macOS standard), + Alt+←/Alt+→ (Windows/Linux standard; on macOS Alt+Arrow stays free — it is + word-wise caret movement), plus the mouse back/forward buttons + (`BrowserBack`/`BrowserForward`). The keystroke is always swallowed while a + handler is registered — an exhausted stack stops dead rather than falling + through to the browser's own back/forward, so the app is never backed out + of by accident — and shortcuts are skipped entirely while a + `[role="dialog"]` modal is open. The sidebar header buttons (before Archived + Sessions, wrapped in `.sidebar-nav-buttons`) disable on stack bounds, show + the platform shortcut in their tooltip, and hide below a 240px sidebar via + the `.sidebar-shell` container query (the keyboard shortcuts still work). + ### Composer word prediction (`hooks/useWordPrediction.ts`, `components/GhostMirror.tsx`) - Ghost text comes from omp's `predict_word` RPC (engine = omp's `spelling.autocomplete` setting; omp applies the prose gates). Tab or → diff --git a/app/globals.css b/app/globals.css index 7029118c8..dd736d42e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1633,6 +1633,26 @@ pre, code { outline-offset: -2px; } +/* The sidebar is its own inline-size container so the header can adapt to the + user-adjustable width. Below ~240px the title plus five header icons no + longer fit, so the navigate back/forward buttons drop out first (they stay + reachable via ⌘[/⌘] / Alt+←/→); Archive/Import/Refresh are kept — with the + nav pair hidden, the header fits down to the 200px minimum. The wrapper's + display/gap live here, NOT as an inline style — an inline display:flex + would out-rank the container query's display:none and break the hiding. */ +.sidebar-shell { + container-type: inline-size; +} +.sidebar-nav-buttons { + display: flex; + gap: 2px; +} +@container (max-width: 240px) { + .sidebar-nav-buttons { + display: none; + } +} + /* Sidebar overflow menus: compact items with a subtle warm hover highlight. */ .sidebar-menu-item { display: block; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 0d8559893..a2bb12e2a 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1,11 +1,13 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useLayoutEffect } from "react"; +import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from "react"; import dynamic from "next/dynamic"; import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useSidebarHistory } from "@/hooks/useSidebarHistory"; import { useModalDialog } from "@/hooks/useModalDialog"; +import { useNavigationHistory } from "@/hooks/useNavigationHistory"; +import { isMacPlatform, navigateShortcutHint, NAVIGATION_HISTORY_MAX_ENTRIES } from "@/lib/navigation-history"; import { SessionSidebar } from "./SessionSidebar"; import { ToastProvider } from "./ui/toast"; import { toast } from "./ui/toast"; @@ -1170,7 +1172,7 @@ export function AppShell() { } }, [router, isMobile, selectedSession?.id]); - const handleNewSession = useCallback((_sessionId: string, cwd: string) => { + const handleNewSession = useCallback((_sessionId: string, cwd: string | null) => { setSettingsTab(null); setSelectedSession(null); setNewSessionCwd(cwd); @@ -1187,10 +1189,102 @@ export function AppShell() { router.replace("/", { scroll: false }); }, [router, isMobile]); + // ---- In-app Navigate back / forward over visited chat views ---- + // Own stack, not the browser History API (that one belongs to the mobile + // back-gesture/exit-guard machinery — see lib/navigation-history.ts). + const navigationHistory = useNavigationHistory(); + const { + record: recordNavigationView, + peekBack: peekNavigationBack, + peekForward: peekNavigationForward, + } = navigationHistory; + // Bumped by the recording effect on every view change. navigateInHistory + // captures it across its await and bails if the user moved on meanwhile, + // so a slow session-list fetch can never yank the chat out from under a + // view the user just switched to. + const navViewVersionRef = useRef(0); + const navSessionId = selectedSession?.id ?? null; + const navSessionCwd = selectedSession?.cwd ?? null; + useEffect(() => { + navViewVersionRef.current += 1; + recordNavigationView({ + sessionId: navSessionId, + cwd: navSessionId ? navSessionCwd : newSessionCwd, + }); + }, [navSessionId, navSessionCwd, newSessionCwd, recordNavigationView]); + + // Resolves a history entry's session id against the live list. `null` + // means the id is gone (entry should be dropped); "unavailable" means the + // list itself could not be fetched (keep the entry, abort this navigation). + const fetchSessionForNavigation = useCallback(async (sessionId: string): Promise => { + try { + const res = await fetch("/api/sessions"); + if (!res.ok) return "unavailable"; + const data = (await res.json()) as { sessions?: SessionInfo[] }; + return data.sessions?.find((s) => s.id === sessionId) ?? null; + } catch { + return "unavailable"; + } + }, []); + + const navigateInHistory = useCallback(async (direction: -1 | 1): Promise => { + // Dead entries (deleted sessions) are skipped, like browser history + // skipping a dead slot; bound the loop by the stack's own cap. + for (let attempt = 0; attempt <= NAVIGATION_HISTORY_MAX_ENTRIES; attempt++) { + const entry = direction === -1 ? peekNavigationBack() : peekNavigationForward(); + // Nothing that way: stay put. An exhausted stack never falls through to + // the browser's own back/forward — the keystroke is swallowed by the + // keyboard layer, so the app can never be backed out of by accident. + if (!entry) return; + if (entry.sessionId === null) { + if (direction === -1) navigationHistory.commitBack(); + else navigationHistory.commitForward(); + handleNewSession("navigate-history", entry.cwd); + return; + } + const versionAtPress = navViewVersionRef.current; + const session = await fetchSessionForNavigation(entry.sessionId); + if (navViewVersionRef.current !== versionAtPress) return; // user moved on: do nothing + if (session === "unavailable") return; // list fetch failed: keep the entry, try again next press + if (session) { + if (direction === -1) navigationHistory.commitBack(); + else navigationHistory.commitForward(); + handleSelectSession(session, false); + return; + } + if (direction === -1) navigationHistory.dropPeekedBack(); + else navigationHistory.dropPeekedForward(); + } + }, [navigationHistory, peekNavigationBack, peekNavigationForward, fetchSessionForNavigation, handleNewSession, handleSelectSession]); + + // Shared by the sidebar buttons and the global shortcuts. The keyboard + // layer always swallows the keystroke, so both directions stop dead at the + // ends of the stack instead of continuing into browser history. + const handleNavigateBack = useCallback(() => { + void navigateInHistory(-1); + }, [navigateInHistory]); + const handleNavigateForward = useCallback(() => { + void navigateInHistory(1); + }, [navigateInHistory]); + + // Tooltip shortcut labels follow the platform (⌘[ vs Alt+←). Computed from + // navigator after mount state to keep SSR markup stable. + const [navIsMac] = useState(() => isMacPlatform()); + const sidebarNavigation = useMemo(() => ({ + canBack: navigationHistory.canBack, + canForward: navigationHistory.canForward, + onBack: handleNavigateBack, + onForward: handleNavigateForward, + backShortcut: navigateShortcutHint(-1, navIsMac), + forwardShortcut: navigateShortcutHint(1, navIsMac), + }), [navigationHistory.canBack, navigationHistory.canForward, handleNavigateBack, handleNavigateForward, navIsMac]); + // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) useGlobalKeyboardShortcuts({ onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd), + onNavigateBack: handleNavigateBack, + onNavigateForward: handleNavigateForward, activeCwd, scopeNativeSelectAll, }); @@ -1647,6 +1741,7 @@ export function AppShell() { settingsOpen={Boolean(settingsTab)} onOpenSettings={() => setSettingsTab((prev) => prev ? null : "general")} onOpenArchive={() => setArchiveBrowserOpen(true)} + navigation={sidebarNavigation} updateAvailable={Boolean(appUpdate?.updateAvailable) || ompUpdateAvailable} /> ); diff --git a/components/SessionSidebar.navigation.test.mjs b/components/SessionSidebar.navigation.test.mjs new file mode 100644 index 000000000..57f8e179d --- /dev/null +++ b/components/SessionSidebar.navigation.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { createJiti } from "jiti"; + +// The navigate back/forward buttons live in the sidebar header, first in the +// quiet-utilities group (before Archived Sessions) and disabled per the +// history stack's bounds. AppShell always passes the `navigation` prop; the +// undefined case only covers any future host that renders the sidebar +// standalone. + +const jiti = createJiti(import.meta.url, { + jsx: { runtime: "automatic" }, + tsconfigPaths: true, +}); +const { SessionSidebar } = await jiti.import("./SessionSidebar.tsx"); + +const noop = () => {}; +const baseProps = { + selectedSessionId: null, + onSelectSession: noop, + addProjectOpen: false, + setAddProjectOpen: noop, + onOpenArchive: noop, +}; + +const navigation = (canBack, canForward) => ({ + canBack, + canForward, + onBack: noop, + onForward: noop, + backShortcut: "⌘[", + forwardShortcut: "⌘]", +}); + +/** The full opening tag of the button carrying this aria-label. */ +function buttonTagFor(html, label) { + const match = html.match(new RegExp(`]*aria-label="${label}"[^>]*>`)); + assert.ok(match, `button with label ${label} renders`); + return match[0]; +} + +test("renders back/forward buttons before Archived Sessions, disabled per stack bounds", () => { + const html = renderToStaticMarkup(React.createElement(SessionSidebar, { + ...baseProps, + navigation: navigation(false, true), + })); + + const back = html.indexOf('aria-label="Navigate back"'); + const forward = html.indexOf('aria-label="Navigate forward"'); + const archive = html.indexOf('aria-label="Archived Sessions"'); + assert.ok(back > 0, "navigate-back button renders"); + assert.ok(forward > 0, "navigate-forward button renders"); + assert.ok(back < archive && forward < archive, "navigation buttons sit before Archived Sessions"); + // Both buttons live inside the .sidebar-nav-buttons wrapper that the + // .sidebar-shell container query hides on narrow sidebars. + const wrapper = html.indexOf('class="sidebar-nav-buttons"'); + assert.ok(wrapper > 0, "nav buttons render inside their collapsible wrapper"); + assert.ok(wrapper < back && wrapper < forward, "wrapper opens before both buttons"); + + // canBack=false disables back; canForward=true keeps forward enabled. + assert.match(buttonTagFor(html, "Navigate back"), /disabled/); + assert.doesNotMatch(buttonTagFor(html, "Navigate forward"), /disabled/); +}); + +test("both buttons enable when the stack allows travel in both directions", () => { + const html = renderToStaticMarkup(React.createElement(SessionSidebar, { + ...baseProps, + navigation: navigation(true, true), + })); + assert.doesNotMatch(buttonTagFor(html, "Navigate back"), /disabled/); + assert.doesNotMatch(buttonTagFor(html, "Navigate forward"), /disabled/); +}); + +test("no navigation buttons without the navigation prop", () => { + const html = renderToStaticMarkup(React.createElement(SessionSidebar, baseProps)); + assert.equal(html.indexOf('aria-label="Navigate back"'), -1); + assert.equal(html.indexOf('aria-label="Navigate forward"'), -1); +}); diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index fac7da5c2..7c40192ec 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -12,7 +12,7 @@ import { toast } from "./ui/toast"; import { clearLastOpenSession, setLastOpenSession, workspaceKeyOf } from "@/lib/workspace-memory"; import { groupSessionsByProject, projectActivityCounts, sortManagedProjects } from "@/lib/project-ordering"; import { comparableProjectPath } from "@/lib/comparable-path"; -import { Archive, Check, ChevronRight, FileUp, Plus, RefreshCw, Search, Settings2, SlidersHorizontal } from "lucide-react"; +import { Archive, ArrowLeft, ArrowRight, Check, ChevronRight, FileUp, Plus, RefreshCw, Search, Settings2, SlidersHorizontal } from "lucide-react"; import { publishSessionsChanged } from "@/lib/session-change-bus"; import { EMPTY_PROJECT_SET, @@ -69,6 +69,16 @@ interface Props { updateAvailable?: boolean; /** Opens the archived sessions browser. */ onOpenArchive?: () => void; + /** In-app back/forward over visited chat views (sidebar header buttons). */ + navigation?: { + canBack: boolean; + canForward: boolean; + onBack: () => void; + onForward: () => void; + /** Platform shortcut label for the tooltip, e.g. "⌘[" / "Alt+←". */ + backShortcut: string; + forwardShortcut: string; + }; /** True when settings full-page view is currently open. */ settingsOpen?: boolean; } @@ -78,7 +88,7 @@ interface Props { -export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, optimisticSession, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onWorkspaceOptionsChange, addProjectOpen, setAddProjectOpen, usageVisible = true, onOpenSettings, onOpenArchive, updateAvailable, settingsOpen = false }: Props) { +export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, optimisticSession, onSelectSession, onNewSession, initialSessionId, skipInitialProjectSelection, onInitialRestoreDone, refreshKey, onSessionDeleted, selectedCwd: selectedCwdProp, onCwdChange, onWorkspaceOptionsChange, addProjectOpen, setAddProjectOpen, usageVisible = true, onOpenSettings, onOpenArchive, navigation, updateAvailable, settingsOpen = false }: Props) { const { t } = useI18n(); @@ -1153,7 +1163,7 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId, ) : null; return ( -
+
{addProjectOpen && (
+ {navigation && ( + + + + + + + + + + + )} {onOpenArchive && ( void) | null): void { interface UseGlobalKeyboardShortcutsOptions { /** Called when Ctrl+Alt+N is pressed. Receives current cwd. */ onNewSession?: (cwd: string) => void; + /** + * Navigate back/forward through visited chat views. The keystroke is always + * preventDefault-ed when a handler is registered — an exhausted history + * stack stops there instead of falling through to the browser's own + * back/forward, so the app is never backed out of by accident. + */ + onNavigateBack?: () => void; + onNavigateForward?: () => void; /** The currently selected project directory (sidebar cwd). */ activeCwd?: string | null; /** Best-effort native-menu selection scoping; keyboard scoping is independent. */ @@ -36,6 +45,8 @@ interface UseGlobalKeyboardShortcutsOptions { * Esc – stop the running agent (via module-level abort handler) * Ctrl+Alt+N – create a new session in the active project directory * Ctrl/Cmd+A – select the active message, transcript, or file contents + * ⌘[/⌘] (Alt+←/→ on Windows/Linux, plus the mouse back/forward buttons) + * – in-app navigate back/forward (see lib/navigation-history.ts) * * Note: Esc inside