refactor(session): centralize fallback title policy (#39890)

This commit is contained in:
Kit Langton 2026-07-31 10:56:38 -04:00 committed by GitHub
commit 7aaf4e7750
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 145 additions and 93 deletions

View file

@ -76,7 +76,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isDefaultTitle } from "./util/session"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
@ -537,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (route.data.type === "session") {
const title = session?.title
if (!title || isDefaultTitle(title)) {
if (!title || isFallbackTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}

View file

@ -16,7 +16,7 @@ import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { truncateFilePath } from "../ui/file-path"
import { stringWidth } from "../util/string-width"
import { sessionTitle } from "../util/session"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { Spinner } from "./spinner"
const RECENT_LIMIT = 8
@ -80,7 +80,7 @@ export function DialogOpen() {
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
const running = data.session.family(session.id).some((id) => data.session.status(id) === "running")
return {
title: sessionTitle(session),
title: withTimestampedFallback(session),
value: { type: "session", sessionID: session.id } as OpenTarget,
category: "Sessions",
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,

View file

@ -18,8 +18,8 @@ import { Spinner } from "./spinner"
import { errorMessage } from "../util/error"
import { useSessionTabs } from "../context/session-tabs"
import { useStorage } from "../context/storage"
import { sessionTitle } from "../util/session"
import { useConfig } from "../config"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function DialogSessionList() {
const dialog = useDialog()
@ -82,7 +82,9 @@ export function DialogSessionList() {
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query))
return sessions.filter(
(session) => !session.parentID && withTimestampedFallback(session).toLowerCase().includes(query),
)
})
const sessions = createMemo(() => {
const query = filter().trim()
@ -144,7 +146,9 @@ export function DialogSessionList() {
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(session),
title: deleting
? `Press ${shortcuts.get("session.delete")} again to confirm`
: withTimestampedFallback(session),
value: session.id,
category,
footer,

View file

@ -3,7 +3,7 @@ import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { useData } from "./data"
import { sessionTitle } from "../util/session"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
@ -116,7 +116,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
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 ? sessionTitle(session) : undefined)
const title =
session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? withTimestampedFallback(session) : undefined)
const tabs = openSessionTab(state().tabs, { sessionID, title })
if (tabs === state().tabs && !state().unread[sessionID]) return
update((draft) => {
@ -130,7 +131,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
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 ? sessionTitle(session) : tab.title })
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])

View file

@ -10,7 +10,7 @@
// back to the usual two-press exit sequence through RunFooter.requestExit().
import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { isDefaultTitle } from "../util/session"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { monoSnapshot } from "./mono"
import { entrySplash, exitSplash, splashMeta } from "./splash"
import { resolveRunTheme } from "./theme"
@ -105,7 +105,7 @@ function shutdown(renderer: CliRenderer): void {
}
function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !isDefaultTitle(title)) {
if (title && !isFallbackTitle(title)) {
return {
title,
showSession: true,
@ -176,7 +176,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
if (mono) renderer.on(CliRenderEvents.EXTERNAL_OUTPUT, monoSnapshot)
const setTitle = (title?: string) => {
if (input.host.platform !== "linux") return
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
if (!title || isFallbackTitle(title)) return renderer.setTerminalTitle("OpenCode")
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
}
setTitle(input.sessionTitle)

View file

@ -8,7 +8,7 @@ import { useTheme } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { Keymap } from "../../../context/keymap"
import { useComposerTab } from "./index"
import { sessionTitle } from "../../../util/session"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
interface SubagentEntry {
sessionID: string
@ -39,7 +39,7 @@ export function SubagentsTab(props: { sessionID: string }) {
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const title = sessionTitle(sibling)
const title = withTimestampedFallback(sibling)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = sibling.agent
? Locale.titlecase(sibling.agent)
@ -58,7 +58,7 @@ export function SubagentsTab(props: { sessionID: string }) {
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const title = sessionTitle(child)
const title = withTimestampedFallback(child)
const agentMatch = title.match(/@(\w+) subagent/)
const agent = child.agent
? Locale.titlecase(child.agent)

View file

@ -92,7 +92,7 @@ import { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width"
import { useArgs } from "../../context/args"
import { sessionTitle } from "../../util/session"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
addDefaultParsers(parsers.parsers)
@ -3308,7 +3308,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI
})
return [`## Assistant\n\n${content.join("\n\n")}`]
})
return `# ${sessionTitle(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
return `# ${withTimestampedFallback(session)}\n\n**Session ID:** ${session.id}\n**Created:** ${new Date(session.time.created).toLocaleString()}\n**Updated:** ${new Date(session.time.updated).toLocaleString()}\n\n---\n\n${body.join("\n\n---\n\n")}\n`
}
export function parseApplyPatchFiles(value: unknown) {

View file

@ -3,7 +3,7 @@ import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { PluginSlot } from "../../plugin/context"
import { sessionTitle } from "../../util/session"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { getScrollAcceleration } from "../../util/scroll"
@ -39,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{sessionTitle(session()!)}</b>
<b>{withTimestampedFallback(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>

View file

@ -1,18 +1,6 @@
import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Locale } from "./locale"
export function isDefaultTitle(title?: string) {
return (
title === undefined || /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
)
}
export function sessionTitle(session: Pick<SessionInfo, "parentID" | "time" | "title">) {
return (
session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`
)
}
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
if (boundary && boundaryIndex === -1) return undefined

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client"
import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session"
import { lastAssistantWithUsage } from "../../src/util/session"
const assistant = (id: string, input: number): SessionMessageInfo => ({
id,
@ -13,22 +13,6 @@ const assistant = (id: string, input: number): SessionMessageInfo => ({
})
describe("util.session", () => {
test("recognizes generated parent and child titles", () => {
expect(isDefaultTitle(undefined)).toBeTrue()
expect(isDefaultTitle("New session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
expect(isDefaultTitle("New session - custom")).toBeFalse()
})
test("derives display-only titles for untitled sessions", () => {
expect(sessionTitle({ title: undefined, time: { created: 0, updated: 0 } })).toBe(
"New session - 1970-01-01T00:00:00.000Z",
)
expect(sessionTitle({ title: undefined, parentID: "ses_parent", time: { created: 0, updated: 0 } })).toBe(
"Child session - 1970-01-01T00:00:00.000Z",
)
})
test("tracks usage across undo and redo boundaries", () => {
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]