session-tabs.test.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. /** @jsxImportSource @opentui/solid */
  2. import { expect, test } from "bun:test"
  3. import type { OpenCodeEvent } from "@opencode-ai/client"
  4. import { testRender } from "@opentui/solid"
  5. import { mkdirSync, watch } from "fs"
  6. import path from "path"
  7. import { ConfigProvider } from "../../src/config"
  8. import { ClientProvider, useClient } from "../../src/context/client"
  9. import { DataProvider, useData } from "../../src/context/data"
  10. import { LocationProvider } from "../../src/context/location"
  11. import { RouteProvider, useRoute } from "../../src/context/route"
  12. import { TuiAppProvider } from "../../src/context/runtime"
  13. import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
  14. import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model"
  15. import { StorageProvider, useStorage } from "../../src/context/storage"
  16. import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client"
  17. import { TestTuiContexts } from "../fixture/tui-environment"
  18. import { tmpdir } from "../fixture/fixture"
  19. import { createTuiResolvedConfig } from "../fixture/tui-runtime"
  20. async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000, label = "condition") {
  21. const start = Date.now()
  22. while (!(await fn())) {
  23. if (Date.now() - start > timeout) throw new Error(`timed out waiting for ${label}`)
  24. await Bun.sleep(10)
  25. }
  26. }
  27. async function renderSessionTabs(
  28. initialSessionID: string,
  29. options?: {
  30. state?: string
  31. title?: string
  32. home?: boolean
  33. persisted?: string[]
  34. sessionGate?: Promise<void>
  35. sessionDirectories?: Record<string, string>
  36. sessionParents?: Record<string, string>
  37. sessionTimes?: Record<string, { idle?: number; viewed?: number }>
  38. newLocation?: "launch" | "inherit"
  39. },
  40. ) {
  41. const temporary = options?.state ? undefined : await tmpdir()
  42. const state = options?.state ?? temporary!.path
  43. if (options?.persisted) {
  44. const file = path.join(state, "test", "tui", "tabs.json")
  45. mkdirSync(path.dirname(file), { recursive: true })
  46. await Bun.write(
  47. file,
  48. JSON.stringify({
  49. global: { tabs: [], unread: {} },
  50. cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
  51. }),
  52. )
  53. }
  54. const events = createEventStream()
  55. const sessions: string[] = []
  56. const views: string[] = []
  57. const locations: string[] = []
  58. const vcsLocations: string[] = []
  59. const sessionTimes = Object.fromEntries(
  60. Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
  61. )
  62. const calls = createFetch(async (url, request) => {
  63. if (url.pathname === "/api/location") {
  64. const requested = url.searchParams.get("location[directory]") ?? directory
  65. locations.push(requested)
  66. return json({
  67. directory: requested,
  68. project: { id: "project", directory: requested, canonical: directory },
  69. })
  70. }
  71. if (url.pathname === "/api/vcs") {
  72. const requested = url.searchParams.get("location[directory]") ?? directory
  73. vcsLocations.push(requested)
  74. return json({
  75. location: { directory: requested },
  76. data: { branch: { current: "main", default: "main" } },
  77. })
  78. }
  79. const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
  80. if (viewed && request.method === "POST") {
  81. views.push(viewed)
  82. const time = (sessionTimes[viewed] ??= {})
  83. time.viewed = time.idle
  84. return new Response(null, { status: 204 })
  85. }
  86. const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
  87. if (!sessionID) return undefined
  88. sessions.push(sessionID)
  89. await options?.sessionGate
  90. return json({
  91. data: {
  92. id: sessionID,
  93. parentID: options?.sessionParents?.[sessionID],
  94. title: sessionID === initialSessionID ? options?.title : undefined,
  95. projectID: "project",
  96. location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
  97. cost: 0,
  98. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  99. time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
  100. },
  101. })
  102. }, events)
  103. let tabs!: ReturnType<typeof useSessionTabs>
  104. let route!: ReturnType<typeof useRoute>
  105. let client!: ReturnType<typeof useClient>
  106. let data!: ReturnType<typeof useData>
  107. let storage!: ReturnType<typeof useStorage>
  108. function Probe() {
  109. tabs = useSessionTabs()
  110. route = useRoute()
  111. client = useClient()
  112. data = useData()
  113. storage = useStorage()
  114. return <box />
  115. }
  116. const app = await testRender(() => (
  117. <TestTuiContexts paths={{ state }}>
  118. <TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
  119. <StorageProvider>
  120. <ConfigProvider
  121. config={createTuiResolvedConfig({
  122. tabs: { enabled: true },
  123. session: { new_location: options?.newLocation ?? "launch" },
  124. })}
  125. >
  126. <RouteProvider
  127. initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
  128. >
  129. <ClientProvider api={createApi(calls.fetch)}>
  130. <DataProvider>
  131. <LocationProvider>
  132. <SessionTabsProvider>
  133. <Probe />
  134. </SessionTabsProvider>
  135. </LocationProvider>
  136. </DataProvider>
  137. </ClientProvider>
  138. </RouteProvider>
  139. </ConfigProvider>
  140. </StorageProvider>
  141. </TuiAppProvider>
  142. </TestTuiContexts>
  143. ))
  144. await wait(() => client.connection.status() === "connected")
  145. return {
  146. tabs,
  147. route,
  148. data,
  149. sessions,
  150. views,
  151. locations,
  152. vcsLocations,
  153. state,
  154. setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
  155. sessionTimes[sessionID] = time
  156. },
  157. emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
  158. focus: () => app.renderer.emit("focus"),
  159. blur: () => app.renderer.emit("blur"),
  160. flush: () => storage.flush(),
  161. async destroy() {
  162. app.renderer.destroy()
  163. await storage.flush()
  164. await temporary?.[Symbol.asyncDispose]()
  165. },
  166. }
  167. }
  168. test("loads persisted tab metadata concurrently on connect", async () => {
  169. let release!: () => void
  170. const sessionGate = new Promise<void>((resolve) => (release = resolve))
  171. const setup = await renderSessionTabs("first", {
  172. home: true,
  173. persisted: ["first", "second"],
  174. sessionGate,
  175. })
  176. try {
  177. await wait(() => setup.sessions.length === 2)
  178. expect(setup.sessions.toSorted()).toEqual(["first", "second"])
  179. release()
  180. await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined)
  181. } finally {
  182. release()
  183. await setup.destroy()
  184. }
  185. })
  186. test("loads VCS metadata for each persisted tab location", async () => {
  187. const other = `${directory}/other-worktree`
  188. const setup = await renderSessionTabs("first", {
  189. home: true,
  190. persisted: ["first", "second"],
  191. sessionDirectories: { second: other },
  192. })
  193. try {
  194. await wait(() => setup.locations.includes(other))
  195. await wait(() => setup.vcsLocations.includes(other))
  196. } finally {
  197. await setup.destroy()
  198. }
  199. })
  200. test("loads location metadata when an open session moves", async () => {
  201. const destination = `${directory}/moved-worktree`
  202. const setup = await renderSessionTabs("first")
  203. try {
  204. await wait(() => setup.locations.includes(directory) && setup.vcsLocations.includes(directory))
  205. setup.emit({
  206. id: "evt_moved",
  207. created: 1,
  208. type: "session.moved",
  209. durable: { aggregateID: "first", seq: 1, version: 1 },
  210. data: {
  211. sessionID: "first",
  212. location: { directory: destination },
  213. projectID: "project",
  214. },
  215. })
  216. await wait(() => setup.data.session.get("first")?.location.directory === destination)
  217. await wait(() => setup.locations.includes(destination))
  218. await wait(() => setup.vcsLocations.includes(destination))
  219. } finally {
  220. await setup.destroy()
  221. }
  222. })
  223. test("stores session tabs for the current working directory by default", async () => {
  224. const setup = await renderSessionTabs("first")
  225. try {
  226. const file = path.join(setup.state, "test", "tui", "tabs.json")
  227. await wait(() => Bun.file(file).size > 0)
  228. const stored = await Bun.file(file).json()
  229. expect(stored.global).toEqual({ tabs: [] })
  230. expect(Object.keys(stored.cwd)).toEqual([directory])
  231. expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
  232. expect(stored.cwd[directory]).not.toHaveProperty("unread")
  233. } finally {
  234. await setup.destroy()
  235. }
  236. })
  237. test("derives unread state from server session times", async () => {
  238. const setup = await renderSessionTabs("first", {
  239. home: true,
  240. persisted: ["first", "second"],
  241. sessionTimes: { second: { idle: 2 } },
  242. })
  243. try {
  244. await wait(() => setup.tabs.status("second").unread === "activity")
  245. expect(setup.tabs.status("first").unread).toBeUndefined()
  246. } finally {
  247. await setup.destroy()
  248. }
  249. })
  250. test("refreshes server session times after terminal events", async () => {
  251. const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
  252. try {
  253. setup.setSessionTime("first", { idle: 2 })
  254. setup.emit({
  255. id: "evt_done_first",
  256. created: 2,
  257. type: "session.execution.succeeded",
  258. durable: { aggregateID: "first", seq: 1, version: 1 },
  259. data: { sessionID: "first" },
  260. })
  261. await wait(() => setup.tabs.status("first").unread === "activity")
  262. } finally {
  263. await setup.destroy()
  264. }
  265. })
  266. test("views a selected unread session only while focused", async () => {
  267. const setup = await renderSessionTabs("first", {
  268. home: true,
  269. persisted: ["first"],
  270. sessionTimes: { first: { idle: 2 } },
  271. })
  272. try {
  273. setup.blur()
  274. setup.route.navigate({ type: "session", sessionID: "first" })
  275. await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
  276. await Bun.sleep(20)
  277. expect(setup.views).toEqual([])
  278. setup.focus()
  279. await wait(() => setup.views.includes("first"))
  280. setup.emit({
  281. id: "evt_viewed_first",
  282. created: 3,
  283. type: "session.viewed",
  284. durable: { aggregateID: "first", seq: 2, version: 1 },
  285. data: { sessionID: "first" },
  286. })
  287. await wait(() => setup.tabs.status("first").unread === undefined)
  288. } finally {
  289. await setup.destroy()
  290. }
  291. })
  292. test("views unread child sessions through their root tab", async () => {
  293. const setup = await renderSessionTabs("root", {
  294. home: true,
  295. persisted: ["root"],
  296. sessionParents: { child: "root" },
  297. sessionTimes: { child: { idle: 2 } },
  298. })
  299. try {
  300. setup.blur()
  301. await setup.data.session.sync("child")
  302. await wait(() => setup.tabs.status("root").unread === "activity")
  303. setup.route.navigate({ type: "session", sessionID: "root" })
  304. await Bun.sleep(20)
  305. expect(setup.views).toEqual([])
  306. setup.focus()
  307. await wait(() => setup.views.includes("child"))
  308. expect(setup.views).not.toContain("root")
  309. } finally {
  310. await setup.destroy()
  311. }
  312. })
  313. test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
  314. await using temporary = await tmpdir()
  315. const state = temporary.path
  316. let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  317. let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
  318. try {
  319. titled = await renderSessionTabs("shared", { state, title: "Generated title" })
  320. untitled = await renderSessionTabs("shared", { state })
  321. const file = path.join(state, "test", "tui", "tabs.json")
  322. await titled.data.session.sync("shared")
  323. await wait(async () => {
  324. if (!(await Bun.file(file).exists())) return false
  325. return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title"
  326. })
  327. const observed = ["Generated title"]
  328. const pending = new Set<Promise<void>>()
  329. const watcher = watch(path.dirname(file), (_, name) => {
  330. if (name !== path.basename(file)) return
  331. const read = Bun.file(file)
  332. .json()
  333. .then((value) => {
  334. const title = value.cwd[directory]?.tabs[0]?.title
  335. if (title && observed.at(-1) !== title) observed.push(title)
  336. })
  337. .catch(() => undefined)
  338. .finally(() => pending.delete(read))
  339. pending.add(read)
  340. })
  341. try {
  342. await untitled.data.session.sync("shared")
  343. await Bun.sleep(500)
  344. } finally {
  345. watcher.close()
  346. await Promise.allSettled(pending)
  347. }
  348. expect(observed).toEqual(["Generated title"])
  349. } finally {
  350. if (titled) await titled.destroy()
  351. if (untitled) await untitled.destroy()
  352. }
  353. })
  354. test("user prompt admissions pulse an already-busy background tab", async () => {
  355. const setup = await renderSessionTabs("background")
  356. const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({
  357. id: `evt_${inboxID}`,
  358. created: Date.now(),
  359. type: "session.inbox.enqueued",
  360. durable: { aggregateID: sessionID, seq: Number(inboxID.replace(/\D/g, "")), version: 1 },
  361. data: {
  362. sessionID,
  363. inboxID,
  364. item: { type: "user", payload: { text: inboxID }, delivery: "steer" },
  365. },
  366. })
  367. try {
  368. await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
  369. setup.route.navigate({ type: "session", sessionID: "active" })
  370. await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
  371. setup.emit({
  372. id: "evt_context",
  373. created: Date.now(),
  374. type: "session.inbox.enqueued",
  375. durable: { aggregateID: "background", seq: 0, version: 1 },
  376. data: {
  377. sessionID: "background",
  378. inboxID: "msg_context",
  379. item: { type: "synthetic", payload: { text: "editor context" }, delivery: "steer" },
  380. },
  381. })
  382. await Bun.sleep(20)
  383. expect(setup.tabs.status("background").promptPulse).toBe(0)
  384. setup.emit(admitted("background", "msg_1"))
  385. await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
  386. setup.emit(admitted("background", "msg_2"))
  387. await wait(() => setup.tabs.status("background").promptPulse === 2)
  388. setup.emit(admitted("active", "msg_3"))
  389. await Bun.sleep(20)
  390. expect(setup.tabs.status("active").promptPulse).toBe(0)
  391. expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
  392. } finally {
  393. await setup.destroy()
  394. }
  395. })
  396. test("tracks a temporary new session tab across close and creation", async () => {
  397. const setup = await renderSessionTabs("first")
  398. try {
  399. await wait(() => setup.tabs.current() === "first")
  400. setup.route.navigate({ type: "session", sessionID: "second" })
  401. await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
  402. setup.route.navigate({ type: "session", sessionID: "first" })
  403. await wait(() => setup.tabs.current() === "first")
  404. setup.route.navigate({ type: "home" })
  405. await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
  406. expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
  407. setup.tabs.close()
  408. await wait(() => setup.route.data.type === "session")
  409. expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
  410. setup.route.navigate({ type: "home" })
  411. await wait(() => setup.tabs.newTab())
  412. setup.route.navigate({ type: "session", sessionID: "third" })
  413. expect(setup.tabs.newTab()).toBe(true)
  414. await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"))
  415. expect(setup.tabs.newTab()).toBe(false)
  416. expect(setup.tabs.tabs().find((tab) => tab.sessionID === "third")?.title).toBe(NEW_SESSION_TAB_TITLE)
  417. } finally {
  418. await setup.destroy()
  419. }
  420. })
  421. test("add opens the new session tab in the launch directory by default", async () => {
  422. const setup = await renderSessionTabs("first", { sessionDirectories: { first: `${directory}/worktree` } })
  423. try {
  424. await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
  425. setup.tabs.add()
  426. expect(setup.route.data).toEqual({ type: "home", location: { directory } })
  427. await wait(() => setup.tabs.newTab())
  428. expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
  429. } finally {
  430. await setup.destroy()
  431. }
  432. })
  433. test("add inherits the current session location when configured", async () => {
  434. const worktree = `${directory}/worktree`
  435. const setup = await renderSessionTabs("first", {
  436. newLocation: "inherit",
  437. sessionDirectories: { first: worktree },
  438. })
  439. try {
  440. await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
  441. setup.tabs.add()
  442. expect(setup.route.data).toEqual({ type: "home", location: { directory: worktree } })
  443. } finally {
  444. await setup.destroy()
  445. }
  446. })