Kaynağa Gözat

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

Kit Langton 1 hafta önce
ebeveyn
işleme
7aaf4e7750

+ 2 - 0
bun.lock

@@ -67,6 +67,7 @@
         "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
         "@opencode-ai/session-ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
+        "@opencode-ai/util": "workspace:*",
         "@pierre/trees": "1.0.0-beta.4",
         "@sentry/solid": "catalog:",
         "@shikijs/transformers": "3.9.2",
@@ -520,6 +521,7 @@
         "@opencode-ai/core": "workspace:*",
         "@opencode-ai/session-ui": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
+        "@opencode-ai/util": "workspace:*",
         "@pierre/diffs": "catalog:",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",

+ 1 - 0
packages/app/package.json

@@ -59,6 +59,7 @@
     "@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
     "@opencode-ai/session-ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@pierre/trees": "1.0.0-beta.4",
     "@sentry/solid": "catalog:",
     "@shikijs/transformers": "3.9.2",

+ 2 - 2
packages/app/src/components/titlebar-tab-nav.tsx

@@ -4,6 +4,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createMutation } from "@tanstack/solid-query"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
 import { useGlobal } from "@/context/global"
 import { ServerConnection, serverName } from "@/context/server"
 import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -11,7 +12,6 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
 import type { Session } from "@opencode-ai/sdk/v2"
 import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
 import { TabPreviewPopover } from "./titlebar-tab-popover"
