From eb95bd27fe39014413d4ab1319d2e21bb19758b0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 30 Jul 2026 21:24:34 -0400 Subject: [PATCH] feat(session): make generated titles optional (#39747) --- .../app/src/components/titlebar-tab-nav.tsx | 10 +++-- .../home/home-session-search-controller.ts | 3 +- .../app/src/pages/layout/sidebar-items.tsx | 4 +- .../session/timeline/message-timeline.tsx | 20 +++++++--- packages/app/src/utils/session-title.test.ts | 11 ++++++ packages/app/src/utils/session-title.ts | 4 +- packages/app/src/utils/session.ts | 2 +- packages/cli/src/acp/service.ts | 4 +- .../client/src/promise/generated/types.ts | 4 +- packages/core/schema.json | 6 +-- packages/core/src/database/migration.gen.ts | 1 + .../20260730195856_optional_session_title.ts | 14 +++++++ packages/core/src/database/schema.gen.ts | 2 +- packages/core/src/session.ts | 2 +- packages/core/src/session/info.ts | 2 +- packages/core/src/session/projector.ts | 5 ++- packages/core/src/session/sql.ts | 2 +- packages/core/test/database-migration.test.ts | 35 +++++++++++++++++ packages/core/test/session-create.test.ts | 38 +++++++++++++++++++ packages/core/test/tool-subagent.test.ts | 2 +- .../enterprise/src/routes/share/[shareID].tsx | 13 ++++--- packages/schema/src/session.ts | 2 +- packages/schema/src/v1/session.ts | 2 +- packages/schema/test/contract-hygiene.test.ts | 14 ++++++- packages/tui/src/app.tsx | 8 ++-- packages/tui/src/component/dialog-open.tsx | 3 +- .../tui/src/component/dialog-session-list.tsx | 5 ++- packages/tui/src/context/session-tabs.tsx | 18 ++++----- .../routes/session/composer/subagents-tab.tsx | 11 ++++-- packages/tui/src/routes/session/index.tsx | 3 +- packages/tui/src/routes/session/sidebar.tsx | 3 +- packages/tui/src/util/session.ts | 14 +++++-- packages/tui/test/util/session.test.ts | 12 +++++- 33 files changed, 215 insertions(+), 64 deletions(-) create mode 100644 packages/app/src/utils/session-title.test.ts create mode 100644 packages/core/src/database/migration/20260730195856_optional_session_title.ts diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 35f600c9f6..03a1049de1 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -11,6 +11,7 @@ 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. @@ -54,7 +55,10 @@ export function TabNavItem(props: { if (!session) return return projectForSession(session, serverCtx()?.projects.list() ?? []) }) - const title = createMemo(() => props.session()?.title ?? props.fallbackTitle) + const title = createMemo(() => { + const session = props.session() + return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle + }) const projectName = createMemo(() => { const session = props.session() @@ -143,7 +147,7 @@ export function TabNavItem(props: { if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return const session = props.session() if (!session) return - titleEl.textContent = session.title + titleEl.textContent = session.title ?? "" setEditing(true) requestAnimationFrame(() => { @@ -302,7 +306,7 @@ export function TabNavItem(props: { }} data={{ projectName: projectName(), - title: props.session()?.title, + title: title(), path: previewPath(), serverName: serverLabel(), }} 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 5e55c4b746..73ff0ff2ae 100644 --- a/packages/app/src/pages/home/home-session-search-controller.ts +++ b/packages/app/src/pages/home/home-session-search-controller.ts @@ -7,6 +7,7 @@ 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 @@ -23,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions if (!value) return [] return sessions.data .searchRecords() - .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)) + .filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value)) }) const active = createMemo(() => { const records = results() diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 776b81ae5a..aa5e8f378f 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -104,7 +104,7 @@ const SessionRow = (props: { warmPress: () => void warmFocus: () => void }): JSX.Element => { - const title = () => sessionTitle(props.session.title) + const title = () => sessionTitle(props.session.title, props.session.parentID) 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 dc9153536c..144517e798 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -297,7 +297,11 @@ export function MessageTimeline(props: { return sync().session.get(id) }) const titleValue = createMemo(() => info()?.title) - const titleLabel = createMemo(() => sessionTitle(titleValue())) + const titleLabel = createMemo(() => { + const session = info() + if (!session) return + return sessionTitle(titleValue(), session.parentID) + }) const shareUrl = createMemo(() => info()?.share?.url) const shareEnabled = createMemo(() => sync().data.config.share !== "disabled") const parentID = createMemo(() => info()?.parentID) @@ -311,7 +315,10 @@ export function MessageTimeline(props: { if (!id) return emptyMessages return sync().data.message[id] ?? emptyMessages }) - const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) + const parentTitle = createMemo(() => { + const session = parent() + return session ? sessionTitle(session.title, session.parentID) : 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) const childTaskDescription = createMemo(() => { @@ -329,7 +336,7 @@ export function MessageTimeline(props: { if (value) return value return language.t("command.session.new") }) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const showHeader = createMemo(() => !!(titleLabel() || parentID())) const projection = createTimelineProjection({ messages: sessionMessages, userMessages: () => props.userMessages, @@ -912,9 +919,10 @@ export function MessageTimeline(props: { } function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo( - () => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"), - ) + const name = createMemo(() => { + const session = sync().session.get(props.sessionID) + return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new") + }) const handleDelete = async () => { await deleteSession(props.sessionID) dialog.close() diff --git a/packages/app/src/utils/session-title.test.ts b/packages/app/src/utils/session-title.test.ts new file mode 100644 index 0000000000..2c13995fe4 --- /dev/null +++ b/packages/app/src/utils/session-title.test.ts @@ -0,0 +1,11 @@ +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 index ca04c01047..9db778178c 100644 --- a/packages/app/src/utils/session-title.ts +++ b/packages/app/src/utils/session-title.ts @@ -1,7 +1,7 @@ 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) { - if (!title) return title +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 faf847967b..fc22778bb0 100644 --- a/packages/app/src/utils/session.ts +++ b/packages/app/src/utils/session.ts @@ -13,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session { parentID: input.parentID, cost: input.cost, tokens: input.tokens, - title: input.title, + title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`, agent: input.agent, model: input.model, version: "", diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index 897d88c8d2..f412bfa32b 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -213,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessions: page.data.map((session) => ({ sessionId: session.id, cwd: session.location.directory, - title: session.title, + title: + session.title ?? + `${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`, updatedAt: new Date(session.time.updated).toISOString(), })), ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 8c8b6295a6..6eeddd3fc4 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1757,7 +1757,7 @@ export type SessionInfo = { cost: MoneyUSD tokens: TokenUsageInfo time: { created: number; updated: number; archived?: number } - title: string + title?: string location: LocationRef subpath?: string revert?: SessionRevert @@ -2006,7 +2006,7 @@ export type SessionV1Info = { cost?: number tokens?: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } share?: { url: string } - title: string + title?: string agent?: string model?: { id: string; providerID: string; variant?: string } version: string diff --git a/packages/core/schema.json b/packages/core/schema.json index 7ed6583e21..34e864a690 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,9 +1,9 @@ { "version": "7", "dialect": "sqlite", - "id": "db37a97f-9b5e-4c87-be8b-4feace35136c", + "id": "e43ed7e2-b9fc-4178-beae-3646e4a976e1", "prevIds": [ - "a4ba73b4-21bc-41ab-a415-94e2ca38d798" + "db37a97f-9b5e-4c87-be8b-4feace35136c" ], "ddl": [ { @@ -1302,7 +1302,7 @@ }, { "type": "text", - "notNull": true, + "notNull": false, "autoincrement": false, "default": null, "generated": null, diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index ee88d30f17..68082bf982 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -58,5 +58,6 @@ export const migrations = ( import("./migration/20260722011141_delete_tool_progress_events"), import("./migration/20260722170000_canonical_tool_results"), import("./migration/20260729022634_session_fork_boundary"), + import("./migration/20260730195856_optional_session_title"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260730195856_optional_session_title.ts b/packages/core/src/database/migration/20260730195856_optional_session_title.ts new file mode 100644 index 0000000000..a8027ca4f2 --- /dev/null +++ b/packages/core/src/database/migration/20260730195856_optional_session_title.ts @@ -0,0 +1,14 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260730195856_optional_session_title", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`ALTER TABLE \`session\` RENAME COLUMN \`title\` TO \`title_old\``) + yield* tx.run(`ALTER TABLE \`session\` ADD COLUMN \`title\` text`) + yield* tx.run(`UPDATE \`session\` SET \`title\` = \`title_old\``) + yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`title_old\``) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index 05c63f435d..7aa38f5f62 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -217,7 +217,7 @@ export default { \`slug\` text NOT NULL, \`directory\` text NOT NULL, \`path\` text, - \`title\` text NOT NULL, + \`title\` text, \`version\` text NOT NULL, \`share_url\` text, \`summary_additions\` integer, diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index bf1b38eaaf..9f5f8e4aa5 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -374,7 +374,7 @@ const layer = Layer.effect( directory: location.directory, path: path.relative(project.directory, location.directory).replaceAll("\\", "/"), workspaceID: location.workspaceID ? Workspace.ID.make(location.workspaceID) : undefined, - title: input.title ?? `New session - ${new Date(now).toISOString()}`, + title: input.title, agent: input.agent, model: input.model ? { diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index ed13a4d317..a5b05b213f 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -17,7 +17,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In return SessionSchema.Info.make({ id: SessionSchema.ID.make(row.id), projectID: Project.ID.make(row.project_id), - title: row.title, + title: row.title ?? undefined, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, fork: row.fork_session_id && row.fork_boundary diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 64ab79dea8..2498151625 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -43,7 +43,8 @@ type Usage = { const ForkBatchSize = 500 -const forkTitle = (value: string) => { +const forkTitle = (value?: string) => { + if (value === undefined) return const match = value.match(/^(.+) \(fork #(\d+)\)$/) if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` return `${value} (fork #1)` @@ -216,7 +217,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( slug: Slug.create(), directory: parent.directory, path: parent.path, - title: forkTitle(parent.title), + title: forkTitle(parent.title ?? undefined), agent: parent.agent, model: parent.model, version: parent.version, diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 9fbe678362..f75aa7718b 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -36,7 +36,7 @@ export const SessionTable = sqliteTable( slug: text().notNull(), directory: directoryColumn().notNull(), path: pathColumn(), - title: text().notNull(), + title: text(), version: text().notNull(), share_url: text(), summary_additions: integer(), diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index f8148b66f8..6dd4be0a4a 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -26,6 +26,7 @@ import timeSuspendedMigration from "@opencode-ai/core/database/migration/2026070 import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync" import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events" import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results" +import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" @@ -399,6 +400,40 @@ describe("DatabaseMigration", () => { ).rejects.toThrow("Database is not empty and has no session table") }) + test("makes session titles nullable without deleting dependent rows", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* db.run(sql` + CREATE TABLE session ( + id text PRIMARY KEY, + title text NOT NULL + ) + `) + yield* db.run(sql` + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE + ) + `) + yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`) + yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`) + + yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration]) + + expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({ + title: "Existing title", + }) + expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" }) + expect( + yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`), + ).toEqual({ notnull: 0 }) + expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 }) + }), + ) + }) + test("backfills existing Context Epoch rows to the build agent", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 1e24012518..cdc8175fb3 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -71,6 +71,27 @@ function withTmp(f: (directory: string) => Effect.Effect) { } describe("Session.create", () => { + it.effect("persists a missing title until one is generated or supplied", () => + Effect.gen(function* () { + const session = yield* Session.Service + const { db } = yield* Database.Service + + const created = yield* session.create({ location }) + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, created.id)).get().pipe(Effect.orDie) + const event = yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, created.id)) + .get() + .pipe(Effect.orDie) + + expect(created.title).toBeUndefined() + expect(row?.title).toBeNull() + expect(event?.data).not.toHaveProperty("info.title") + expect((yield* session.create({ location, title: "Explicit title" })).title).toBe("Explicit title") + }), + ) + it.effect("creates a fresh projected session when the ID is omitted", () => Effect.gen(function* () { const session = yield* Session.Service @@ -296,6 +317,23 @@ describe("Session.create", () => { }), ) + it.effect("keeps a fork untitled when its parent is untitled", () => + Effect.gen(function* () { + const session = yield* Session.Service + const bus = yield* Bus.Service + const { db } = yield* Database.Service + const parent = yield* session.create({ location }) + yield* session.prompt({ sessionID: parent.id, text: "First", resume: false }) + yield* SessionPending.promote(db, bus, parent.id, "steer") + + const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }) + const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie) + + expect(forked.title).toBeUndefined() + expect(row?.title).toBeNull() + }), + ) + it.effect("rejects forking an empty session", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 73299769f6..9546582193 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({ const completed = new Set() const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) { if (completed.has(sessionID)) return - if ((yield* store.get(sessionID))?.title.includes("fail")) { + if ((yield* store.get(sessionID))?.title?.includes("fail")) { yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID }) return } diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index dea83faed1..7af9430fbf 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -158,6 +158,11 @@ 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 ogImage = createMemo(() => { const models = new Set() const messages = data().message[data().sessionID] ?? [] @@ -167,7 +172,7 @@ export default function () { } } const modelIDs = Array.from(models) - const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700)))) + const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title().substring(0, 700)))) let modelParam: string if (modelIDs.length === 1) { modelParam = modelIDs[0] @@ -184,9 +189,7 @@ export default function () { return ( <> - - {info().title} | OpenCode - + {title()} | OpenCode @@ -240,7 +243,7 @@ export default function () { -
{info().title}
+
{title()}
) diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index e7e9525b24..4de41beb0d 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -42,7 +42,7 @@ export const Info = Schema.Struct({ updated: DateTimeUtcFromMillis, archived: DateTimeUtcFromMillis.pipe(optional), }), - title: Schema.String, + title: Schema.String.pipe(optional), location: Location.Ref, subpath: RelativePath.pipe(optional), revert: Revert.pipe(optional), diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 9da5efaf66..293c68c96d 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -552,7 +552,7 @@ export const SessionInfo = Schema.Struct({ cost: optional(Schema.Finite), tokens: optional(SessionTokens), share: optional(SessionShare), - title: Schema.String, + title: optional(Schema.String), agent: optional(Schema.String), model: optional(SessionModel), version: Schema.String, diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index ba5496e660..74df1ac627 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -17,7 +17,7 @@ import { Money } from "../src/money.js" import { Skill } from "../src/skill.js" import { Shell } from "../src/shell.js" import { PersistedRevert } from "../src/session-revert.js" -import { optional } from "../src/schema.js" +import { AbsolutePath, optional } from "../src/schema.js" describe("contract hygiene", () => { test("restricts agent colors to six-digit hex values", () => { @@ -52,6 +52,18 @@ describe("contract hygiene", () => { metadata: undefined, }), ).toEqual({ text: "completed" }) + + expect( + Schema.encodeSync(Session.Info)({ + id: Session.ID.make("ses_untitled"), + projectID: Project.ID.make("global"), + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + title: undefined, + location: { directory: AbsolutePath.make("/project") }, + }), + ).not.toHaveProperty("title") }) test("pending session items omit the internal admission sequence", () => { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9bbef09c3c..26ad36db95 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -523,7 +523,7 @@ function App(props: { pair?: DialogPairCredentials }) { renderer.useMouse = config.data.mouse }) - let active: { id: string; title: string } | undefined + let active: { id: string; title?: string } | undefined // Update terminal window title based on current route and session createEffect(() => { const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined @@ -536,13 +536,13 @@ function App(props: { pair?: DialogPairCredentials }) { } if (route.data.type === "session") { - if (!session || isDefaultTitle(session.title)) { + const title = session?.title + if (!title || isDefaultTitle(title)) { renderer.setTerminalTitle("OpenCode") return } - const title = session.title.length > 40 ? session.title.slice(0, 37) + "..." : session.title - renderer.setTerminalTitle(`OC | ${title}`) + renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`) return } diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index bad3a3c35b..120e636f5e 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -16,6 +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 { Spinner } from "./spinner" const RECENT_LIMIT = 8 @@ -79,7 +80,7 @@ export function DialogOpen() { const name = project?.name || path.basename(project?.canonical ?? session.location.directory) const running = data.session.family(session.id).some((id) => data.session.status(id) === "running") return { - title: session.title, + title: sessionTitle(session), value: { type: "session", sessionID: session.id } as OpenTarget, category: "Sessions", footer: `${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 5d08e5590c..e88cca2677 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -18,6 +18,7 @@ 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" export function DialogSessionList() { const dialog = useDialog() @@ -77,7 +78,7 @@ export function DialogSessionList() { (session.projectID === current?.project.id && session.location.directory === current.directory), ) if (!query) return sessions - return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query)) + return sessions.filter((session) => !session.parentID && sessionTitle(session).toLowerCase().includes(query)) }) const sessions = createMemo(() => { const query = filter().trim() @@ -139,7 +140,7 @@ 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` : session.title, + title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : sessionTitle(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 a93042927b..b0f0ec54e8 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -3,6 +3,7 @@ import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useClient } from "./client" import { useData } from "./data" +import { sessionTitle } from "../util/session" import { useEvent } from "./event" import { useRoute } from "./route" import { useConfig } from "../config" @@ -114,7 +115,8 @@ 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 title = data.session.get(sessionID)?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : undefined) + const session = data.session.get(sessionID) + const title = session?.title ?? (newTab() ? NEW_SESSION_TAB_TITLE : session ? sessionTitle(session) : undefined) const tabs = openSessionTab(state().tabs, { sessionID, title }) if (tabs === state().tabs && !state().unread[sessionID]) return update((draft) => { @@ -127,7 +129,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return const next = state().tabs.reduce((tabs, tab) => { const sessionID = root(tab.sessionID) - return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) + const session = data.session.get(sessionID) + return openSessionTab(tabs, { sessionID, title: session ? sessionTitle(session) : tab.title }) }, []) const unread = Object.entries(state().unread).reduce>((result, entry) => { const sessionID = root(entry[0]) @@ -136,15 +139,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, {}) if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return update((draft) => { - draft.tabs = draft.tabs.reduce((tabs, tab) => { - const sessionID = root(tab.sessionID) - return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) - }, []) - draft.unread = Object.entries(draft.unread).reduce>((result, entry) => { - const sessionID = root(entry[0]) - result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] - return result - }, {}) + draft.tabs = next + draft.unread = unread }) }) diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 49f88997bc..a6f4d5c4b0 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -8,6 +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" interface SubagentEntry { sessionID: string @@ -38,13 +39,14 @@ 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 agentMatch = sibling.title.match(/@(\w+) subagent/) + const title = sessionTitle(sibling) + const agentMatch = title.match(/@(\w+) subagent/) const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title + const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title result.push({ sessionID: sibling.id, agent, @@ -56,13 +58,14 @@ export function SubagentsTab(props: { sessionID: string }) { } else { const children = data.session.list().filter((s) => s.parentID === props.sessionID) for (const child of children) { - const agentMatch = child.title.match(/@(\w+) subagent/) + const title = sessionTitle(child) + const agentMatch = title.match(/@(\w+) subagent/) const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" - const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title + const name = agentMatch ? title.replace(agentMatch[0], "").trim() || title : title result.push({ sessionID: child.id, agent, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ef060b6899..58b5cccda6 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -93,6 +93,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" addDefaultParsers(parsers.parsers) @@ -3334,7 +3335,7 @@ function formatSessionTranscript(session: SessionInfo, messages: SessionMessageI }) return [`## Assistant\n\n${content.join("\n\n")}`] }) - return `# ${session.title}\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 `# ${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` } export function parseApplyPatchFiles(value: unknown) { diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 3de0335463..c89fed0dff 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -3,6 +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 { getScrollAcceleration } from "../../util/scroll" @@ -38,7 +39,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {session()!.title} + {sessionTitle(session()!)} {session()!.location.workspaceID} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index edec41e2d5..2b0bd7ea73 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,8 +1,16 @@ -import type { ModelInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" +import type { ModelInfo, SessionInfo, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { Locale } from "./locale" -export function isDefaultTitle(title: string) { - return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) +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) { diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 5172aa2694..7239096534 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 } from "../../src/util/session" +import { isDefaultTitle, lastAssistantWithUsage, sessionTitle } from "../../src/util/session" const assistant = (id: string, input: number): SessionMessageInfo => ({ id, @@ -14,11 +14,21 @@ 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)]