From 985ee1e2ec7ca799d61fb5f32d97fccfb8c1e8d7 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:15:53 +1000 Subject: [PATCH] fix(app): navigate tabs by selection history --- packages/app/src/components/titlebar.tsx | 22 +--------- packages/app/src/context/tab-history.test.ts | 43 ++++++++++++++++++++ packages/app/src/context/tab-history.ts | 28 +++++++++++++ packages/app/src/context/tabs.tsx | 16 ++++++++ packages/app/src/desktop-menu.test.ts | 11 +++++ packages/app/src/desktop-menu.ts | 4 +- 6 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 packages/app/src/context/tab-history.test.ts create mode 100644 packages/app/src/context/tab-history.ts diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 4578656107..51cf437a1f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -419,16 +419,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl title: "", keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index -= 1 - if (index === -1) index = tabsStore.length - 1 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, + onSelect: tabs.previous, }, { id: `tab.next`, @@ -436,16 +427,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl title: "", keybind: `mod+option+ArrowRight,ctrl+tab`, hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index += 1 - if (index === tabsStore.length) index = 0 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, + onSelect: tabs.next, }, ].filter((v) => v !== undefined) }) diff --git a/packages/app/src/context/tab-history.test.ts b/packages/app/src/context/tab-history.test.ts new file mode 100644 index 0000000000..e8a64bd936 --- /dev/null +++ b/packages/app/src/context/tab-history.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history" + +function history(): TabHistory { + return { stack: [], index: -1 } +} + +describe("tab history", () => { + test("moves backward and forward through selected tabs", () => { + const selected = ["a", "b", "c"].reduce(rememberTab, history()) + const available = new Set(selected.stack) + + const previous = previousTab(selected, available) + expect(previous?.key).toBe("b") + + const first = previousTab(previous!.state, available) + expect(first?.key).toBe("a") + + const next = nextTab(first!.state, available) + expect(next?.key).toBe("b") + }) + + test("replaces forward history after a new selection", () => { + const selected = ["a", "b", "c"].reduce(rememberTab, history()) + const previous = previousTab(selected, new Set(selected.stack)) + const next = rememberTab(previous!.state, "d") + + expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 }) + expect(nextTab(next, new Set(next.stack))).toBeUndefined() + }) + + test("skips tabs that are no longer open", () => { + const selected = ["a", "b", "c"].reduce(rememberTab, history()) + + expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a") + }) + + test("skips a repeated current tab after closing the previous selection", () => { + const selected = ["a", "b", "c", "b"].reduce(rememberTab, history()) + + expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a") + }) +}) diff --git a/packages/app/src/context/tab-history.ts b/packages/app/src/context/tab-history.ts new file mode 100644 index 0000000000..998e70ad34 --- /dev/null +++ b/packages/app/src/context/tab-history.ts @@ -0,0 +1,28 @@ +const MAX_TAB_HISTORY = 100 + +export type TabHistory = { + stack: string[] + index: number +} + +export function rememberTab(state: TabHistory, key: string): TabHistory { + if (state.stack[state.index] === key) return state + const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY) + return { stack, index: stack.length - 1 } +} + +export function previousTab(state: TabHistory, available: Set) { + return move(state, -1, available) +} + +export function nextTab(state: TabHistory, available: Set) { + return move(state, 1, available) +} + +function move(state: TabHistory, offset: -1 | 1, available: Set) { + const current = state.stack[state.index] + for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) { + const key = state.stack[index] + if (key && key !== current && available.has(key)) return { state: { ...state, index }, key } + } +} diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf..efae62943d 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { createDraftPromptSession, type PromptModel } from "./prompt-state" +import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history" export type SessionTab = { type: "session" @@ -79,6 +80,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const memory = createTabMemory(getOwner()) const closing = new Set() + let history: TabHistory = { stack: [], index: -1 } let recentWrite = 0 let recentValue: string | undefined @@ -150,10 +152,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const navigateTab = (tab: Tab) => { const href = tabHref(tab) + history = rememberTab(history, tabKey(tab)) setRecentKey(tabKey(tab)) navigate(href) } + const moveHistory = (direction: "previous" | "next") => { + const available = new Set(store.map(tabKey)) + const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available) + if (!result) return + const tab = store.find((item) => tabKey(item) === result.key) + if (!tab) return + history = result.state + navigateTab(tab) + } + const removeTab = (index: number) => { const tab = store[index] if (!tab) return @@ -359,8 +372,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ select: navigateTab, remember(tab: Tab) { const key = tabKey(tab) + history = rememberTab(history, key) if (recentKey() !== key) setRecentKey(key) }, + previous: () => moveHistory("previous"), + next: () => moveHistory("next"), toggleHome(input: { home: boolean; current?: Tab }) { if (input.home) { const tab = store.find((tab) => tabKey(tab) === recentKey()) diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts index c7e36a7cb7..3f21da29d6 100644 --- a/packages/app/src/desktop-menu.test.ts +++ b/packages/app/src/desktop-menu.test.ts @@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test" import { DESKTOP_MENU } from "./desktop-menu" describe("desktop menu", () => { + test("navigates between tabs", () => { + const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( + (item) => item.type === "item" && (item.label === "Previous Tab" || item.label === "Next Tab"), + ) + + expect(items).toEqual([ + { type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } }, + { type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } }, + ]) + }) + test("exports logs through the desktop command registry", () => { const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( (item) => item.type === "item" && item.label === "Export Logs...", diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index d3902cd2b2..7a73eb34ad 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -168,8 +168,8 @@ export const DESKTOP_MENU: DesktopMenu[] = [ { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, { type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "separator" }, - { type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } }, - { type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } }, + { type: "item", label: "Previous Tab", command: "tab.prev", accelerator: { macos: "Option+Up" } }, + { type: "item", label: "Next Tab", command: "tab.next", accelerator: { macos: "Option+Down" } }, { type: "separator" }, { type: "item",