-import { sessionTitle } from "@/utils/session-title"
 import "./titlebar-tab-nav.css"
 
 // MouseEvent.button uses 1 for the middle/wheel button.
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
   })
   const title = createMemo(() => {
     const session = props.session()
-    return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
+    return session ? displayLabel(session) : props.fallbackTitle
   })
 
   const projectName = createMemo(() => {

+ 2 - 2
packages/app/src/pages/home/home-session-search-controller.ts

@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
 import { serverName } from "@/context/server"
 import { displayName } from "@/pages/layout/helpers"
 import { makeEventListener } from "@solid-primitives/event-listener"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
 import { createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import type { HomeController } from "./home-controller"
 import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
-import { sessionTitle } from "@/utils/session-title"
 
 type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
 
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
     if (!value) return []
     return sessions.data
       .searchRecords()
-      .filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
+      .filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
   })
   const active = createMemo(() => {
     const records = results()

+ 3 - 3
packages/app/src/pages/home/home-sessions-view.tsx

@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
 import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
 import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
 import { useLanguage } from "@/context/language"
 import { ServerConnection } from "@/context/server"
 import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
-import { sessionTitle } from "@/utils/session-title"
 import { shouldOpenSessionInBackground } from "../home-session-open"
 import {
   HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
     selected: boolean
   },
 ) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const title = createMemo(() => displayLabel(props.record.session))
   const showProjectName = () => props.showProjectName() && props.record.projectName
   const key = () => homeSessionSearchKey(props.record)
 
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
 }
 
 function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
-  const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
+  const title = createMemo(() => displayLabel(props.record.session))
   const showProjectName = () => props.showProjectName() && props.record.projectName
 
   return (

+ 3 - 3
packages/app/src/pages/layout/sidebar-items.tsx

@@ -5,6 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
 import { getFilename } from "@opencode-ai/core/util/path"
 import { A, useParams } from "@solidjs/router"
 import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -14,7 +15,6 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { messageAgentColor } from "@/utils/agent"
-import { sessionTitle } from "@/utils/session-title"
 import { sessionPermissionRequest } from "../session/composer/session-request-tree"
 import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
 
@@ -104,7 +104,7 @@ const SessionRow = (props: {
   warmPress: () => void
   warmFocus: () => void
 }): JSX.Element => {
-  const title = () => sessionTitle(props.session.title, props.session.parentID)
+  const title = () => displayLabel(props.session)
 
   return (
     <A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
               fallback={
                 <Tooltip
                   placement={props.mobile ? "bottom" : "right"}
-                  value={sessionTitle(props.session.title, props.session.parentID)}
+                  value={displayLabel(props.session)}
                   gutter={10}
                   class="min-w-0 w-full"
                 >

+ 4 - 5
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
-import { sessionTitle } from "@/utils/session-title"
+import { displayLabel } from "@opencode-ai/util/session-title-fallback"
 import { scheduleConnectedMeasure } from "./measure"
 import { observeElementOffsetReconnectAware } from "./observe-element-offset"
 import { createTimelineProjection } from "./projection"
@@ -296,11 +296,10 @@ export function MessageTimeline(props: {
     if (!id) return
     return sync().session.get(id)
   })
-  const titleValue = createMemo(() => info()?.title)
   const titleLabel = createMemo(() => {
     const session = info()
     if (!session) return
-    return sessionTitle(titleValue(), session.parentID)
+    return displayLabel(session)
   })
   const shareUrl = createMemo(() => info()?.share?.url)
   const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
@@ -317,7 +316,7 @@ export function MessageTimeline(props: {
   })
   const parentTitle = createMemo(() => {
     const session = parent()
-    return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
+    return session ? displayLabel(session) : language.t("command.session.new")
   })
   const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
   const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
@@ -921,7 +920,7 @@ export function MessageTimeline(props: {
   function DialogDeleteSession(props: { sessionID: string }) {
     const name = createMemo(() => {
       const session = sync().session.get(props.sessionID)
-      return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
+      return session ? displayLabel(session) : language.t("command.session.new")
     })
     const handleDelete = async () => {
       await deleteSession(props.sessionID)

+ 0 - 11
packages/app/src/utils/session-title.test.ts

@@ -1,11 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { sessionTitle } from "./session-title"
-
-describe("sessionTitle", () => {
-  test("uses a display fallback without persisting it", () => {
-    expect(sessionTitle(undefined)).toBe("New session")
-    expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
-    expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
-    expect(sessionTitle("Generated title")).toBe("Generated title")
-  })
-})

+ 0 - 7
packages/app/src/utils/session-title.ts

@@ -1,7 +0,0 @@
-const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
-
-export function sessionTitle(title?: string, parentID?: string) {
-  if (!title) return parentID ? "Child session" : "New session"
-  const match = title.match(pattern)
-  return match?.[1] ?? title
-}

+ 2 - 1
packages/app/src/utils/session.ts

@@ -1,5 +1,6 @@
 import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
 import type { Session } from "@opencode-ai/sdk/v2/client"
+import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 
 export function normalizeSessionInfo(input: SessionInfo | Session): Session {
   if (!("location" in input)) return input
@@ -13,7 +14,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
     parentID: input.parentID,
     cost: input.cost,
     tokens: input.tokens,
-    title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
+    title: withTimestampedFallback(input),
     agent: input.agent,
     model: input.model,
     version: "",

+ 2 - 3
packages/cli/src/acp/service.ts

@@ -8,6 +8,7 @@ import {
   type SessionMessageInfo,
   type SkillInfo,
 } from "@opencode-ai/client/promise"
+import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 import type {
   AgentSideConnection,
   AuthenticateRequest,
@@ -213,9 +214,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
         sessions: page.data.map((session) => ({
           sessionId: session.id,
           cwd: session.location.directory,
-          title:
-            session.title ??
-            `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
+          title: withTimestampedFallback(session),
           updatedAt: new Date(session.time.updated).toISOString(),
         })),
         ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),

+ 5 - 1
packages/core/src/session/title.ts

@@ -6,6 +6,7 @@ import { Agent } from "../agent"
 import { Database } from "../database/database"
 import { Bus } from "../bus"
 import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
+import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
 import { App } from "../app"
 import { llmClient } from "../effect/app-node-platform"
 import { SessionEvent } from "./event"
@@ -39,7 +40,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
 
 const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
 const isUntitled = (session: SessionSchema.Info) =>
-  session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
+  isExactRootFallback({
+    title: session.title,
+    time: { created: DateTime.toEpochMillis(session.time.created) },
+  })
 
 const make = (dependencies: Dependencies) => {
   const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (

+ 1 - 0
packages/enterprise/package.json

@@ -16,6 +16,7 @@
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/session-ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "aws4fetch": "^1.0.20",
     "@pierre/diffs": "catalog:",
     "@solidjs/router": "catalog:",

+ 2 - 5
packages/enterprise/src/routes/share/[shareID].tsx

@@ -4,6 +4,7 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
 import { DataProvider } from "@opencode-ai/session-ui/context"
 import { FileComponentProvider } from "@opencode-ai/ui/context/file"
 import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
+import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
 import { createAsync, query, useParams } from "@solidjs/router"
 import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
 import { Share } from "~/core/share"
@@ -158,11 +159,7 @@ export default function () {
           const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
           if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
           const info = createMemo(() => data().session[match().index])
-          const title = createMemo(
-            () =>
-              info().title ??
-              `${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
-          )
+          const title = createMemo(() => withTimestampedFallback(info()))
           const ogImage = createMemo(() => {
             const models = new Set<string>()
             const messages = data().message[data().sessionID] ?? []

+ 2 - 2
packages/tui/src/app.tsx

@@ -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
       }

+ 2 - 2
packages/tui/src/component/dialog-open.tsx

@@ -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)}`,

+ 7 - 3
packages/tui/src/component/dialog-session-list.tsx

@@ -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,

+ 7 - 3
packages/tui/src/context/session-tabs.tsx

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

+ 3 - 3
packages/tui/src/mini/runtime.lifecycle.ts

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

+ 3 - 3
packages/tui/src/routes/session/composer/subagents-tab.tsx

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

+ 2 - 2
packages/tui/src/routes/session/index.tsx

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

+ 2 - 2
packages/tui/src/routes/session/sidebar.tsx

@@ -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>

+ 1 - 13
packages/tui/src/util/session.ts

@@ -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

+ 1 - 17
packages/tui/test/util/session.test.ts

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

+ 1 - 0
packages/util/package.json

@@ -28,6 +28,7 @@
   },
   "scripts": {
     "build": "bun run script/build.ts",
+    "test": "bun test --only-failures",
     "typecheck": "tsgo --noEmit"
   },
   "dependencies": {

+ 36 - 0
packages/util/src/session-title-fallback.ts

@@ -0,0 +1,36 @@
+export * as SessionTitleFallback from "./session-title-fallback.js"
+
+const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
+
+interface Info {
+  readonly title?: string
+  readonly parentID?: string
+  readonly time: {
+    readonly created: number
+  }
+}
+
+/** Supplies the timestamped title required by compatibility surfaces. */
+export function withTimestampedFallback(info: Info) {
+  return info.title ?? fallback(info)
+}
+
+/** Supplies a compact human label and collapses historical timestamped fallbacks. */
+export function displayLabel(info: Pick<Info, "title" | "parentID">) {
+  if (!info.title) return info.parentID ? "Child session" : "New session"
+  return info.title.match(pattern)?.[1] ?? info.title
+}
+
+/** Recognizes missing and historical root or child fallback titles. */
+export function isFallbackTitle(title?: string) {
+  return title === undefined || pattern.test(title)
+}
+
+/** Recognizes a missing title or the exact root fallback for this session. */
+export function isExactRootFallback(info: Pick<Info, "title" | "time">) {
+  return info.title === undefined || info.title === fallback({ time: info.time })
+}
+
+function fallback(info: Pick<Info, "parentID" | "time">) {
+  return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}`
+}

+ 49 - 0
packages/util/test/session-title-fallback.test.ts

@@ -0,0 +1,49 @@
+import { describe, expect, test } from "bun:test"
+import { SessionTitleFallback } from "../src/session-title-fallback.js"
+
+const root = { title: undefined, time: { created: 0 } }
+const child = { ...root, parentID: "ses_parent" }
+
+describe("SessionTitleFallback", () => {
+  test("supplies timestamped compatibility titles", () => {
+    expect(SessionTitleFallback.withTimestampedFallback(root)).toBe("New session - 1970-01-01T00:00:00.000Z")
+    expect(SessionTitleFallback.withTimestampedFallback(child)).toBe("Child session - 1970-01-01T00:00:00.000Z")
+    expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "Generated title" })).toBe("Generated title")
+    expect(SessionTitleFallback.withTimestampedFallback({ ...root, title: "" })).toBe("")
+  })
+
+  test("supplies compact display labels", () => {
+    expect(SessionTitleFallback.displayLabel(root)).toBe("New session")
+    expect(SessionTitleFallback.displayLabel(child)).toBe("Child session")
+    expect(SessionTitleFallback.displayLabel({ title: "", parentID: "ses_parent" })).toBe("Child session")
+    expect(SessionTitleFallback.displayLabel({ title: "New session - 2026-07-30T18:45:03.662Z" })).toBe("New session")
+    expect(SessionTitleFallback.displayLabel({ title: "Child session - 2026-07-30T18:45:03.662Z" })).toBe(
+      "Child session",
+    )
+    expect(SessionTitleFallback.displayLabel({ title: "Generated title" })).toBe("Generated title")
+  })
+
+  test("recognizes historical fallback titles", () => {
+    expect(SessionTitleFallback.isFallbackTitle(undefined)).toBeTrue()
+    expect(SessionTitleFallback.isFallbackTitle("New session - 2026-07-30T18:45:03.662Z")).toBeTrue()
+    expect(SessionTitleFallback.isFallbackTitle("Child session - 2026-07-30T18:45:03.662Z")).toBeTrue()
+    expect(SessionTitleFallback.isFallbackTitle("")).toBeFalse()
+    expect(SessionTitleFallback.isFallbackTitle("New session - custom")).toBeFalse()
+  })
+
+  test("recognizes only the fallback associated with a session", () => {
+    expect(SessionTitleFallback.isExactRootFallback(root)).toBeTrue()
+    expect(
+      SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 1970-01-01T00:00:00.000Z" }),
+    ).toBeTrue()
+    expect(
+      SessionTitleFallback.isExactRootFallback({ ...root, title: "New session - 2099-01-01T00:00:00.000Z" }),
+    ).toBeFalse()
+    expect(
+      SessionTitleFallback.isExactRootFallback({
+        ...child,
+        title: "Child session - 1970-01-01T00:00:00.000Z",
+      }),
+    ).toBeFalse()
+  })
+})