fix(tui): stabilize generated session titles (#39894)

This commit is contained in:
Kit Langton 2026-07-31 11:13:25 -04:00 committed by GitHub
parent 7fd12c560c
commit dc3c996892
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 191 additions and 32 deletions

View file

@ -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]) {

View file

@ -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<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((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<SessionTab[]>((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<Record<string, SessionTabUnread>>((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
})
})

View file

@ -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<void>()
setup.renderer.setTerminalTitle = (title) => {
titles.push(title)
if (title === "OC | Generated title") generatedTitle.resolve()
setTitle(title)
}
const sessionRequested = Promise.withResolvers<void>()
const renameSyncRequested = Promise.withResolvers<void>()
const releaseSession = Promise.withResolvers<void>()
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")

View file

@ -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<boolean>, 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<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
function Probe() {
tabs = useSessionTabs()
route = useRoute()
client = useClient()
data = useData()
return <box />
}
@ -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<ReturnType<typeof renderSessionTabs>> | undefined
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | 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<Promise<void>>()
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 => ({