From 6bfce8d9a88f85da9bc7a88ff88555259dca3279 Mon Sep 17 00:00:00 2001 From: razzeee Date: Tue, 21 Jul 2026 17:07:41 +0200 Subject: [PATCH] feat(tabs): implement MRU tab cycling and order management --- packages/app/src/components/titlebar.tsx | 97 +++++++++++++++++----- packages/app/src/context/tab-order.test.ts | 62 ++++++++++++++ packages/app/src/context/tab-order.ts | 42 ++++++++++ packages/app/src/context/tabs.tsx | 29 ++++++- 4 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 packages/app/src/context/tab-order.test.ts create mode 100644 packages/app/src/context/tab-order.ts diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 45786561077a..23cd89dee02e 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" +import { stepOrder } from "@/context/tab-order" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" @@ -297,12 +298,66 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl const currentTab = () => matchRoute(layout.route()) + // Browser-style Ctrl+Tab cycling: while the modifier is held, presses + // walk back through MRU order without reshuffling it; the landed tab + // only moves to the front once released. mod+option+Arrow are discrete + // jumps that commit immediately (see stepTab). + let cycle: { landed: string | undefined } | undefined + let cycleCleanup: (() => void) | undefined + + const endCycle = () => { + cycleCleanup?.() + cycleCleanup = undefined + const landed = cycle?.landed + cycle = undefined + if (landed) tabs.promoteTab(landed) + } + + const resolveStep = (anchor: string | undefined, offset: number) => { + const key = stepOrder(tabs.order, anchor, offset) + if (!key) return + const tab = tabsStore.find((item) => tabKey(item) === key) + if (!tab) return + return { tab, key } + } + + // Promotes directly so it stays correct even mid-cycle, where the + // route-change effect suppresses promotion. + const stepTab = (offset: number) => { + const active = currentTab() + const result = resolveStep(active ? tabKey(active) : undefined, offset) + if (!result) return + tabs.select(result.tab) + tabs.promoteTab(result.key) + } + + const cycleTabs = (offset: number) => { + if (!cycle) { + const active = currentTab() + cycle = { landed: active ? tabKey(active) : undefined } + const off = makeEventListener(window, "keyup", (event: KeyboardEvent) => { + if (event.key === "Control" || event.key === "Meta") endCycle() + }) + const blur = makeEventListener(window, "blur", endCycle) + cycleCleanup = () => { + off() + blur() + } + } + const result = resolveStep(cycle.landed, offset) + if (!result) return + cycle.landed = result.key + tabs.select(result.tab) + } + createEffect(() => { const route = layout.route() if (!tabs.ready()) return const tab = currentTab() if (tab) { tabs.remember(tab) + // Suppress promotion mid-cycle; endCycle commits the landed tab. + if (!cycle) tabs.promoteTab(tabKey(tab)) return } @@ -417,35 +472,33 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl id: `tab.prev`, category: "tab", title: "", - keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + keybind: `mod+option+ArrowLeft`, 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: () => stepTab(-1), }, { id: `tab.next`, category: "tab", title: "", - keybind: `mod+option+ArrowRight,ctrl+tab`, + keybind: `mod+option+ArrowRight`, 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: () => stepTab(1), + }, + { + id: `tab.cyclePrev`, + category: "tab", + title: "", + keybind: `ctrl+shift+tab`, + hidden: true, + onSelect: () => cycleTabs(-1), + }, + { + id: `tab.cycleNext`, + category: "tab", + title: "", + keybind: `ctrl+tab`, + hidden: true, + onSelect: () => cycleTabs(1), }, ].filter((v) => v !== undefined) }) diff --git a/packages/app/src/context/tab-order.test.ts b/packages/app/src/context/tab-order.test.ts new file mode 100644 index 000000000000..289379d618ca --- /dev/null +++ b/packages/app/src/context/tab-order.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { promoteOrder, reconcileOrder, stepOrder } from "./tab-order" + +describe("tab order promote", () => { + test("moves a key to the front", () => { + expect(promoteOrder(["a", "b", "c"], "c")).toEqual(["c", "a", "b"]) + }) + + test("adds an unseen key to the front", () => { + expect(promoteOrder(["a", "b"], "c")).toEqual(["c", "a", "b"]) + }) + + test("drops duplicates", () => { + expect(promoteOrder(["a", "b", "a"], "a")).toEqual(["a", "b"]) + }) +}) + +describe("tab order reconcile", () => { + test("drops closed tabs and keeps order", () => { + expect(reconcileOrder(["c", "a", "b"], ["a", "b"])).toEqual(["a", "b"]) + }) + + test("appends newly-seen keys at the back", () => { + expect(reconcileOrder(["a"], ["a", "b", "c"])).toEqual(["a", "b", "c"]) + }) + + test("dedupes", () => { + expect(reconcileOrder(["a", "a", "b"], ["a", "b"])).toEqual(["a", "b"]) + }) +}) + +describe("tab order step", () => { + const order = ["a", "b", "c"] + + test("steps toward older tabs", () => { + expect(stepOrder(order, "a", 1)).toBe("b") + expect(stepOrder(order, "a", 2)).toBe("c") + }) + + test("wraps forward", () => { + expect(stepOrder(order, "a", 3)).toBe("a") + expect(stepOrder(order, "c", 1)).toBe("a") + }) + + test("steps toward newer tabs and wraps", () => { + expect(stepOrder(order, "a", -1)).toBe("c") + expect(stepOrder(order, "b", -1)).toBe("a") + }) + + test("anchors on active even when not at the front", () => { + expect(stepOrder(order, "b", 1)).toBe("c") + }) + + test("falls back to the front when active is unknown", () => { + expect(stepOrder(order, "missing", 1)).toBe("b") + expect(stepOrder(order, undefined, 1)).toBe("b") + }) + + test("returns undefined for an empty order", () => { + expect(stepOrder([], "a", 1)).toBeUndefined() + }) +}) diff --git a/packages/app/src/context/tab-order.ts b/packages/app/src/context/tab-order.ts new file mode 100644 index 000000000000..c64699fafd6d --- /dev/null +++ b/packages/app/src/context/tab-order.ts @@ -0,0 +1,42 @@ +// Most-recently-used (MRU) ordering for top-level tabs. The order is a list of +// tab keys, most-recent first. Ctrl+Tab walks it like a browser: repeated +// presses step further back through visit history and the order only commits +// once the modifier is released, so a burst walks the full history instead of +// bouncing between the two most recent tabs. + +export function promoteOrder(order: string[], key: string): string[] { + const next = order.filter((item) => item !== key) + next.unshift(key) + return next +} + +// Keeps only keys for still-open tabs, preserving MRU order and appending +// newly-seen keys at the back so they can still be cycled to. +export function reconcileOrder(order: string[], keys: string[]): string[] { + const open = new Set(keys) + const seen = new Set() + const next: string[] = [] + for (const key of order) { + if (!open.has(key) || seen.has(key)) continue + seen.add(key) + next.push(key) + } + for (const key of keys) { + if (seen.has(key)) continue + seen.add(key) + next.push(key) + } + return next +} + +// Steps `offset` positions through the MRU list from `active` (positive walks +// toward older tabs, negative toward newer), wrapping around. Anchoring on +// `active` lets a fresh cycle start from the current tab even before the order +// has committed it to the front. +export function stepOrder(order: string[], active: string | undefined, offset: number): string | undefined { + if (order.length === 0) return undefined + const anchor = active !== undefined && order.includes(active) ? active : order[0] + const start = anchor !== undefined ? order.indexOf(anchor) : 0 + const index = (((start + offset) % order.length) + order.length) % order.length + return order[index] +} diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf4a..d30d62b841f2 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { promoteOrder, reconcileOrder } from "./tab-order" import { createDraftPromptSession, type PromptModel } from "./prompt-state" export type SessionTab = { @@ -70,6 +71,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) + const [order, setOrder, , orderReady] = persisted(Persist.window("tabs.order"), createStore([])) const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore>({})) const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) @@ -105,6 +107,19 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ void closedReady.promise?.then(apply) } + // MRU order updates are gated on readiness so a burst of early selects does + // not clobber the persisted history before it has loaded. + const updateOrder = (update: (order: string[]) => string[]) => { + const apply = () => setOrder((current) => update(current)) + if (orderReady()) { + apply() + return + } + void orderReady.promise?.then(apply) + } + + const promoteTab = (key: string) => updateOrder((current) => promoteOrder(current, key)) + const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } @@ -148,6 +163,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (next.length !== closed.length) setClosed(() => next) }) + // Keep the MRU history in step with the open tabs: drop keys for closed tabs + // and append keys for tabs opened elsewhere so every tab stays cyclable. + createEffect(() => { + if (!ready() || !orderReady()) return + const keys = store.map(tabKey) + const next = reconcileOrder(order, keys) + if (next.length !== order.length || next.some((key, index) => key !== order[index])) setOrder(() => next) + }) + const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) @@ -361,6 +385,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const key = tabKey(tab) if (recentKey() !== key) setRecentKey(key) }, + // Commits a tab to the front of the MRU history. The titlebar suppresses + // this mid-cycle so repeated Ctrl+Tab presses keep walking back. + promoteTab, toggleHome(input: { home: boolean; current?: Tab }) { if (input.home) { const tab = store.find((tab) => tabKey(tab) === recentKey()) @@ -382,6 +409,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, } - return { ...actions, store, info, ready, recentReady } + return { ...actions, store, info, order, ready, recentReady, orderReady } }, })