diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 383310b3f7..d588c42b4d 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -375,8 +375,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ .catch((error) => console.error("Failed to load projected model switch message", error)) break case "session.renamed": - if (store.session.info[event.data.sessionID]) - setStore("session", "info", event.data.sessionID, "title", event.data.title) + // Preserve the live title when it races the session's initial read. + void result.session.sync(event.data.sessionID).then(() => { + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, "title", event.data.title) + }) break case "session.moved": if (store.session.info[event.data.sessionID]) { diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 979d2f8003..1afcf9eed7 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -78,6 +78,21 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } const root = (sessionID: string) => data.session.root(sessionID) + const title = (sessionID: string, persisted?: string, fallback?: string) => { + const session = data.session.get(sessionID) + return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined) + } + const normalize = (value: TabsState) => ({ + tabs: value.tabs.reduce((tabs, tab) => { + const sessionID = root(tab.sessionID) + return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) }) + }, []), + unread: Object.entries(value.unread).reduce>((result, entry) => { + const sessionID = root(entry[0]) + result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] + return result + }, {}), + }) const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) const newTab = createMemo((open = false) => { if (route.data.type === "home") return true @@ -115,36 +130,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (route.data.type !== "session" || route.data.sessionID === "dummy") return const sessionID = root(route.data.sessionID) history = recordSessionTabHistory(history, sessionID) - const session = data.session.get(sessionID) - const title = - session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? withTimestampedFallback(session) : undefined) - const tabs = openSessionTab(state().tabs, { sessionID, title }) + const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined + const tabs = openSessionTab(state().tabs, { + sessionID, + title: title(sessionID, state().tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), + }) if (tabs === state().tabs && !state().unread[sessionID]) return update((draft) => { - draft.tabs = openSessionTab(draft.tabs, { sessionID, title }) + draft.tabs = openSessionTab(draft.tabs, { + sessionID, + title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback), + }) delete draft.unread[sessionID] }) }) createEffect(() => { if (!enabled()) return - const next = state().tabs.reduce((tabs, tab) => { - const sessionID = root(tab.sessionID) - const session = data.session.get(sessionID) - return openSessionTab(tabs, { - sessionID, - title: session ? withTimestampedFallback(session) : tab.title, - }) - }, []) - const unread = Object.entries(state().unread).reduce>((result, entry) => { - const sessionID = root(entry[0]) - result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] - return result - }, {}) - if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return + const next = normalize(state()) + if (isDeepEqual(next, state())) return update((draft) => { - draft.tabs = next - draft.unread = unread + const next = normalize(draft) + draft.tabs = next.tabs + draft.unread = next.unread }) }) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index a07e3e6b12..da73a047a3 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -138,6 +138,94 @@ test("session lifecycle updates the terminal title and prints the epilogue after } }) +test("session title generated while an untitled session is loading remains visible", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const titles: string[] = [] + const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) + const generatedTitle = Promise.withResolvers() + setup.renderer.setTerminalTitle = (title) => { + titles.push(title) + if (title === "OC | Generated title") generatedTitle.resolve() + setTitle(title) + } + const sessionRequested = Promise.withResolvers() + const renameSyncRequested = Promise.withResolvers() + const releaseSession = Promise.withResolvers() + let sessionRequests = 0 + const session = { + id: "dummy", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const events = createEventStream() + const calls = createFetch(async (url) => { + if (url.pathname === "/api/session") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy") { + sessionRequests++ + sessionRequested.resolve() + if (sessionRequests === 2) renameSyncRequested.resolve() + await releaseSession.promise + return json({ data: session }) + } + if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} }) + if (url.pathname === "/api/session/dummy/pending") return json({ data: [] }) + if (url.pathname === "/api/session/dummy/permission") return json({ data: [] }) + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({}), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + args: { sessionID: "dummy" }, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await sessionRequested.promise + events.emit({ + id: "evt_renamed", + created: 1, + type: "session.renamed", + durable: { aggregateID: "dummy", seq: 1, version: 1 }, + data: { sessionID: "dummy", title: "Generated title" }, + }) + await Promise.race([ + renameSyncRequested.promise, + Bun.sleep(2_000).then(() => { + throw new Error("rename sync did not start") + }), + ]) + releaseSession.resolve() + await Promise.race([ + generatedTitle.promise, + Bun.sleep(2_000).then(() => { + throw new Error("generated title was not shown") + }), + ]) + await Bun.sleep(20) + + const generated = titles.lastIndexOf("OC | Generated title") + expect(generated).toBeGreaterThan(-1) + expect(titles.slice(generated + 1)).not.toContain("OpenCode") + setup.renderer.destroy() + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + mock.restore() + } +}) + test("session startup prompt is submitted exactly once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const core = await import("@opentui/core") diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index d8ddae518a..ceffc9c5f8 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -2,41 +2,56 @@ import { expect, test } from "bun:test" import type { OpenCodeEvent } from "@opencode-ai/client" import { testRender } from "@opentui/solid" -import { mkdtempSync, rmSync } from "fs" +import { mkdtempSync, rmSync, watch } from "fs" import { tmpdir } from "os" import path from "path" import { ConfigProvider } from "../../src/config" import { ClientProvider, useClient } from "../../src/context/client" -import { DataProvider } from "../../src/context/data" +import { DataProvider, useData } from "../../src/context/data" import { RouteProvider, useRoute } from "../../src/context/route" import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" import { NEW_SESSION_TAB_TITLE } from "../../src/context/session-tabs-model" import { StorageProvider } from "../../src/context/storage" -import { createApi, createEventStream, createFetch, directory } from "../fixture/tui-client" +import { createApi, createEventStream, createFetch, directory, json } from "../fixture/tui-client" import { TestTuiContexts } from "../fixture/tui-environment" import { createTuiResolvedConfig } from "../fixture/tui-runtime" -async function wait(fn: () => boolean, timeout = 2_000) { +async function wait(fn: () => boolean | Promise, timeout = 2_000) { const start = Date.now() - while (!fn()) { + while (!(await fn())) { if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") await Bun.sleep(10) } } -async function renderSessionTabs(initialSessionID: string) { - const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) +async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) { + const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) const events = createEventStream() - const calls = createFetch(undefined, events) + const calls = createFetch((url) => { + if (url.pathname !== `/api/session/${initialSessionID}`) return + return json({ + data: { + id: initialSessionID, + title: options?.title, + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + }) + }, events) let tabs!: ReturnType let route!: ReturnType let client!: ReturnType + let data!: ReturnType function Probe() { tabs = useSessionTabs() route = useRoute() client = useClient() + data = useData() return } @@ -64,11 +79,12 @@ async function renderSessionTabs(initialSessionID: string) { return { tabs, route, + data, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), destroy() { app.renderer.destroy() - rmSync(state, { recursive: true, force: true }) + if (!options?.state) rmSync(state, { recursive: true, force: true }) }, } } @@ -88,6 +104,50 @@ test("stores session tabs globally by default", async () => { } }) +test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => { + const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-")) + let titled: Awaited> | undefined + let untitled: Awaited> | undefined + + try { + titled = await renderSessionTabs("shared", { state, title: "Generated title" }) + untitled = await renderSessionTabs("shared", { state }) + const file = path.join(state, "test", "tui", "tabs.json") + await titled.data.session.sync("shared") + await wait(async () => { + if (!(await Bun.file(file).exists())) return false + return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title" + }) + const observed = ["Generated title"] + const pending = new Set>() + const watcher = watch(path.dirname(file), (_, name) => { + if (name !== path.basename(file)) return + const read = Bun.file(file) + .json() + .then((value) => { + const title = value.global.tabs[0]?.title + if (title && observed.at(-1) !== title) observed.push(title) + }) + .catch(() => undefined) + .finally(() => pending.delete(read)) + pending.add(read) + }) + try { + await untitled.data.session.sync("shared") + await Bun.sleep(500) + } finally { + watcher.close() + await Promise.allSettled(pending) + } + + expect(observed).toEqual(["Generated title"]) + } finally { + titled?.destroy() + untitled?.destroy() + rmSync(state, { recursive: true, force: true }) + } +}) + test("user prompt admissions pulse an already-busy background tab", async () => { const setup = await renderSessionTabs("background") const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({