Skip to content
Closed
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
97 changes: 75 additions & 22 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
})
Expand Down
62 changes: 62 additions & 0 deletions packages/app/src/context/tab-order.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
42 changes: 42 additions & 0 deletions packages/app/src/context/tab-order.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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]
}
29 changes: 28 additions & 1 deletion packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -70,6 +71,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const [order, setOrder, , orderReady] = persisted(Persist.window("tabs.order"), createStore<string[]>([]))
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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())
Expand All @@ -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 }
},
})
Loading