Browse Source

feat(tui): reopen closed session tabs (#39731)

Kit Langton 2 weeks ago
parent
commit
cc883058df

+ 8 - 0
packages/tui/src/app.tsx

@@ -105,6 +105,7 @@ const sessionTabBindingCommands = [
   "session.tab.next_unread",
   "session.tab.previous_unread",
   "session.tab.close",
+  "session.tab.reopen",
   "session.tab.select.1",
   "session.tab.select.2",
   "session.tab.select.3",
@@ -721,6 +722,13 @@ function App(props: { pair?: DialogPairCredentials }) {
         enabled: sessionTabs.enabled,
         run: () => sessionTabs.close(),
       },
+      {
+        name: "session.tab.reopen",
+        title: "Reopen closed tab",
+        category: "Session",
+        enabled: sessionTabs.enabled,
+        run: () => sessionTabs.reopen(),
+      },
       ...Array.from({ length: 9 }, (_, i) => ({
         name: `session.tab.select.${i + 1}`,
         title: `Switch to tab ${i + 1}`,

+ 2 - 0
packages/tui/src/config/v1/keybind.ts

@@ -94,6 +94,7 @@ export const Definitions = {
   session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
   session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
   session_tab_close: keybind("<leader>w", "Close current tab"),
+  session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
   session_timeline: keybind("<leader>g", "Show session timeline"),
   session_fork: keybind("none", "Fork session from message"),
   session_rename: keybind("ctrl+r", "Rename session"),
@@ -297,6 +298,7 @@ export const CommandMap = {
   session_tab_next_unread: "session.tab.next_unread",
   session_tab_previous_unread: "session.tab.previous_unread",
   session_tab_close: "session.tab.close",
+  session_tab_reopen: "session.tab.reopen",
   session_timeline: "session.timeline",
   session_fork: "session.fork",
   session_rename: "session.rename",

+ 33 - 0
packages/tui/src/context/session-tabs-model.ts

@@ -36,6 +36,39 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
   }
 }
 
+export type ClosedSessionTab = {
+  tab: SessionTab
+  index: number
+}
+
+const CLOSED_SESSION_TAB_LIMIT = 10
+
+export function recordClosedSessionTab(
+  stack: readonly ClosedSessionTab[],
+  tab: SessionTab,
+  index: number,
+): ClosedSessionTab[] {
+  return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
+    -CLOSED_SESSION_TAB_LIMIT,
+  )
+}
+
+/**
+ * Pop the most recently closed tab that is not already open and restore it at its original
+ * position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
+ */
+export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
+  const remaining = [...stack]
+  while (remaining.length > 0) {
+    const entry = remaining.pop()!
+    if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
+    const next = [...tabs]
+    next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
+    return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
+  }
+  return { stack: remaining, tabs: undefined, sessionID: undefined }
+}
+
 export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
   const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
   const to = Math.max(0, Math.min(tabs.length - 1, index))

+ 21 - 0
packages/tui/src/context/session-tabs.tsx

@@ -14,7 +14,10 @@ import {
   moveSessionTab,
   moveSessionTabHistory,
   openSessionTab,
+  recordClosedSessionTab,
   recordSessionTabHistory,
+  reopenSessionTab,
+  type ClosedSessionTab,
   type SessionTab,
   type SessionTabHistory,
   type SessionTabUnread,
@@ -57,6 +60,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     const fallback = empty()
     const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
     let history: SessionTabHistory = { entries: [], index: -1 }
+    // User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
+    let closedTabs: ClosedSessionTab[] = []
 
     function state() {
       if (config.tabs?.scope === "global") return store.global
@@ -193,6 +198,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     )
     onCleanup(
       event.on("session.deleted", (evt) => {
+        const target = root(evt.data.sessionID)
+        closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
         remove(evt.data.sessionID, enabled())
       }),
     )
@@ -244,8 +251,22 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
           if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
           return
         }
+        const index = state().tabs.findIndex((tab) => tab.sessionID === target)
+        const tab = state().tabs[index]
+        if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
         remove(target, true)
       },
+      reopen() {
+        if (!enabled()) return
+        const result = reopenSessionTab(closedTabs, state().tabs)
+        closedTabs = result.stack
+        const tabs = result.tabs
+        if (!tabs || !result.sessionID) return
+        update((draft) => {
+          draft.tabs = tabs
+        })
+        route.navigate({ type: "session", sessionID: result.sessionID })
+      },
       move(sessionID: string, index: number) {
         if (!enabled()) return
         const session = root(sessionID)

+ 41 - 0
packages/tui/test/context/session-tabs-model.test.ts

@@ -6,7 +6,9 @@ import {
   moveSessionTab,
   moveSessionTabHistory,
   openSessionTab,
+  recordClosedSessionTab,
   recordSessionTabHistory,
+  reopenSessionTab,
   seedSessionTabMotion,
   sessionTabComplete,
   sessionTabOverflowWidth,
@@ -136,6 +138,45 @@ describe("session tabs", () => {
     expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
   })
 
+  test("reopens the most recently closed tab at its original position", () => {
+    const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
+    const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
+    const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
+
+    expect(reopened.sessionID).toBe("b")
+    expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
+    expect(reopened.stack).toEqual([])
+    expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
+  })
+
+  test("skips and consumes closed entries that are already open", () => {
+    const stack = [
+      { tab: { sessionID: "a" }, index: 0 },
+      { tab: { sessionID: "b" }, index: 1 },
+    ]
+    const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
+
+    expect(reopened.sessionID).toBe("a")
+    expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
+    expect(reopened.stack).toEqual([])
+  })
+
+  test("clamps restored positions and keeps one entry per session", () => {
+    const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
+    expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
+
+    const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
+    expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
+
+    const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
+      (stack, tab, index) => recordClosedSessionTab(stack, tab, index),
+      twice,
+    )
+    expect(overflow).toHaveLength(10)
+    expect(overflow.at(-1)?.tab.sessionID).toBe("11")
+    expect(overflow[0]?.tab.sessionID).toBe("2")
+  })
+
   test("reveals completion activity only after session work becomes idle", () => {
     expect(sessionTabComplete("activity", true)).toBe(false)
     expect(sessionTabComplete("activity", false)).toBe(true)