session-tabs-model.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { describe, expect, test } from "bun:test"
  2. import {
  3. adaptiveSessionTabLayout,
  4. closeSessionTab,
  5. cycleSessionTab,
  6. moveSessionTab,
  7. moveSessionTabHistory,
  8. openSessionTab,
  9. recordClosedSessionTab,
  10. recordSessionTabHistory,
  11. reopenSessionTab,
  12. seedSessionTabMotion,
  13. sessionTabComplete,
  14. sessionTabDetail,
  15. sessionTabOverflowWidth,
  16. sessionTabShortcutLabel,
  17. } from "../../src/context/session-tabs-model"
  18. describe("session tabs", () => {
  19. test("appends the branch to the project detail", () => {
  20. expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode ⎇ feature/sidebar")
  21. expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode ⎇ feature/sidebar")
  22. expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode")
  23. expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode")
  24. expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode")
  25. })
  26. test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
  27. expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
  28. "1",
  29. "2",
  30. "3",
  31. "4",
  32. "5",
  33. "6",
  34. "7",
  35. "8",
  36. "9",
  37. "0",
  38. "·",
  39. "·",
  40. ])
  41. })
  42. test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
  43. const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
  44. expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
  45. expect(moveSessionTab(tabs, "c", -5).map((tab) => tab.sessionID)).toEqual(["c", "a", "b"])
  46. expect(moveSessionTab(tabs, "b", 99).map((tab) => tab.sessionID)).toEqual(["a", "c", "b"])
  47. expect(moveSessionTab(tabs, "b", 1)).toBe(tabs)
  48. expect(moveSessionTab(tabs, "missing", 0)).toBe(tabs)
  49. })
  50. test("open seeding keeps survivors and grows the new tab from zero", () => {
  51. const seeded = seedSessionTabMotion(
  52. ["a", "b"],
  53. ["a", "b", "c"],
  54. { widths: [35, 35], selections: [1, 0], activities: [0, 1] },
  55. { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 1, 0] },
  56. )
  57. expect(seeded).toEqual({ widths: [35, 35, 0], selections: [1, 0, 0], activities: [0, 1, 0] })
  58. })
  59. test("close seeding keeps survivors at their current animated widths", () => {
  60. const seeded = seedSessionTabMotion(
  61. ["a", "b", "c"],
  62. ["a", "c"],
  63. { widths: [24, 23, 23], selections: [1, 0, 0], activities: [0, 0, 1] },
  64. { widths: [35, 35], selections: [1, 0], activities: [0, 1] },
  65. )
  66. expect(seeded).toEqual({ widths: [24, 23], selections: [1, 0], activities: [0, 1] })
  67. })
  68. test("window shifts keep retained tabs and grow revealed ones", () => {
  69. const seeded = seedSessionTabMotion(
  70. ["a", "b", "c"],
  71. ["b", "c", "d"],
  72. { widths: [22, 8, 8], selections: [1, 0, 0], activities: [0, 0, 0] },
  73. { widths: [8, 8, 22], selections: [0, 0, 1], activities: [0, 0, 0] },
  74. )
  75. expect(seeded).toEqual({ widths: [8, 8, 0], selections: [0, 0, 1], activities: [0, 0, 0] })
  76. })
  77. test("fully replaced windows jump instead of seeding", () => {
  78. const values = { widths: [22], selections: [1], activities: [0] }
  79. expect(seedSessionTabMotion(["a"], ["z"], values, values)).toBeUndefined()
  80. })
  81. test("overflow markers reserve room for a gap beside their digits", () => {
  82. expect(sessionTabOverflowWidth(5)).toBe(3)
  83. expect(sessionTabOverflowWidth(12)).toBe(4)
  84. })
  85. test("opens each session once and refreshes its title", () => {
  86. const tabs = openSessionTab([{ sessionID: "a", title: "Old" }], { sessionID: "a", title: "New" })
  87. expect(tabs).toEqual([{ sessionID: "a", title: "New" }])
  88. expect(openSessionTab(tabs, { sessionID: "b" })).toEqual([{ sessionID: "a", title: "New" }, { sessionID: "b" }])
  89. expect(openSessionTab(tabs, { sessionID: "a", title: "New" })).toBe(tabs)
  90. })
  91. test("selects the right tab then the left tab after closing", () => {
  92. expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }, { sessionID: "c" }], "b")).toEqual({
  93. tabs: [{ sessionID: "a" }, { sessionID: "c" }],
  94. next: "c",
  95. })
  96. expect(closeSessionTab([{ sessionID: "a" }, { sessionID: "b" }], "b").next).toBe("a")
  97. expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
  98. })
  99. test("closing an unknown session returns the same tabs reference", () => {
  100. const tabs = [{ sessionID: "a" }, { sessionID: "b" }]
  101. expect(closeSessionTab(tabs, "missing").tabs).toBe(tabs)
  102. })
  103. test("cycles through a filtered tab set in either direction", () => {
  104. const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
  105. expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
  106. expect(cycleSessionTab(tabs, "c", -1)?.sessionID).toBe("a")
  107. expect(cycleSessionTab(tabs, "e", 1)?.sessionID).toBe("a")
  108. expect(cycleSessionTab(tabs, "b", 1)?.sessionID).toBe("a")
  109. })
  110. test("cycles to the nearest matching tab from an unmatched active tab", () => {
  111. const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
  112. const unread = new Set(["a", "d"])
  113. const matches = (tab: { sessionID: string }) => unread.has(tab.sessionID)
  114. expect(cycleSessionTab(tabs, "c", 1, matches)?.sessionID).toBe("d")
  115. expect(cycleSessionTab(tabs, "c", -1, matches)?.sessionID).toBe("a")
  116. expect(cycleSessionTab(tabs, "e", 1, matches)?.sessionID).toBe("a")
  117. expect(cycleSessionTab(tabs, "a", -1, matches)?.sessionID).toBe("d")
  118. })
  119. test("moves backward and forward through selection history", () => {
  120. const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
  121. const history = ["a", "b", "c", "d"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
  122. const backToC = moveSessionTabHistory(history, tabs, "d", -1)
  123. const backToB = moveSessionTabHistory(backToC.history, tabs, "c", -1)
  124. const forwardToC = moveSessionTabHistory(backToB.history, tabs, "b", 1)
  125. const forwardToD = moveSessionTabHistory(forwardToC.history, tabs, "c", 1)
  126. expect([backToC.sessionID, backToB.sessionID, forwardToC.sessionID, forwardToD.sessionID]).toEqual([
  127. "c",
  128. "b",
  129. "c",
  130. "d",
  131. ])
  132. })
  133. test("truncates forward history after a new selection", () => {
  134. const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
  135. const history = ["a", "b", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
  136. const back = moveSessionTabHistory(history, tabs, "c", -1)
  137. const branched = recordSessionTabHistory(back.history, "d")
  138. expect(branched).toEqual({ entries: ["a", "b", "d"], index: 2 })
  139. expect(moveSessionTabHistory(branched, tabs, "d", 1).sessionID).toBeUndefined()
  140. })
  141. test("skips closed tabs and duplicate entries for the active tab", () => {
  142. const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
  143. const history = ["a", "b", "c", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
  144. expect(moveSessionTabHistory(history, tabs, "b", -1).sessionID).toBe("a")
  145. expect(recordSessionTabHistory(history, "b")).toBe(history)
  146. })
  147. test("drops the oldest history entries beyond the limit", () => {
  148. const sessions = Array.from({ length: 150 }, (_, index) => `session-${index}`)
  149. const history = sessions.reduce(recordSessionTabHistory, { entries: [], index: -1 })
  150. expect(history.entries.length).toBe(100)
  151. expect(history.entries[0]).toBe("session-50")
  152. expect(history.entries[history.index]).toBe("session-149")
  153. })
  154. test("returns to the latest history entry when no tab is active", () => {
  155. const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
  156. const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
  157. expect(moveSessionTabHistory(history, tabs, undefined, -1).sessionID).toBe("b")
  158. })
  159. test("returns to the previous selected open tab after closing the active tab", () => {
  160. const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
  161. const history = ["a", "c"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
  162. const closed = closeSessionTab(tabs, "b")
  163. const current = recordSessionTabHistory(history, "b")
  164. expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
  165. })
  166. test("reopens the most recently closed tab at its original position", () => {
  167. const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
  168. const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
  169. const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
  170. expect(reopened.sessionID).toBe("b")
  171. expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
  172. expect(reopened.stack).toEqual([])
  173. expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
  174. })
  175. test("skips and consumes closed entries that are already open", () => {
  176. const stack = [
  177. { tab: { sessionID: "a" }, index: 0 },
  178. { tab: { sessionID: "b" }, index: 1 },
  179. ]
  180. const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
  181. expect(reopened.sessionID).toBe("a")
  182. expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
  183. expect(reopened.stack).toEqual([])
  184. })
  185. test("clamps restored positions and keeps one entry per session", () => {
  186. const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
  187. expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
  188. const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
  189. expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
  190. const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
  191. (stack, tab, index) => recordClosedSessionTab(stack, tab, index),
  192. twice,
  193. )
  194. expect(overflow).toHaveLength(10)
  195. expect(overflow.at(-1)?.tab.sessionID).toBe("11")
  196. expect(overflow[0]?.tab.sessionID).toBe("2")
  197. })
  198. test("reveals completion activity only after session work becomes idle", () => {
  199. expect(sessionTabComplete("activity", true)).toBe(false)
  200. expect(sessionTabComplete("activity", false)).toBe(true)
  201. expect(sessionTabComplete("error", false)).toBe(false)
  202. })
  203. test("expands the active tab and keeps inactive widths equal", () => {
  204. const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
  205. const layout = adaptiveSessionTabLayout(tabs, "d", 76)
  206. expect(layout).toMatchObject({ before: 0, after: 0, start: 0, total: 76 })
  207. expect(layout.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
  208. expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
  209. })
  210. test("reserves an active tab slot for the new session page", () => {
  211. const tabs = ["a", "b", "c", "d", "new"].map((sessionID) => ({ sessionID }))
  212. const layout = adaptiveSessionTabLayout(tabs, "new", 54)
  213. expect(layout.tabs).toEqual(tabs)
  214. expect(layout.widths).toEqual([8, 8, 8, 8, 22])
  215. expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
  216. })
  217. test("keeps the visible tab window stable on the new session page", () => {
  218. const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
  219. const selected = adaptiveSessionTabLayout(tabs, "7", 70)
  220. const home = adaptiveSessionTabLayout(tabs, undefined, 70, selected.start)
  221. expect(selected.start).toBeGreaterThan(0)
  222. expect(home.start).toBe(selected.start)
  223. })
  224. test("only swaps old and new active width inside a sticky window", () => {
  225. const tabs = ["a", "b", "c", "d", "e", "f", "g"].map((sessionID) => ({ sessionID }))
  226. const before = adaptiveSessionTabLayout(tabs, "c", 76)
  227. const after = adaptiveSessionTabLayout(tabs, "d", 76, before.start)
  228. expect(before.start).toBe(after.start)
  229. expect(before.widths).toEqual([9, 9, 22, 9, 9, 9, 9])
  230. expect(after.widths).toEqual([9, 9, 9, 22, 9, 9, 9])
  231. })
  232. test("shares roomy width equally without changing widths on selection", () => {
  233. const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
  234. const before = adaptiveSessionTabLayout(tabs, "a", 100)
  235. const after = adaptiveSessionTabLayout(tabs, "c", 100, before.start)
  236. expect(before.widths).toEqual([32, 32, 32])
  237. expect(after.widths).toEqual(before.widths)
  238. expect(before.total).toBe(96)
  239. })
  240. test("caps a single tab instead of stretching it across the terminal", () => {
  241. const layout = adaptiveSessionTabLayout([{ sessionID: "a" }], "a", 100)
  242. expect(layout.widths).toEqual([32])
  243. expect(layout.total).toBe(32)
  244. })
  245. test("fills roomy space equally below the maximum width", () => {
  246. const tabs = ["a", "b", "c", "d"].map((sessionID) => ({ sessionID }))
  247. expect(adaptiveSessionTabLayout(tabs, "b", 100).widths).toEqual([25, 25, 25, 25])
  248. })
  249. test("expands only the active tab under compact pressure", () => {
  250. const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
  251. const before = adaptiveSessionTabLayout(tabs, "c", 100)
  252. const after = adaptiveSessionTabLayout(tabs, "d", 100, before.start)
  253. expect(before.widths).toEqual([19, 19, 24, 19, 19])
  254. expect(after.widths).toEqual([19, 19, 19, 24, 19])
  255. })
  256. test("moves the window only after selection crosses its edge", () => {
  257. const tabs = Array.from({ length: 10 }, (_, index) => ({ sessionID: String(index) }))
  258. const initial = adaptiveSessionTabLayout(tabs, "3", 70)
  259. const inside = adaptiveSessionTabLayout(tabs, "4", 70, initial.start)
  260. const crossed = adaptiveSessionTabLayout(tabs, "7", 70, inside.start)
  261. expect(initial.start).toBe(0)
  262. expect(inside.start).toBe(0)
  263. expect(crossed.start).toBeGreaterThan(0)
  264. expect(crossed.tabs.some((tab) => tab.sessionID === "7")).toBe(true)
  265. expect(crossed.widths.reduce((total, width) => total + width, 0)).toBe(crossed.total)
  266. })
  267. })