diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts index be12c074b5dc..b4fdb50671ae 100644 --- a/packages/app/src/context/global-sync/session-trim.test.ts +++ b/packages/app/src/context/global-sync/session-trim.test.ts @@ -56,4 +56,19 @@ describe("trimSessions", () => { "root-2", ]) }) + + test("keeps the returned list sorted by id for Binary.search consumers", () => { + // store.session must stay id-ordered: event-reducer / directory-sync / + // layout / home-sessions-controller binary search it with `(s) => s.id`. + const now = 1_000_000 + const list = [ + session({ id: "ses_000b546a1ffeVEfro39BIgUSKd", created: now - 100_000 }), + session({ id: "ses_ffffb5fe2ffe3fyEdnrtQqBz28", created: now - 90_000 }), + session({ id: "ses_0065dfc6cffe0oXoDMduY66CKc", created: now - 80_000 }), + ] + const result = trimSessions(list, { limit: 10, permission: {}, now }) + const ids = result.map((x) => x.id) + const sorted = ids.toSorted() + expect(ids).toEqual(sorted) + }) }) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 61abe4abd8d9..ebfb41b6627e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -39,6 +39,7 @@ import type { } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" +import { compareSessionsByTime, nextUserMessageAfter } from "../../util/session" import { webSearchProviderLabel } from "../../util/tool-display" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "../../context/sdk" @@ -208,7 +209,7 @@ export function Session() { const parentID = session()?.parentID ?? session()?.id return sync.data.session .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .toSorted(compareSessionsByTime) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const messagesBeforeRevert = () => { @@ -656,7 +657,7 @@ export function Session() { dialog.clear() const messageID = session()?.revert?.messageID if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) + const message = nextUserMessageAfter(messages(), messageID) if (!message) { void sdk.client.session.unrevert({ sessionID: route.sessionID, diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d093..61fc91b6f7f1 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,32 @@ 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) } + +/** + * Returns the first user message after `messageID` by array position. + * Message ids carry a wrapping time prefix, so the caller's list must be + * ordered chronologically. + */ +export function nextUserMessageAfter( + messages: readonly T[], + messageID: string, +): T | undefined { + const index = messages.findIndex((message) => message.id === messageID) + if (index < 0) return undefined + return messages.slice(index + 1).find((message) => message.role === "user") +} + +/** + * Orders sessions by `time.updated` (falling back to `time.created`), newest + * first, ties by id. + * Session ids carry a wrapping time prefix, so time is the stable ordering. + */ +export function compareSessionsByTime( + a: T, + b: T, +) { + const aTime = a.time.updated ?? a.time.created + const bTime = b.time.updated ?? b.time.created + if (aTime !== bTime) return bTime - aTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index faf889781f59..3bbd27ca799e 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,5 +1,16 @@ import { describe, expect, test } from "bun:test" -import { isDefaultTitle } from "../../src/util/session" +import { compareSessionsByTime, isDefaultTitle, nextUserMessageAfter } from "../../src/util/session" + +// Message ids carry a 48-bit time prefix that wraps every ~795 days: after a +// wrap, fresh ids (msg_0000...) sort below older ones (msg_ff42...). +// Redo boundaries use array position. +const messages = [ + { id: "msg_ff423c83f001ibGzwcHELsDfop", role: "user" }, // pre-wrap, oldest + { id: "msg_ff5ad131a001WhNqR6oX2N7KRS", role: "assistant" }, + { id: "msg_0000267f1001NBgzca5UNHQr7n", role: "user" }, // post-wrap revert point + { id: "msg_000029787001DYbXAVN3eyKZBl", role: "assistant" }, + { id: "msg_000030605001m8cCZxYiweZ9n7", role: "user" }, // actual next user +] as const describe("util.session", () => { test("recognizes generated parent and child titles", () => { @@ -7,4 +18,35 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + test("nextUserMessageAfter picks the next user message by position", () => { + // msg_ff42... sorts above all post-wrap ids, so id order and time order + // diverge in this fixture. + const naive = messages.find((x) => x.role === "user" && x.id > "msg_0000267f1001NBgzca5UNHQr7n") + expect(naive?.id).toBe("msg_ff423c83f001ibGzwcHELsDfop") + const next = nextUserMessageAfter(messages, "msg_0000267f1001NBgzca5UNHQr7n") + expect(next?.id).toBe("msg_000030605001m8cCZxYiweZ9n7") + }) + + test("nextUserMessageAfter returns undefined when the revert point has no following user message", () => { + expect(nextUserMessageAfter(messages, "msg_000030605001m8cCZxYiweZ9n7")).toBeUndefined() + }) + + test("nextUserMessageAfter returns undefined for an unknown message id", () => { + expect(nextUserMessageAfter(messages, "msg_does-not-exist")).toBeUndefined() + }) + + test("compareSessionsByTime orders child sessions by updated time, not id", () => { + // Session ids carry an inverted time prefix that wraps: a newer post-wrap session + // (ses_ffff...) sorts after an older pre-wrap one (ses_000b...) by id. + // Sessions are ordered by time. + const older = { id: "ses_000b546a1ffeVEfro39BIgUSKd", time: { created: 1_000 } as { created: number; updated?: number } } + const newer = { id: "ses_ffffb5fe2ffe3fyEdnrtQqBz28", time: { created: 2_000 } as { created: number; updated?: number } } + + const byId = [newer, older].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + expect(byId[0]?.id).toBe("ses_000b546a1ffeVEfro39BIgUSKd") + + const byTime = [newer, older].sort(compareSessionsByTime) + expect(byTime.map((s) => s.id)).toEqual(["ses_ffffb5fe2ffe3fyEdnrtQqBz28", "ses_000b546a1ffeVEfro39BIgUSKd"]) + }) })