mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:33:31 +00:00
fix(tui): stabilize generated session titles (#39894)
This commit is contained in:
parent
7fd12c560c
commit
dc3c996892
4 changed files with 191 additions and 32 deletions
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue