diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 971c9e2ccb..ad7621c427 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -60,7 +60,7 @@ import { PromptStashProvider } from "./component/prompt/stash" import { DialogAlert } from "./ui/dialog-alert" import { DialogConfirm } from "./ui/dialog-confirm" import { ToastProvider, useToast } from "./ui/toast" -import { isDefaultTitle } from "./util/session" +import { isDefaultTitle, selectContinueSession, selectEmptySession } from "./util/session" import { KVProvider, useKV } from "./context/kv" import * as Model from "./util/model" import { ArgsProvider, useArgs, type Args } from "./context/args" @@ -525,11 +525,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi async function findEmptySession() { const list = await sdk.client.session.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000 }) - const match = (list.data ?? []) - .filter((x) => !x.parentID && !x.time.archived && isDefaultTitle(x.title)) - .filter((x) => !sdk.directory || x.directory === sdk.directory) - .toSorted((a, b) => b.time.updated - a.time.updated) - .at(0) + const match = selectEmptySession(list.data ?? [], sdk.directory) if (!match) return const messages = await sdk.client.session.messages({ sessionID: match.id, limit: 1 }) if (messages.data?.length !== 0) return @@ -557,10 +553,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if (continued || sync.status === "loading" || !args.continue) return // Skip default-titled sessions: those are the empty ones auto-created at // startup, and -c should land on the last real conversation. - const sorted = sync.data.session - .toSorted((a, b) => b.time.updated - a.time.updated) - .filter((x) => x.parentID === undefined) - const match = (sorted.find((x) => !isDefaultTitle(x.title)) ?? sorted[0])?.id + const match = selectContinueSession(sync.data.session)?.id if (match) { continued = true if (args.fork) { diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d0..cd0d8d9b58 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,29 @@ 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) } + +type SessionSummary = { + title: string + directory?: string + parentID?: string + time: { updated: number; archived?: number } +} + +// Fork: the empty session that plain startup reuses — the newest parentless, +// unarchived, default-titled session in the current directory. Callers still +// confirm it has zero messages before adopting it. +export function selectEmptySession(sessions: readonly T[], directory?: string) { + return sessions + .filter((x) => !x.parentID && !x.time.archived && isDefaultTitle(x.title)) + .filter((x) => !directory || x.directory === directory) + .toSorted((a, b) => b.time.updated - a.time.updated) + .at(0) +} + +// Fork: the session `-c` lands on — the newest parentless conversation, +// skipping default-titled ones (the empty sessions auto-created at startup) so +// it resumes the last real conversation, falling back to the newest overall. +export function selectContinueSession(sessions: readonly T[]) { + const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated).filter((x) => x.parentID === undefined) + return sorted.find((x) => !isDefaultTitle(x.title)) ?? sorted.at(0) +} diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index faf889781f..6516767961 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { isDefaultTitle } from "../../src/util/session" +import { isDefaultTitle, selectContinueSession, selectEmptySession } from "../../src/util/session" describe("util.session", () => { test("recognizes generated parent and child titles", () => { @@ -8,3 +8,86 @@ describe("util.session", () => { expect(isDefaultTitle("New session - custom")).toBeFalse() }) }) + +function session(overrides: { + id: string + title?: string + directory?: string + parentID?: string + archived?: number + updated?: number +}) { + return { + id: overrides.id, + title: overrides.title ?? "New session - 2026-06-06T12:34:56.789Z", + directory: overrides.directory, + parentID: overrides.parentID, + time: { updated: overrides.updated ?? 0, archived: overrides.archived }, + } +} + +describe("selectEmptySession", () => { + test("returns the newest reusable empty session in the directory", () => { + const match = selectEmptySession( + [ + session({ id: "old", directory: "/work", updated: 1 }), + session({ id: "new", directory: "/work", updated: 5 }), + ], + "/work", + ) + expect(match?.id).toBe("new") + }) + + test("ignores archived, parented, titled, and other-directory sessions", () => { + const match = selectEmptySession( + [ + session({ id: "archived", directory: "/work", archived: 123, updated: 9 }), + session({ id: "child", directory: "/work", parentID: "root", updated: 9 }), + session({ id: "titled", directory: "/work", title: "Fix the bug", updated: 9 }), + session({ id: "elsewhere", directory: "/other", updated: 9 }), + session({ id: "reusable", directory: "/work", updated: 2 }), + ], + "/work", + ) + expect(match?.id).toBe("reusable") + }) + + test("matches any directory when none is provided", () => { + const match = selectEmptySession([session({ id: "any", directory: "/somewhere", updated: 3 })]) + expect(match?.id).toBe("any") + }) + + test("returns undefined when nothing is reusable", () => { + expect(selectEmptySession([session({ id: "titled", title: "Real work" })], "/work")).toBeUndefined() + }) +}) + +describe("selectContinueSession", () => { + test("prefers the newest non-default-titled conversation", () => { + const match = selectContinueSession([ + session({ id: "empty", updated: 10 }), + session({ id: "real", title: "Ship the feature", updated: 4 }), + ]) + expect(match?.id).toBe("real") + }) + + test("falls back to the newest session when all are default-titled", () => { + const match = selectContinueSession([ + session({ id: "older", updated: 1 }), + session({ id: "newer", updated: 8 }), + ]) + expect(match?.id).toBe("newer") + }) + + test("skips child sessions", () => { + const match = selectContinueSession([ + session({ id: "child", title: "Ship the feature", parentID: "root", updated: 10 }), + session({ id: "root", title: "Earlier work", updated: 3 }), + ]) + expect(match?.id).toBe("root") + }) + + test("returns undefined for an empty list", () => { + expect(selectContinueSession([])).toBeUndefined() + }) +})