diff --git a/bun.lock b/bun.lock index 72f8d70c49..fc53223d76 100644 --- a/bun.lock +++ b/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:", diff --git a/packages/app/package.json b/packages/app/package.json index 0e68f1106c..06a91d5efe 100644 --- a/packages/app/package.json +++ b/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", diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 03a1049de1..e2aa47adbd 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/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(() => { diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts index 73ff0ff2ae..3a861c8067 100644 --- a/packages/app/src/pages/home/home-session-search-controller.ts +++ b/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 @@ -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() diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 461322ae43..df93c4a714 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/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 ( diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index aa5e8f378f..7046484a60 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/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 ( { fallback={ diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 144517e798..81c1a8f022 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/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) diff --git a/packages/app/src/utils/session-title.test.ts b/packages/app/src/utils/session-title.test.ts deleted file mode 100644 index 2c13995fe4..0000000000 --- a/packages/app/src/utils/session-title.test.ts +++ /dev/null @@ -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") - }) -}) diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts deleted file mode 100644 index 9db778178c..0000000000 --- a/packages/app/src/utils/session-title.ts +++ /dev/null @@ -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 -} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts index fc22778bb0..833dbb923d 100644 --- a/packages/app/src/utils/session.ts +++ b/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: "", diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index f412bfa32b..cc1dd8896a 100644 --- a/packages/cli/src/acp/service.ts +++ b/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 } : {}), diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index f11a55e97f..0f2d611f3d 100644 --- a/packages/core/src/session/title.ts +++ b/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()("@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* ( diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7a4c65783f..8ebc71d060 100644 --- a/packages/enterprise/package.json +++ b/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:", diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 7af9430fbf..bd6279cef1 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/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() const messages = data().message[data().sessionID] ?? [] diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index f98129c2d1..4bd2dd02b7 100644 --- a/packages/tui/src/app.tsx +++ b/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 } diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index bfef6d54c7..d1c7603a7e 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/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)}`, diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index a0b28830d1..50fcb2264e 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/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, diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 519dc2f578..979d2f8003 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/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((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>((result, entry) => { const sessionID = root(entry[0]) diff --git a/packages/tui/src/mini/runtime.lifecycle.ts b/packages/tui/src/mini/runtime.lifecycle.ts index 315335da82..d8ef63eb33 100644 --- a/packages/tui/src/mini/runtime.lifecycle.ts +++ b/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 { 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) diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index a6f4d5c4b0..70cbd39df0 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/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) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index bffa7cf15c..98d174b54d 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/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) { diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index c89fed0dff..a14582f0e8 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/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 }) { - {sessionTitle(session()!)} + {withTimestampedFallback(session()!)} {session()!.location.workspaceID} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 2b0bd7ea73..95bc6394e5 100644 --- a/packages/tui/src/util/session.ts +++ b/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) { - return ( - session.title ?? `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}` - ) -} - export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 if (boundary && boundaryIndex === -1) return undefined diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 7239096534..a000af7a06 100644 --- a/packages/tui/test/util/session.test.ts +++ b/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)] diff --git a/packages/util/package.json b/packages/util/package.json index 156a9eaeac..6612d6737d 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -28,6 +28,7 @@ }, "scripts": { "build": "bun run script/build.ts", + "test": "bun test --only-failures", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/util/src/session-title-fallback.ts b/packages/util/src/session-title-fallback.ts new file mode 100644 index 0000000000..30b9249bb3 --- /dev/null +++ b/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) { + 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) { + return info.title === undefined || info.title === fallback({ time: info.time }) +} + +function fallback(info: Pick) { + return `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}` +} diff --git a/packages/util/test/session-title-fallback.test.ts b/packages/util/test/session-title-fallback.test.ts new file mode 100644 index 0000000000..d55f109905 --- /dev/null +++ b/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() + }) +})