diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..a6d06f51c686 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -44,6 +44,7 @@ export const MessagesQuery = Schema.Struct({ ...WorkspaceRoutingQueryFields, limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), before: Schema.optional(Schema.String), + after: Schema.optional(Schema.String), }) export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) export const UpdatePayload = Schema.Struct({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..deefa60e804c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Database } from "@opencode-ai/core/database/database" import { Agent } from "@/agent/agent" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" @@ -48,6 +49,7 @@ const tryParseJson = (text: string) => export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) => Effect.gen(function* () { const session = yield* Session.Service + const database = yield* Database.Service const shareSvc = yield* SessionShare.Service const promptSvc = yield* SessionPrompt.Service const revertSvc = yield* SessionRevert.Service @@ -107,11 +109,13 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { - if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) - if (ctx.query.before) { - const before = ctx.query.before + if ((ctx.query.before || ctx.query.after) && ctx.query.limit === undefined) + return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before && ctx.query.after) return yield* new HttpApiError.BadRequest({}) + const cursor = ctx.query.before ?? ctx.query.after + if (cursor) { yield* Effect.try({ - try: () => MessageV2.cursor.decode(before), + try: () => MessageV2.cursor.decode(cursor), catch: () => new HttpApiError.BadRequest({}), }) } @@ -125,7 +129,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", sessionID: ctx.params.sessionID, limit: ctx.query.limit, before: ctx.query.before, - }), + after: ctx.query.after, + }).pipe(Effect.provideService(Database.Service, database)), ) if (!page.cursor) return page.items @@ -134,7 +139,8 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", // header echoes the real origin instead of a hard-coded localhost. const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) - url.searchParams.set("before", page.cursor) + const direction = ctx.query.after ? "after" : "before" + url.searchParams.set(direction, page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { headers: { "Access-Control-Expose-Headers": "Link, X-Next-Cursor", diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1bea9f52c3ec..5cdf01247636 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -26,6 +26,8 @@ import { desc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { lt } from "drizzle-orm" +import { gt } from "drizzle-orm" +import { asc } from "drizzle-orm" import { or } from "drizzle-orm" import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql" import { ProviderError } from "@/provider/error" @@ -95,11 +97,15 @@ const part = (row: typeof PartTable.$inferSelect) => const older = (row: Cursor) => or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) -function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) { +const newer = (row: Cursor) => + or(gt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), gt(MessageTable.id, row.id))) + +function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { const ids = rows.map((row) => row.id) const partByMessage = new Map() return Effect.gen(function* () { if (ids.length > 0) { + const { db } = yield* Database.Service const partRows = yield* db .select() .from(PartTable) @@ -426,17 +432,26 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { sessionID: SessionID limit: number before?: string + after?: string }) { - const { db } = yield* Database.Service + if (input.before && input.after) + throw new Error("page: only one of `before` or `after` may be provided") const before = input.before ? cursor.decode(input.before) : undefined + const after = input.after ? cursor.decode(input.after) : undefined const where = before ? and(eq(MessageTable.session_id, input.sessionID), older(before)) - : eq(MessageTable.session_id, input.sessionID) + : after + ? and(eq(MessageTable.session_id, input.sessionID), newer(after)) + : eq(MessageTable.session_id, input.sessionID) + const { db } = yield* Database.Service const rows = yield* db .select() .from(MessageTable) .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) + .orderBy( + after ? asc(MessageTable.time_created) : desc(MessageTable.time_created), + after ? asc(MessageTable.id) : desc(MessageTable.id), + ) .limit(input.limit + 1) .all() .pipe(Effect.orDie) @@ -456,13 +471,13 @@ export const page = Effect.fn("MessageV2.page")(function* (input: { const more = rows.length > input.limit const slice = more ? rows.slice(0, input.limit) : rows - const items = yield* hydrate(db, slice) - items.reverse() - const tail = slice.at(-1) + const items = yield* hydrate(slice) + if (!after) items.reverse() + const cursorRow = slice.at(-1) return { items, more, - cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, + cursor: more && cursorRow ? cursor.encode({ id: cursorRow.id, time: cursorRow.time_created }) : undefined, } }) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index b67c982ebcd3..9376db400815 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -291,6 +291,46 @@ describe("MessageV2.page", () => { }), ) + it.instance("pages forward with after cursor", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + const ids = yield* fill(sessionID, 6) + + // Anchor at "before everything": all messages are newer than time 0 + const anchor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) + + const a = yield* MessageV2.page({ sessionID, limit: 2, after: anchor }) + expect(a.items.map((item) => item.info.id)).toEqual(ids.slice(0, 2)) + expect(a.more).toBe(true) + expect(a.cursor).toBeTruthy() + + const b = yield* MessageV2.page({ sessionID, limit: 2, after: a.cursor! }) + expect(b.items.map((item) => item.info.id)).toEqual(ids.slice(2, 4)) + expect(b.more).toBe(true) + expect(b.cursor).toBeTruthy() + + const c = yield* MessageV2.page({ sessionID, limit: 2, after: b.cursor! }) + expect(c.items.map((item) => item.info.id)).toEqual(ids.slice(4, 6)) + expect(c.more).toBe(false) + expect(c.cursor).toBeUndefined() + }), + ), + ) + + it.instance("rejects requests with both before and after", () => + withSession(({ sessionID }) => + Effect.gen(function* () { + yield* fill(sessionID, 2) + const dummyCursor = MessageV2.cursor.encode({ id: MessageID.ascending(), time: 0 }) + + const exit = yield* Effect.exit( + MessageV2.page({ sessionID, limit: 2, before: dummyCursor, after: dummyCursor }), + ) + expect(exit._tag).toBe("Failure") + }), + ), + ) + it.instance("large limit returns all messages without cursor", () => withSession(({ sessionID }) => Effect.gen(function* () { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 5e4fd8906155..ac72368ec7e2 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -2556,6 +2556,8 @@ export type SessionMessagesData = { query?: { directory?: string limit?: number + before?: string + after?: string } url: "/session/{id}/message" } diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..672628d995ce 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -3710,6 +3710,7 @@ export class Session2 extends HeyApiClient { workspace?: string limit?: number before?: string + after?: string }, options?: Options, ) { @@ -3723,6 +3724,7 @@ export class Session2 extends HeyApiClient { { in: "query", key: "workspace" }, { in: "query", key: "limit" }, { in: "query", key: "before" }, + { in: "query", key: "after" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d32..6511262f7f3c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9758,6 +9758,7 @@ export type SessionMessagesData = { workspace?: string limit?: number before?: string + after?: string } url: "/session/{sessionID}/message" } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b300754bc859..6b989e1a31ce 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6132,6 +6132,14 @@ "type": "string" }, "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], "responses": { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index d0511c5183e4..e2401d6b0b1e 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -105,6 +105,18 @@ export const { } formatter: FormatterStatus[] vcs: VcsInfo | undefined + messageOlderCursor: { + [sessionID: string]: string | null + } + messageNewerCursor: { + [sessionID: string]: string | null + } + messageOlderLoading: { + [sessionID: string]: boolean + } + messageNewerLoading: { + [sessionID: string]: boolean + } }>({ provider_next: { all: [], @@ -135,6 +147,10 @@ export const { mcp_resource: {}, formatter: [], vcs: undefined, + messageOlderCursor: {}, + messageNewerCursor: {}, + messageOlderLoading: {}, + messageNewerLoading: {}, }) const event = useEvent() @@ -313,43 +329,37 @@ export const { } case "message.updated": { - touchMessage(event.properties.info.sessionID, event.properties.info.id) - const messages = store.message[event.properties.info.sessionID] + const sessionID = event.properties.info.sessionID + const messages = store.message[sessionID] if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", sessionID, [event.properties.info]) break } const result = search(messages, event.properties.info.id, (m) => m.id) if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + setStore("message", sessionID, result.index, reconcile(event.properties.info)) break } + // If the bottom of the window has been evicted (messageNewerCursor + // is set), drop messages that arrive past our visible tail. They + // will be loaded on demand when the user scrolls back down. + if (store.messageNewerCursor[sessionID]) { + const last = messages[messages.length - 1] + if (last) { + const incoming = event.properties.info + const isPastTail = + incoming.time.created > last.time.created || + (incoming.time.created === last.time.created && incoming.id > last.id) + if (isPastTail) break + } + } setStore( "message", - event.properties.info.sessionID, + sessionID, produce((draft) => { draft.splice(result.index, 0, event.properties.info) }), ) - const updated = store.message[event.properties.info.sessionID] - if (updated.length > 100) { - const oldest = updated[0] - batch(() => { - setStore( - "message", - event.properties.info.sessionID, - produce((draft) => { - draft.shift() - }), - ) - setStore( - "part", - produce((draft) => { - delete draft[oldest.id] - }), - ) - }) - } break } case "message.removed": { @@ -368,20 +378,27 @@ export const { break } case "message.part.updated": { - touchPart(event.properties.part.sessionID, event.properties.part.id) - const parts = store.part[event.properties.part.messageID] + const sessionID = event.properties.part.sessionID + const messageID = event.properties.part.messageID + const parts = store.part[messageID] + // If the parent message isn't in our window AND the window's + // bottom has been evicted, drop the part - it would otherwise + // be orphaned in store.part with no message to attach to. + const messages = store.message[sessionID] + const inWindow = !messages || search(messages, messageID, (m) => m.id).found if (!parts) { - setStore("part", event.properties.part.messageID, [event.properties.part]) + if (!inWindow && store.messageNewerCursor[sessionID]) break + setStore("part", messageID, [event.properties.part]) break } const result = search(parts, event.properties.part.id, (p) => p.id) if (result.found) { - setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part)) + setStore("part", messageID, result.index, reconcile(event.properties.part)) break } setStore( "part", - event.properties.part.messageID, + messageID, produce((draft) => { draft.splice(result.index, 0, event.properties.part) }), @@ -587,76 +604,163 @@ export const { }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return - const syncing = syncingSessions.get(sessionID) - if (syncing) return syncing - const tracker = { messages: new Set(), parts: new Set() } - hydratingSessions.set(sessionID, tracker) - const task = (async () => { + const hydration = { messages: new Set(), parts: new Set() } + hydratingSessions.set(sessionID, hydration) + try { const [session, messages, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), + sdk.client.session.messages({ sessionID, limit: INITIAL_PAGE_SIZE }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + const olderCursor = (messages.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null setStore( produce((draft) => { const match = search(draft.session, sessionID, (s) => s.id) if (match.found) draft.session[match.index] = session.data! if (!match.found) draft.session.splice(match.index, 0, session.data!) draft.todo[sessionID] = todo.data ?? [] - const currentMessages = draft.message[sessionID] ?? [] - const infos = (messages.data ?? []).flatMap((message) => { - if (!tracker.messages.has(message.info.id)) return [message.info] - const current = currentMessages.find((item) => item.id === message.info.id) - return current ? [current] : [] - }) - infos.push( - ...currentMessages.filter( - (message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id), - ), - ) - const removed = infos.slice(0, -100) - const visible = infos.slice(-100) - const visibleIDs = new Set(visible.map((message) => message.id)) - for (const message of messages.data ?? []) { - if (!visibleIDs.has(message.info.id)) { - delete draft.part[message.info.id] - continue - } - const currentParts = draft.part[message.info.id] ?? [] - const parts = message.parts.flatMap((part) => { - const current = currentParts.find((item) => item.id === part.id) - if (tracker.parts.has(part.id)) return current ? [current] : [] - if ( - current && - (part.type === "text" || part.type === "reasoning") && - (current.type === "text" || current.type === "reasoning") && - part.text.length === 0 && - current.text.length > 0 - ) { - return [current] + if (messages.data) { + const existing = draft.message[sessionID] ?? [] + const infos: (typeof draft.message)[string] = [] + const hydratedIDs = new Set() + for (const message of messages.data) { + hydratedIDs.add(message.info.id) + const current = existing.find((item) => item.id === message.info.id) + if (current) { + infos.push(current) + continue } - return [part] - }) - parts.push( - ...currentParts.filter( - (part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id), - ), - ) - draft.part[message.info.id] = parts + if (hydration.messages.has(message.info.id)) continue + infos.push(message.info) + draft.part[message.info.id] = message.parts + } + for (const message of existing) { + if (hydratedIDs.has(message.id)) continue + infos.push(message) + } + while (infos.length > INITIAL_PAGE_SIZE) { + const evicted = infos.shift() + if (evicted) delete draft.part[evicted.id] + } + draft.message[sessionID] = infos + draft.messageOlderCursor[sessionID] = olderCursor + draft.messageNewerCursor[sessionID] = null } - for (const message of removed) delete draft.part[message.id] - draft.message[sessionID] = visible draft.session_diff[sessionID] = diff.data ?? [] }), ) - fullSyncedSessions.add(sessionID) - })().finally(() => { - syncingSessions.delete(sessionID) + if (!olderCursor) fullSyncedSessions.add(sessionID) + } finally { hydratingSessions.delete(sessionID) - }) - syncingSessions.set(sessionID, task) - return task + } + }, + async loadOlderMessages(sessionID: string) { + const cursor = store.messageOlderCursor[sessionID] + if (!cursor || store.messageOlderLoading[sessionID]) return + setStore("messageOlderLoading", sessionID, true) + try { + const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, before: cursor }) + const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const prepend: Message[] = [] + for (const m of res.data ?? []) { + draft.part[m.info.id] = m.parts + prepend.push(m.info) + } + draft.message[sessionID] = [...prepend, ...existing] + draft.messageOlderCursor[sessionID] = nextCursor + }), + ) + if (!nextCursor && !store.messageNewerCursor[sessionID]) fullSyncedSessions.add(sessionID) + } finally { + setStore("messageOlderLoading", sessionID, false) + } + }, + async loadNewerMessages(sessionID: string) { + const cursor = store.messageNewerCursor[sessionID] + if (!cursor || store.messageNewerLoading[sessionID]) return + setStore("messageNewerLoading", sessionID, true) + try { + const res = await sdk.client.session.messages({ sessionID, limit: PAGE_SIZE, after: cursor }) + const nextCursor = (res.response?.headers.get("x-next-cursor") as string | null | undefined) ?? null + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const append: Message[] = [] + for (const m of res.data ?? []) { + draft.part[m.info.id] = m.parts + append.push(m.info) + } + draft.message[sessionID] = [...existing, ...append] + draft.messageNewerCursor[sessionID] = nextCursor + }), + ) + if (!nextCursor && !store.messageOlderCursor[sessionID]) fullSyncedSessions.add(sessionID) + } finally { + setStore("messageNewerLoading", sessionID, false) + } + }, + trimNewerMessages(sessionID: string, cap: number) { + const messages = store.message[sessionID] + if (!messages || messages.length <= cap) return + // Find the largest "safe" prefix length we can keep without + // discarding a message that's still in flight (assistants + // currently streaming) - those need to remain pinned so live + // events can update them. + let target = cap + while (target < messages.length) { + const tail = messages.slice(target) + const hasInflight = tail.some( + (m) => m.role === "assistant" && !m.time?.completed, + ) + if (!hasInflight) break + target++ + } + if (target >= messages.length) return + const evicted = messages.slice(target) + const newLast = messages[target - 1] + if (!newLast) return + const cursorVal = encodeMessageCursor({ id: newLast.id, time: newLast.time.created }) + setStore( + produce((draft) => { + const arr = draft.message[sessionID] + for (const ev of evicted) delete draft.part[ev.id] + arr.length = target + draft.messageNewerCursor[sessionID] = cursorVal + }), + ) + fullSyncedSessions.delete(sessionID) + }, + trimOlderMessages(sessionID: string, cap: number) { + const messages = store.message[sessionID] + if (!messages || messages.length <= cap) return + const drop = messages.length - cap + const evicted = messages.slice(0, drop) + const newFirst = messages[drop] + if (!newFirst) return + const cursorVal = encodeMessageCursor({ id: newFirst.id, time: newFirst.time.created }) + setStore( + produce((draft) => { + const arr = draft.message[sessionID] + for (const ev of evicted) delete draft.part[ev.id] + arr.splice(0, drop) + draft.messageOlderCursor[sessionID] = cursorVal + }), + ) + fullSyncedSessions.delete(sessionID) + }, + async loadAllMessages(sessionID: string) { + // Page through both directions until exhausted. Used by the + // Timeline dialog so it can render every prompt in the session. + while (store.messageOlderCursor[sessionID]) { + await result.session.loadOlderMessages(sessionID) + } + while (store.messageNewerCursor[sessionID]) { + await result.session.loadNewerMessages(sessionID) + } }, }, bootstrap, @@ -664,3 +768,10 @@ export const { return result }, }) + +const INITIAL_PAGE_SIZE = 100 +const PAGE_SIZE = 50 + +function encodeMessageCursor(input: { id: string; time: number }): string { + return Buffer.from(JSON.stringify(input)).toString("base64url") +} diff --git a/packages/tui/src/routes/session/dialog-timeline.tsx b/packages/tui/src/routes/session/dialog-timeline.tsx index bda87119d4b5..11ba58d9a34f 100644 --- a/packages/tui/src/routes/session/dialog-timeline.tsx +++ b/packages/tui/src/routes/session/dialog-timeline.tsx @@ -17,6 +17,7 @@ export function DialogTimeline(props: { onMount(() => { dialog.setSize("large") + void sync.session.loadAllMessages(props.sessionID) }) const options = createMemo((): DialogSelectOption[] => { @@ -31,7 +32,7 @@ export function DialogTimeline(props: { result.push({ title: part.text.replace(/\n/g, " "), value: message.id, - footer: Locale.time(message.time.created), + footer: Locale.datetimeFull(message.time.created), onSelect: (dialog) => { dialog.replace(() => ( diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..6f354b2c1be7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -420,6 +420,73 @@ export function Session() { }, 50) } + // Pagination + asymmetric windowing + const WINDOW_CAP = 200 + + async function maybeLoadOlderMessages() { + if (!scroll || scroll.isDestroyed) return + if (!sync.data.messageOlderCursor[route.sessionID]) return + if (sync.data.messageOlderLoading[route.sessionID]) return + if (scroll.scrollTop > 5) return + // Anchor-based scroll restoration: identify the first visible child + // so we can restore its position after content changes at either end. + // Note: child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + const anchor = scroll.getChildren().find((c) => c.id && c.y >= scroll.y) + const anchorId = anchor?.id + const anchorOffset = anchor ? anchor.y - scroll.y : undefined + await sync.session.loadOlderMessages(route.sessionID) + // Trim from the bottom if the user is well above it - only safe when + // there's room above the live tail and no message there is still + // streaming. trimNewerMessages itself enforces the streaming guard. + const messages = sync.data.message[route.sessionID] ?? [] + if (messages.length > WINDOW_CAP && scroll.scrollHeight - scroll.scrollTop > scroll.height * 4) { + sync.session.trimNewerMessages(route.sessionID, WINDOW_CAP) + } + restoreScrollAnchor(anchorId, anchorOffset) + } + + async function maybeLoadNewerMessages() { + if (!scroll || scroll.isDestroyed) return + if (!sync.data.messageNewerCursor[route.sessionID]) return + if (sync.data.messageNewerLoading[route.sessionID]) return + const distanceFromBottom = scroll.scrollHeight - scroll.height - scroll.scrollTop + if (distanceFromBottom > 5) return + // Anchor-based scroll restoration: identify the first visible child + // so we can restore its position after content changes at either end. + // Note: child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + const anchor = scroll.getChildren().find((c) => c.id && c.y >= scroll.y) + const anchorId = anchor?.id + const anchorOffset = anchor ? anchor.y - scroll.y : undefined + await sync.session.loadNewerMessages(route.sessionID) + // Trim from the top - older messages can always be re-fetched via the + // older cursor, no streaming concern. + const messages = sync.data.message[route.sessionID] ?? [] + if (messages.length > WINDOW_CAP && scroll.scrollTop > scroll.height * 4) { + sync.session.trimOlderMessages(route.sessionID, WINDOW_CAP) + } + restoreScrollAnchor(anchorId, anchorOffset) + } + + // Anchor-based scroll restoration: after content changes at either end, + // reposition the viewport so the previously-visible anchor child stays + // in place. child.y includes the scroll offset, so child.y - scroll.y + // gives the offset from the viewport top regardless of scroll position. + function restoreScrollAnchor(anchorId?: string, anchorOffset?: number) { + setTimeout(() => { + if (!scroll || scroll.isDestroyed) return + if (anchorId === undefined || anchorOffset === undefined) return + const child = scroll.getChildren().find((c) => c.id === anchorId) + if (child) scroll.scrollBy(child.y - scroll.y - anchorOffset) + }, 0) + } + + function maybeLoadAdjacent() { + void maybeLoadOlderMessages() + void maybeLoadNewerMessages() + } + const local = useLocal() function enterChild(sessionID: string) { @@ -749,6 +816,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -759,6 +827,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 2) + maybeLoadAdjacent() dialog.clear() }, }, @@ -769,6 +838,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -779,6 +849,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(1) + maybeLoadAdjacent() dialog.clear() }, }, @@ -789,6 +860,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -799,6 +871,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(scroll.height / 4) + maybeLoadAdjacent() dialog.clear() }, }, @@ -809,6 +882,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + maybeLoadAdjacent() dialog.clear() }, }, @@ -819,6 +893,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(scroll.scrollHeight) + maybeLoadAdjacent() dialog.clear() }, }, @@ -1182,7 +1257,17 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + onMouseScroll={() => { + // Defer until after the scrollbox has applied the scroll + // delta so scroll.y reflects the post-event position. + setTimeout(() => maybeLoadAdjacent(), 0) + }} > + + + Loading older messages… + + {(message, index) => ( @@ -1278,6 +1363,11 @@ export function Session() { )} + + + Loading newer messages… + + 0}> @@ -1426,7 +1516,7 @@ function UserMessage(props: { - {Locale.todayTimeOrDateTime(props.message.time.created)} + {Locale.datetimeFull(props.message.time.created)} diff --git a/packages/tui/src/util/locale.ts b/packages/tui/src/util/locale.ts index ddcb973e7439..5938cf323612 100644 --- a/packages/tui/src/util/locale.ts +++ b/packages/tui/src/util/locale.ts @@ -14,6 +14,11 @@ export function datetime(input: number): string { return `${localTime} · ${localDate}` } +export function datetimeFull(input: number): string { + const date = new Date(input) + return `${String(date.getDate()).padStart(2, "0")}/${String(date.getMonth() + 1).padStart(2, "0")}/${date.getFullYear()} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}` +} + export function todayTimeOrDateTime(input: number): string { const date = new Date(input) const now = new Date()