Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/app/src/context/global-sync/session-trim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
5 changes: 3 additions & 2 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions packages/tui/src/util/session.ts
Original file line number Diff line number Diff line change
@@ -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<T extends { id: string; role: string }>(
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<T extends { id: string; time: { created: number; updated?: number } }>(
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
}
44 changes: 43 additions & 1 deletion packages/tui/test/util/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,52 @@
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", () => {
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("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"])
})
})
Loading