Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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 →
Expand Down
20 changes: 20 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
99 changes: 97 additions & 2 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -294,7 +296,7 @@
if (container && active instanceof HTMLElement && container.contains(active)) {
active.blur();
}
}, [sidebarOpen, mobileSidebarReady]);

Check warning on line 299 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useLayoutEffect has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

Check warning on line 299 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useLayoutEffect has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array
useEffect(() => {
const controller = new AbortController();
void fetch("/api/omp-update", {
Expand Down Expand Up @@ -839,7 +841,7 @@
sidebarResizeHandlersRef.current = { onMove, onUp };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [isMobile, sidebarWidth]);

Check warning on line 844 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

Check warning on line 844 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'sidebarContainerRef'. Either include it or remove the dependency array

// If the app unmounts mid-drag, remove the window listeners and restore the
// body cursor; otherwise the handlers leak and body stays cursor:col-resize.
Expand All @@ -856,7 +858,7 @@
const resetRightPanelWidth = useCallback(() => {
rightPanelRef.current?.style.removeProperty("--right-panel-width");
setRightPanelWidth(null);
}, []);

Check warning on line 861 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 861 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

const changeRightPanelWidth = useCallback((delta: number) => {
setRightPanelWidth((prev) => {
Expand All @@ -867,7 +869,7 @@
rightPanelRef.current?.style.setProperty("--right-panel-width", `${next}px`);
return next;
});
}, []);

Check warning on line 872 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 872 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

const handleRightPanelResizeKey = useCallback((e: React.KeyboardEvent) => {
// The handle sits on the panel's left edge: left widens, right narrows.
Expand Down Expand Up @@ -928,7 +930,7 @@
rightResizeHandlersRef.current = { onMove, onUp };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, [isMobile]);

Check warning on line 933 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (windows-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

Check warning on line 933 in components/AppShell.tsx

View workflow job for this annotation

GitHub Actions / Verify (ubuntu-latest)

React Hook useCallback has a missing dependency: 'rightPanelRef'. Either include it or remove the dependency array

// If the app unmounts mid-drag, remove the window listeners and restore the
// body cursor; otherwise the handlers leak and body stays cursor:col-resize.
Expand Down Expand Up @@ -1170,7 +1172,7 @@
}
}, [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);
Expand All @@ -1187,10 +1189,102 @@
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<SessionInfo | null | "unavailable"> => {
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<void> => {
// 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,
});
Expand Down Expand Up @@ -1647,6 +1741,7 @@
settingsOpen={Boolean(settingsTab)}
onOpenSettings={() => setSettingsTab((prev) => prev ? null : "general")}
onOpenArchive={() => setArchiveBrowserOpen(true)}
navigation={sidebarNavigation}
updateAvailable={Boolean(appUpdate?.updateAvailable) || ompUpdateAvailable}
/>
);
Expand Down
80 changes: 80 additions & 0 deletions components/SessionSidebar.navigation.test.mjs
Original file line number Diff line number Diff line change
@@ -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(`<button[^>]*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);
});
38 changes: 35 additions & 3 deletions components/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();
Expand Down Expand Up @@ -1153,7 +1163,7 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId,
) : null;

return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", paddingBottom: "env(safe-area-inset-bottom)" }}>
<div className="sidebar-shell" style={{ display: "flex", flexDirection: "column", height: "100%", overflow: "hidden", paddingBottom: "env(safe-area-inset-bottom)" }}>
{addProjectOpen && (
<DirectoryPicker
busy={addProjectBusy}
Expand Down Expand Up @@ -1190,6 +1200,28 @@ export const SessionSidebar = memo(function SessionSidebar({ selectedSessionId,
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<OmpWebTitle />
<div style={{ display: "flex", gap: 2 }}>
{navigation && (
<span className="sidebar-nav-buttons">
<Tooltip content={`${t("sessionSidebar.navigateBack")} (${navigation.backShortcut})`} side="bottom">
<SidebarIconButton
label={t("sessionSidebar.navigateBack")}
onClick={navigation.onBack}
disabled={!navigation.canBack}
>
<ArrowLeft size={15} strokeWidth={1.9} aria-hidden="true" />
</SidebarIconButton>
</Tooltip>
<Tooltip content={`${t("sessionSidebar.navigateForward")} (${navigation.forwardShortcut})`} side="bottom">
<SidebarIconButton
label={t("sessionSidebar.navigateForward")}
onClick={navigation.onForward}
disabled={!navigation.canForward}
>
<ArrowRight size={15} strokeWidth={1.9} aria-hidden="true" />
</SidebarIconButton>
</Tooltip>
</span>
)}
{onOpenArchive && (
<Tooltip content={t("sessionSidebar.archiveBrowserTitle")} side="bottom">
<SidebarIconButton
Expand Down
Loading
Loading