mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-30 22:56:11 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70567d551d | ||
|
|
1e6be05e12 | ||
|
|
46f20b2c49 |
@@ -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(),
|
||||
}}
|
||||
|
||||
@@ -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<HomeSessionsController, "data" | "session">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
<A
|
||||
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={sessionTitle(props.session.title)}
|
||||
value={sessionTitle(props.session.title, props.session.parentID)}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -71,6 +71,27 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -47,7 +47,7 @@ const executionNode = makeGlobalNode({
|
||||
const completed = new Set<Session.ID>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@ export default function () {
|
||||
}
|
||||
}
|
||||
const modelIDs = Array.from(models)
|
||||
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(info().title.substring(0, 700))))
|
||||
const title = info().title ?? `New session - ${new Date(info().time.created).toISOString()}`
|
||||
const encodedTitle = encodeURIComponent(Base64.encode(encodeURIComponent(title.substring(0, 700))))
|
||||
let modelParam: string
|
||||
if (modelIDs.length === 1) {
|
||||
modelParam = modelIDs[0]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -536,13 +536,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
|
||||
if (route.data.type === "session") {
|
||||
const session = data.session.get(route.data.sessionID)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { sessionTitle } from "../util/session"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -75,7 +76,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()
|
||||
@@ -137,7 +138,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,
|
||||
|
||||
@@ -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<SessionTab[]>((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<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
@@ -138,7 +141,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
update((draft) => {
|
||||
draft.tabs = draft.tabs.reduce<SessionTab[]>((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 })
|
||||
}, [])
|
||||
draft.unread = Object.entries(draft.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const sessionID = root(entry[0])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -92,6 +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"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -3307,7 +3308,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) {
|
||||
|
||||
@@ -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 }) {
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{session()!.title}</b>
|
||||
<b>{sessionTitle(session()!)}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
|
||||
@@ -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<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) {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user