From 9f741e98bf17716c296cf6f8477afe7f321c3ec2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:12:02 +0000
Subject: [PATCH 1/3] Initial plan
From 8fb331617d7320481006e32cd482878725e5cff8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:25:28 +0000
Subject: [PATCH 2/3] feat: navigation stack with back/forward buttons,
hotkeys, and mouse support
- Add NavEntry type, navHistory and navIndex fields to WorkspaceState
- Add navBack / navForward actions; openFile pushes to history
- Register navigate-back (Alt+Left) and navigate-forward (Alt+Right) commands
- Listen for mouse buttons 3/4 (browser back/forward) to dispatch nav actions
- Add back/forward arrow buttons to Ribbon near the search input
- Add ribbon-nav CSS for the new buttons
- Add unit tests for all navigation stack scenarios
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
apps/web/src/app.tsx | 33 +++++++++++++
apps/web/src/components/ribbon.tsx | 28 +++++++++++
apps/web/src/state/reducer.test.ts | 67 ++++++++++++++++++++++++++
apps/web/src/state/reducer.ts | 76 ++++++++++++++++++++++++++++--
apps/web/src/state/types.ts | 14 ++++++
apps/web/src/styles.css | 24 ++++++++++
package-lock.json | 1 +
7 files changed, 240 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 225274c..719218c 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -523,6 +523,21 @@ export function App() {
});
}, [refreshTree, dispatch, tomeActive]);
+ // Mouse back/forward button support (button 3 = back, button 4 = forward).
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (e.button === 3) {
+ e.preventDefault();
+ dispatch({ type: "navBack" });
+ } else if (e.button === 4) {
+ e.preventDefault();
+ dispatch({ type: "navForward" });
+ }
+ };
+ window.addEventListener("mouseup", handler);
+ return () => window.removeEventListener("mouseup", handler);
+ }, [dispatch]);
+
// Discard provisional notes whose tab was closed without modification.
useEffect(() => {
const openPaths = new Set(state.panes.flatMap((pane) => pane.tabs.map((tab) => tab.path)));
@@ -608,6 +623,20 @@ export function App() {
run: () => createCalendar(),
},
{ id: "new-grid", title: "New grid", category: "Create", run: () => createGrid() },
+ {
+ id: "navigate-back",
+ title: "Navigate back",
+ category: "Go",
+ defaultHotkey: "Alt+ArrowLeft",
+ run: () => dispatch({ type: "navBack" }),
+ },
+ {
+ id: "navigate-forward",
+ title: "Navigate forward",
+ category: "Go",
+ defaultHotkey: "Alt+ArrowRight",
+ run: () => dispatch({ type: "navForward" }),
+ },
{
id: "split-pane",
title: "Split editor pane",
@@ -892,6 +921,10 @@ export function App() {
tomeActive={tomeActive}
onCloseTome={closeTome}
onOpenTome={openTome}
+ canNavBack={state.navIndex > 0}
+ canNavForward={state.navIndex < state.navHistory.length - 1}
+ onNavBack={() => dispatch({ type: "navBack" })}
+ onNavForward={() => dispatch({ type: "navForward" })}
/>
void;
onOpenTome?: () => void;
+ canNavBack?: boolean;
+ canNavForward?: boolean;
+ onNavBack?: () => void;
+ onNavForward?: () => void;
}
function TopBarMenu({
@@ -81,6 +85,10 @@ export function Ribbon({
tomeActive = true,
onCloseTome,
onOpenTome,
+ canNavBack = false,
+ canNavForward = false,
+ onNavBack,
+ onNavForward,
}: RibbonProps) {
const electronApi = window.electronAPI;
const isDesktop = Boolean(electronApi);
@@ -169,6 +177,26 @@ export function Ribbon({
+
+
+
+
{
expect(state.panes[1].tabs.map((t) => t.path)).toEqual(["a.md"]); // original keeps non-active tab
});
});
+
+describe("navigation stack", () => {
+ it("openFile pushes to navHistory", () => {
+ let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ expect(state.navHistory).toHaveLength(1);
+ expect(state.navHistory[0]).toEqual({ path: "a.md", title: "a" });
+ expect(state.navIndex).toBe(0);
+
+ state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" });
+ expect(state.navHistory).toHaveLength(2);
+ expect(state.navIndex).toBe(1);
+ });
+
+ it("opening same path does not duplicate history entry", () => {
+ let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ state = workspaceReducer(state, { type: "openFile", path: "a.md", title: "a" });
+ expect(state.navHistory).toHaveLength(1);
+ expect(state.navIndex).toBe(0);
+ });
+
+ it("navBack navigates to the previous entry", () => {
+ let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" });
+ expect(state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId)?.path).toBe(
+ "b.md",
+ );
+
+ state = workspaceReducer(state, { type: "navBack" });
+ expect(state.navIndex).toBe(0);
+ const activeTab = state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId);
+ expect(activeTab?.path).toBe("a.md");
+ });
+
+ it("navForward navigates forward after going back", () => {
+ let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" });
+ state = workspaceReducer(state, { type: "navBack" });
+ state = workspaceReducer(state, { type: "navForward" });
+ expect(state.navIndex).toBe(1);
+ const activeTab = state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId);
+ expect(activeTab?.path).toBe("b.md");
+ });
+
+ it("navBack at start is a no-op", () => {
+ const state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ const next = workspaceReducer(state, { type: "navBack" });
+ expect(next.navIndex).toBe(0);
+ expect(next).toBe(state);
+ });
+
+ it("navForward at end is a no-op", () => {
+ const state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ const next = workspaceReducer(state, { type: "navForward" });
+ expect(next.navIndex).toBe(0);
+ expect(next).toBe(state);
+ });
+
+ it("opening a file after going back truncates forward history", () => {
+ let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
+ state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" });
+ state = workspaceReducer(state, { type: "navBack" });
+ // Now open a new file — this should drop "b.md" from history.
+ state = workspaceReducer(state, { type: "openFile", path: "c.md", title: "c" });
+ expect(state.navHistory.map((e) => e.path)).toEqual(["a.md", "c.md"]);
+ expect(state.navIndex).toBe(1);
+ });
+});
diff --git a/apps/web/src/state/reducer.ts b/apps/web/src/state/reducer.ts
index 324c27d..b6b1bc5 100644
--- a/apps/web/src/state/reducer.ts
+++ b/apps/web/src/state/reducer.ts
@@ -1,4 +1,7 @@
-import type { Pane, Tab, WorkspaceAction, WorkspaceState } from "./types";
+import type { NavEntry, Pane, Tab, WorkspaceAction, WorkspaceState } from "./types";
+
+/** Maximum number of entries kept in the navigation history. */
+const NAV_MAX = 100;
function generateId(prefix: string): string {
const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
@@ -16,6 +19,52 @@ function updatePane(
};
}
+/**
+ * Push `entry` to the navigation history of `state`, truncating any forward
+ * entries, and return the updated nav fields. Returns undefined when the entry
+ * is identical to the current position (no-op).
+ */
+function pushNav(
+ state: WorkspaceState,
+ entry: NavEntry,
+): Pick
| undefined {
+ const current = state.navHistory[state.navIndex];
+ if (current && current.path === entry.path) {
+ // Already at this location — update title in place if it changed.
+ if (current.title === entry.title) return undefined;
+ const navHistory = [...state.navHistory];
+ navHistory[state.navIndex] = entry;
+ return { navHistory, navIndex: state.navIndex };
+ }
+ // Truncate any forward entries and append the new one.
+ const base = state.navHistory.slice(0, state.navIndex + 1);
+ const navHistory = [...base, entry].slice(-NAV_MAX);
+ return { navHistory, navIndex: navHistory.length - 1 };
+}
+
+/**
+ * Resolve the pane state when navigating to `entry` without pushing history.
+ * Activates an existing tab for the path, or opens a new tab.
+ */
+function applyNavEntry(state: WorkspaceState, entry: NavEntry): WorkspaceState {
+ // Find the pane that currently has this path open.
+ for (const pane of state.panes) {
+ const tab = pane.tabs.find((t) => t.path === entry.path);
+ if (tab) {
+ const next = updatePane(state, pane.id, (p) => ({ ...p, activeTabId: tab.id }));
+ return { ...next, activePaneId: pane.id };
+ }
+ }
+ // Not open anywhere — open it as a new tab in the active pane.
+ const pane = state.panes.find((p) => p.id === state.activePaneId) ?? state.panes[0];
+ const tab: Tab = { id: generateId("tab"), path: entry.path, title: entry.title };
+ return updatePane(state, pane.id, (p) => ({
+ ...p,
+ tabs: [...p.tabs, tab],
+ activeTabId: tab.id,
+ }));
+}
+
export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState {
switch (action.type) {
case "setTree":
@@ -81,15 +130,36 @@ export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction)
case "openFile": {
const pane = state.panes.find((p) => p.id === state.activePaneId) ?? state.panes[0];
const existing = pane.tabs.find((tab) => tab.path === action.path);
+ const navUpdate = pushNav(state, { path: action.path, title: action.title });
if (existing) {
- return updatePane(state, pane.id, (p) => ({ ...p, activeTabId: existing.id }));
+ const next = updatePane(state, pane.id, (p) => ({ ...p, activeTabId: existing.id }));
+ return navUpdate ? { ...next, ...navUpdate } : next;
}
const tab: Tab = { id: generateId("tab"), path: action.path, title: action.title };
- return updatePane(state, pane.id, (p) => ({
+ const next = updatePane(state, pane.id, (p) => ({
...p,
tabs: [...p.tabs, tab],
activeTabId: tab.id,
}));
+ return navUpdate ? { ...next, ...navUpdate } : next;
+ }
+
+ case "navBack": {
+ if (state.navIndex <= 0) return state;
+ const navIndex = state.navIndex - 1;
+ const entry = state.navHistory[navIndex];
+ if (!entry) return state;
+ const next = applyNavEntry(state, entry);
+ return { ...next, navIndex };
+ }
+
+ case "navForward": {
+ if (state.navIndex >= state.navHistory.length - 1) return state;
+ const navIndex = state.navIndex + 1;
+ const entry = state.navHistory[navIndex];
+ if (!entry) return state;
+ const next = applyNavEntry(state, entry);
+ return { ...next, navIndex };
}
case "activateTab":
diff --git a/apps/web/src/state/types.ts b/apps/web/src/state/types.ts
index e9cd0b4..fa14078 100644
--- a/apps/web/src/state/types.ts
+++ b/apps/web/src/state/types.ts
@@ -18,17 +18,29 @@ export interface Pane {
activeTabId?: string;
}
+/** A single entry in the navigation history (path + display title). */
+export interface NavEntry {
+ path: string;
+ title: string;
+}
+
export interface WorkspaceState {
tree: FileEntry[];
panes: Pane[];
activePaneId: string;
theme: ThemeMode;
status: string;
+ /** Ordered list of visited locations. */
+ navHistory: NavEntry[];
+ /** Index of the currently active entry in navHistory (−1 when empty). */
+ navIndex: number;
}
export type WorkspaceAction =
| { type: "setTree"; tree: FileEntry[] }
| { type: "openFile"; path: string; title: string }
+ | { type: "navBack" }
+ | { type: "navForward" }
| { type: "closeTab"; paneId: string; tabId: string }
| { type: "closeOtherTabs"; paneId: string; tabId: string }
| { type: "closeTabsToRight"; paneId: string; tabId: string }
@@ -70,5 +82,7 @@ export function createInitialState(theme: ThemeMode): WorkspaceState {
activePaneId: paneId,
theme,
status: "Ready",
+ navHistory: [],
+ navIndex: -1,
};
}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index c62c557..a881165 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -327,10 +327,34 @@ button {
.ribbon-search {
display: flex;
+ align-items: center;
+ gap: 4px;
justify-content: center;
min-width: 0;
}
+.ribbon-nav {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.ribbon-nav-btn {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ font-size: 14px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ribbon-nav-btn:disabled {
+ opacity: 0.35;
+ cursor: default;
+}
+
.ribbon-search-input {
width: min(420px, 100%);
height: 26px;
diff --git a/package-lock.json b/package-lock.json
index 12cd6a4..bd92106 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4713,6 +4713,7 @@
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
+ "hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
From 3fdf717dc2853f17b98638eef5595fabb1c89d91 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 28 Aug 2026 02:26:32 +0000
Subject: [PATCH 3/3] Apply remaining changes
---
apps/web/src/state/reducer.test.ts | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/apps/web/src/state/reducer.test.ts b/apps/web/src/state/reducer.test.ts
index d176646..99f6bbe 100644
--- a/apps/web/src/state/reducer.test.ts
+++ b/apps/web/src/state/reducer.test.ts
@@ -211,9 +211,7 @@ describe("navigation stack", () => {
it("navBack navigates to the previous entry", () => {
let state = workspaceReducer(initial(), { type: "openFile", path: "a.md", title: "a" });
state = workspaceReducer(state, { type: "openFile", path: "b.md", title: "b" });
- expect(state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId)?.path).toBe(
- "b.md",
- );
+ expect(state.panes[0].tabs.find((t) => t.id === state.panes[0].activeTabId)?.path).toBe("b.md");
state = workspaceReducer(state, { type: "navBack" });
expect(state.navIndex).toBe(0);