diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 5f43c341bc0b..4a6f7e644254 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -15,8 +15,14 @@ import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" +import { compareMessages } from "@/context/revert-page" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +const messageIndex = (messages: readonly Message[], id: string) => messages.findIndex((message) => message.id === id) +const messageInsertIndex = (messages: readonly Message[], message: Message) => { + const index = messages.findIndex((item) => compareMessages(message, item) < 0) + return index === -1 ? messages.length : index +} export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } @@ -188,16 +194,16 @@ export function applyDirectoryEvent(input: { input.setStore("message", info.sessionID, [info]) break } - const result = Binary.search(messages, info.id, (m) => m.id) - if (result.found) { - input.setStore("message", info.sessionID, result.index, reconcile(info)) + const index = messageIndex(messages, info.id) + if (index !== -1) { + input.setStore("message", info.sessionID, index, reconcile(info)) break } input.setStore( "message", info.sessionID, produce((draft) => { - draft.splice(result.index, 0, info) + draft.splice(messageInsertIndex(draft, info), 0, info) }), ) break @@ -208,8 +214,8 @@ export function applyDirectoryEvent(input: { produce((draft) => { const messages = draft.message[props.sessionID] if (messages) { - const result = Binary.search(messages, props.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) + const index = messageIndex(messages, props.messageID) + if (index !== -1) messages.splice(index, 1) } delete draft.part[props.messageID] }), diff --git a/packages/app/src/context/revert-page.test.ts b/packages/app/src/context/revert-page.test.ts new file mode 100644 index 000000000000..4245dbd90924 --- /dev/null +++ b/packages/app/src/context/revert-page.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" +import { hasVisibleUserBeforeRevert, loadRevertAwareLatestPage } from "./revert-page" + +const message = (id: string, role: Message["role"]): Message => + role === "assistant" + ? ({ + id, + sessionID: "ses_1", + role: "assistant", + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Number(id.slice(2)) }, + } as unknown as Message) + : ({ + id, + sessionID: "ses_1", + role: "user", + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + time: { created: Number(id.slice(2)) }, + } as unknown as Message) + +const textPart = (id: string, messageID: string): Extract => ({ + id, + sessionID: "ses_1", + messageID, + type: "text", + text: id, +}) + +describe("revert page helpers", () => { + test("detects when the loaded page has no visible user before revert", () => { + expect(hasVisibleUserBeforeRevert([message("m6", "user"), message("m7", "assistant")], "m6")).toBe(false) + expect(hasVisibleUserBeforeRevert([message("m5", "user"), message("m6", "user")], "m6")).toBe(true) + }) + + test("loads and merges an older boundary window when latest page is fully reverted", async () => { + const olderPart = textPart("p5", "m5") + const boundaryPart = textPart("p6", "m6") + + const result = await loadRevertAwareLatestPage({ + current: { + session: [message("m6", "user"), message("m7", "assistant"), message("m8", "user")], + part: [ + { id: "m6", part: [boundaryPart] }, + { id: "m7", part: [] }, + { id: "m8", part: [] }, + ], + cursor: undefined, + complete: true, + }, + revertMessageID: "m6", + fetchMessage: async () => ({ info: message("m6", "user"), parts: [boundaryPart], cursor: "boundary" }), + fetchPage: async (before) => { + expect(before).toBe("boundary") + return { + session: [message("m4", "assistant"), message("m5", "user")], + part: [ + { id: "m4", part: [] }, + { id: "m5", part: [olderPart] }, + ], + cursor: "older", + complete: false, + } + }, + }) + + expect(result.session.map((item) => item.id)).toEqual(["m4", "m5", "m6", "m7", "m8"]) + expect(result.part.find((item) => item.id === "m5")?.part).toEqual([olderPart]) + expect(result.part.find((item) => item.id === "m6")?.part).toEqual([boundaryPart]) + expect(result.cursor).toBe("older") + expect(result.complete).toBe(false) + }) + + test("keeps loading older pages until a visible user exists before revert", async () => { + const boundaryPart = textPart("p6", "m6") + const olderPart = textPart("p3", "m3") + let call = 0 + + const result = await loadRevertAwareLatestPage({ + current: { + session: [message("m6", "user"), message("m7", "assistant"), message("m8", "user")], + part: [ + { id: "m6", part: [boundaryPart] }, + { id: "m7", part: [] }, + { id: "m8", part: [] }, + ], + cursor: undefined, + complete: true, + }, + revertMessageID: "m6", + fetchMessage: async () => ({ info: message("m6", "user"), parts: [boundaryPart], cursor: "boundary" }), + fetchPage: async () => { + call += 1 + if (call === 1) { + return { + session: [message("m4", "assistant"), message("m5", "assistant")], + part: [ + { id: "m4", part: [] }, + { id: "m5", part: [] }, + ], + cursor: "older-2", + complete: false, + } + } + return { + session: [message("m3", "user")], + part: [{ id: "m3", part: [olderPart] }], + cursor: undefined, + complete: true, + } + }, + }) + + expect(call).toBe(2) + expect(result.session.map((item) => item.id)).toEqual(["m3", "m4", "m5", "m6", "m7", "m8"]) + expect(result.part.find((item) => item.id === "m3")?.part).toEqual([olderPart]) + expect(result.cursor).toBeUndefined() + expect(result.complete).toBe(true) + }) + + test("marks stale revert boundaries for clearing when the boundary message is missing", async () => { + const result = await loadRevertAwareLatestPage({ + current: { + session: [message("m7", "assistant"), message("m8", "user")], + part: [ + { id: "m7", part: [] }, + { id: "m8", part: [] }, + ], + cursor: undefined, + complete: true, + }, + revertMessageID: "m6", + fetchMessage: async () => undefined, + fetchPage: async () => ({ + session: [], + part: [], + cursor: undefined, + complete: true, + }), + }) + + expect(result.clearedRevert).toBe(true) + expect(result.session.map((item) => item.id)).toEqual(["m7", "m8"]) + }) +}) diff --git a/packages/app/src/context/revert-page.ts b/packages/app/src/context/revert-page.ts new file mode 100644 index 000000000000..6c84c00d8b94 --- /dev/null +++ b/packages/app/src/context/revert-page.ts @@ -0,0 +1,77 @@ +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean + clearedRevert?: boolean +} + +type MessageWithParts = { + info: Message + parts: Part[] + cursor?: string +} + +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +export const compareMessages = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) +export const messageBefore = (message: Message, boundary: Message) => compareMessages(message, boundary) < 0 + +const sortParts = (parts: Part[]) => parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) + +export function hasVisibleUserBeforeRevert(messages: Message[], revertMessageID?: string, boundary?: Message) { + if (!revertMessageID) return true + const revert = boundary ?? messages.find((message) => message.id === revertMessageID) + if (!revert) return messages.some((message) => message.role === "user" && message.id < revertMessageID) + return messages.some((message) => message.role === "user" && messageBefore(message, revert)) +} + +function mergeMessages(current: Message[], older: Message[], boundary: Message) { + const merged = new Map(current.filter((message) => !!message?.id).map((message) => [message.id, message] as const)) + for (const message of older) { + if (!message?.id) continue + merged.set(message.id, message) + } + merged.set(boundary.id, boundary) + return [...merged.values()].sort(compareMessages) +} + +function mergeParts(current: MessagePage["part"], older: MessagePage["part"], boundary: MessageWithParts) { + const merged = new Map(current.filter((item) => !!item?.id).map((item) => [item.id, sortParts(item.part)] as const)) + for (const item of older) { + if (!item?.id) continue + merged.set(item.id, sortParts(item.part)) + } + merged.set(boundary.info.id, sortParts(boundary.parts)) + return [...merged.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })) +} + +export async function loadRevertAwareLatestPage(input: { + current: MessagePage + revertMessageID?: string + fetchMessage: (messageID: string) => Promise + fetchPage: (before: string) => Promise +}) { + if (!input.revertMessageID) return input.current + + const boundary = await input.fetchMessage(input.revertMessageID) + if (!boundary) return { ...input.current, clearedRevert: true } + if (hasVisibleUserBeforeRevert(input.current.session, input.revertMessageID, boundary.info)) return input.current + if (!boundary.cursor) return input.current + + let older = await input.fetchPage(boundary.cursor) + let session = mergeMessages(input.current.session, older.session, boundary.info) + let part = mergeParts(input.current.part, older.part, boundary) + while (!hasVisibleUserBeforeRevert(session, input.revertMessageID) && older.cursor) { + older = await input.fetchPage(older.cursor) + session = mergeMessages(session, older.session, boundary.info) + part = mergeParts(part, older.part, boundary) + } + return { + session, + part, + cursor: older.cursor, + complete: older.complete, + } +} diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 34b597b6bb52..0e2e76f57a17 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" +import { compareMessages, hasVisibleUserBeforeRevert, loadRevertAwareLatestPage } from "./revert-page" import { clearSessionPrefetch, getSessionPrefetch, @@ -17,6 +18,17 @@ import { diffs as list, message as clean } from "@/utils/diffs" const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) +function nextBefore(link: string | null) { + if (!link) return undefined + const match = /<([^>]+)>;\s*rel="prev"/.exec(link) + if (!match) return undefined + try { + return new URL(match[1]).searchParams.get("before") ?? undefined + } catch { + return undefined + } +} + function sortParts(parts: Part[]) { return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) } @@ -35,10 +47,16 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -function merge(a: readonly T[], b: readonly T[]) { +function merge(a: readonly Message[], b: readonly Message[]) { const map = new Map(a.map((item) => [item.id, item] as const)) for (const item of b) map.set(item.id, item) - return [...map.values()].sort((x, y) => cmp(x.id, y.id)) + return [...map.values()].sort(compareMessages) +} + +const messageIndex = (messages: readonly Message[], id: string) => messages.findIndex((message) => message.id === id) +const messageInsertIndex = (messages: readonly Message[], message: Message) => { + const index = messages.findIndex((item) => compareMessages(message, item) < 0) + return index === -1 ? messages.length : index } type OptimisticStore = { @@ -67,6 +85,7 @@ type MessagePage = { part: { id: string; part: Part[] }[] cursor?: string complete: boolean + clearedRevert?: boolean } const hasParts = (parts: Part[] | undefined, want: Part[]) => { @@ -96,9 +115,9 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) const confirmed: string[] = [] for (const item of items) { - const result = Binary.search(session, item.message.id, (message) => message.id) - const found = result.found - if (!found) session.splice(result.index, 0, item.message) + const index = messageIndex(session, item.message.id) + const found = index !== -1 + if (!found) session.splice(messageInsertIndex(session, item.message), 0, item.message) const current = part.get(item.message.id) if (found && hasParts(current, item.parts)) { @@ -112,6 +131,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) return { cursor: page.cursor, complete: page.complete, + clearedRevert: page.clearedRevert, session, part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })), confirmed, @@ -121,8 +141,8 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { const messages = draft.message[input.sessionID] if (messages) { - const result = Binary.search(messages, input.message.id, (m) => m.id) - messages.splice(result.index, 0, input.message) + const index = messageIndex(messages, input.message.id) + if (index === -1) messages.splice(messageInsertIndex(messages, input.message), 0, input.message) } else { draft.message[input.sessionID] = [input.message] } @@ -132,8 +152,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { const messages = draft.message[input.sessionID] if (messages) { - const result = Binary.search(messages, input.messageID, (m) => m.id) - if (result.found) messages.splice(result.index, 1) + const index = messageIndex(messages, input.messageID) + if (index !== -1) messages.splice(index, 1) } delete draft.part[input.messageID] } @@ -141,9 +161,10 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) { setStore("message", input.sessionID, (messages: Message[] | undefined) => { if (!messages) return [input.message] - const result = Binary.search(messages, input.message.id, (m) => m.id) + const index = messageIndex(messages, input.message.id) + if (index !== -1) return messages const next = [...messages] - next.splice(result.index, 0, input.message) + next.splice(messageInsertIndex(next, input.message), 0, input.message) return next }) setStore("part", input.message.id, sortParts(input.parts)) @@ -152,10 +173,10 @@ function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: Optimis function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) { setStore("message", input.sessionID, (messages: Message[] | undefined) => { if (!messages) return messages - const result = Binary.search(messages, input.messageID, (m) => m.id) - if (!result.found) return messages + const index = messageIndex(messages, input.messageID) + if (index === -1) return messages const next = [...messages] - next.splice(result.index, 1) + next.splice(index, 1) return next }) setStore("part", (part: Record) => { @@ -296,20 +317,39 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessionID: string limit: number before?: string + revertMessageID?: string }) => { + const toPage = (messages: Awaited>) => { + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const session = items.map((x) => clean(x.info)).sort(compareMessages) + const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) + const cursor = nextBefore(messages.response.headers.get("Link")) + return { + session, + part, + cursor, + complete: !cursor, + } + } + const messages = await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }), ) - const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id)) - const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) })) - const cursor = messages.response.headers.get("x-next-cursor") ?? undefined - return { - session, - part, - cursor, - complete: !cursor, - } + const page = toPage(messages) + if (input.before) return page + return loadRevertAwareLatestPage({ + current: page, + revertMessageID: input.revertMessageID, + fetchMessage: (messageID) => + retry(() => input.client.session.message({ sessionID: input.sessionID, messageID })).then((result) => { + if (!result.data?.info?.id) return undefined + return { info: clean(result.data.info), parts: sortParts(result.data.parts), cursor: result.data.cursor } + }), + fetchPage: (before) => + retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before })).then( + toPage, + ), + }) } const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false @@ -321,6 +361,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ sessionID: string limit: number before?: string + revertMessageID?: string mode?: "replace" | "prepend" }) => { const key = keyFor(input.directory, input.sessionID) @@ -343,6 +384,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type)) if (filtered.length) input.setStore("part", p.id, filtered) } + if (next.clearedRevert) { + input.setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, input.sessionID, (s) => s.id) + if (!match.found) return + draft[match.index] = { ...draft[match.index], revert: undefined } + }), + ) + } setMeta("limit", key, message.length) setMeta("cursor", key, next.cursor) setMeta("complete", key, next.complete) @@ -460,18 +511,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const sessionMatch = Binary.search(store.session, sessionID, (s) => s.id) + const sessionInfo = sessionMatch.found ? store.session[sessionMatch.index] : undefined const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined - if (cached && hasSession && !opts?.force) return + const canReuseCached = !!( + cached && + sessionInfo && + hasVisibleUserBeforeRevert(store.message[sessionID] ?? [], sessionInfo.revert?.messageID) + ) + if (canReuseCached && !opts?.force) return const limit = meta.limit[key] ?? initialMessagePageSize - const sessionReq = - hasSession && !opts?.force - ? Promise.resolve() - : retry(() => client.session.get({ sessionID })).then((session) => { - if (!tracked(directory, sessionID)) return + const nextSession = + sessionInfo && !opts?.force + ? sessionInfo + : await retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return undefined const data = session.data - if (!data) return + if (!data) return undefined setStore( "session", produce((draft) => { @@ -483,10 +540,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ draft.splice(match.index, 0, data) }), ) + return data }) const messagesReq = - cached && !opts?.force + canReuseCached && !opts?.force ? Promise.resolve() : loadMessages({ directory, @@ -494,9 +552,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore, sessionID, limit, + revertMessageID: nextSession?.revert?.messageID, }) - await Promise.all([sessionReq, messagesReq]) + await messagesReq }) }, async diff(sessionID: string, opts?: { force?: boolean }) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7e9e2d32aaba..46fcaa345d1f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -16,6 +16,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" +import { compareMessages } from "@/context/revert-page" import { Persist, persisted } from "@/utils/persist" import { base64Encode } from "@opencode-ai/core/util/encode" import { decode64 } from "@/utils/base64" @@ -675,6 +676,17 @@ export default function Layout(props: ParentProps) { running: number } + function nextBefore(link: string | null) { + if (!link) return undefined + const match = /<([^>]+)>;\s*rel="prev"/.exec(link) + if (!match) return undefined + try { + return new URL(match[1]).searchParams.get("before") ?? undefined + } catch { + return undefined + } + } + const prefetchChunk = 200 const prefetchConcurrency = 2 const prefetchPendingLimit = 10 @@ -759,6 +771,15 @@ export default function Layout(props: ParentProps) { return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) } + const mergeMessages = (current: Message[], incoming: Message[]) => { + if (current.length === 0) return incoming.slice().sort(compareMessages) + + const map = new Map() + for (const item of current) map.set(item.id, item) + for (const item of incoming) map.set(item.id, item) + return [...map.values()].sort(compareMessages) + } + async function prefetchMessages(directory: string, sessionID: string, token: number) { const [store, setStore] = globalSync.child(directory, { bootstrap: false }) @@ -773,9 +794,9 @@ export default function Layout(props: ParentProps) { const items = (messages.data ?? []).filter((x) => !!x?.info?.id) const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) - const sorted = mergeByID([], next) + const sorted = mergeMessages([], next) const stale = markPrefetched(directory, sessionID) - const cursor = messages.response.headers.get("x-next-cursor") ?? undefined + const cursor = nextBefore(messages.response.headers.get("Link")) const meta = { limit: sorted.length, cursor, @@ -791,7 +812,7 @@ export default function Layout(props: ParentProps) { } const current = store.message[sessionID] ?? [] - const merged = mergeByID( + const merged = mergeMessages( current.filter((item): item is Message => !!item?.id), sorted, ) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1345e355eb25..7d93e27ed30c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -10,10 +10,11 @@ import { createMemo, createEffect, createComputed, + createSignal, + createResource, on, onMount, untrack, - createResource, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -40,6 +41,7 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" +import { messageBefore } from "@/context/revert-page" import { useTerminal } from "@/context/terminal" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" @@ -68,6 +70,11 @@ import { formatServerError } from "@/utils/server-errors" const emptyUserMessages: UserMessage[] = [] type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick +type RevertPreview = { + userCount: number + nextMessageID?: string + items: { id: string; text: string }[] +} const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" @@ -471,7 +478,9 @@ export default function Page() { () => { const revert = revertMessageID() if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) + const boundary = messages().find((m) => m.id === revert) + if (!boundary) return userMessages().filter((m) => m.id < revert) + return userMessages().filter((m) => messageBefore(m, boundary)) }, emptyUserMessages, { @@ -511,6 +520,19 @@ export default function Page() { ), ) + createEffect( + on( + () => [params.id, revertMessageID()] as const, + ([id, revert], prev) => { + if (!id) return + if (prev && prev[0] === id && prev[1] === revert) return + if (!prev) return + void sync.session.sync(id, { force: true }) + }, + { defer: true }, + ), + ) + const [store, setStore] = createStore({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", @@ -788,6 +810,24 @@ export default function Page() { }, ) + const [revertPreview, setRevertPreview] = createSignal() + let revertPreviewEpoch = 0 + createEffect( + on( + () => [params.id, revertMessageID()] as const, + ([sessionID, revert]) => { + const epoch = ++revertPreviewEpoch + setRevertPreview(undefined) + if (!sessionID || !revert) return + void sdk.client.session.revertPreview({ sessionID }).then((result) => { + if (epoch !== revertPreviewEpoch) return + setRevertPreview(result.data ?? undefined) + }) + }, + { defer: true }, + ), + ) + createEffect( on( () => { @@ -1037,6 +1077,7 @@ export default function Page() { setActiveMessage, focusInput, review: reviewTab, + nextRevertMessageID: () => revertPreview()?.nextMessageID, }) const openReviewFile = createOpenReviewFile({ @@ -1460,6 +1501,21 @@ export default function Page() { attachmentName: language.t("common.attachment"), }) + const loadPromptDraft = async (sessionID: string, id: string) => { + const parts = sync.data.part[id] + if (parts) { + return extractPromptFromParts(parts, { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) + } + const result = await sdk.client.session.message({ sessionID, messageID: id }) + return extractPromptFromParts(result.data?.parts ?? [], { + directory: sdk.directory, + attachmentName: language.t("common.attachment"), + }) + } + const line = (id: string) => { const text = draft(id) .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) @@ -1487,15 +1543,6 @@ export default function Page() { return out }) - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync.set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) - const busy = (sessionID: string) => { if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true return (sync.data.message[sessionID] ?? []).some( @@ -1623,22 +1670,15 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { const prev = prompt.current().slice() - const last = info()?.revert const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) - }) + prompt.set(value) await halt(input.sessionID) .then(() => sdk.client.session.revert(input)) .then((result) => { if (result.data) merge(result.data) }) .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) + prompt.set(prev) fail(err) }) }, @@ -1649,25 +1689,31 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const next = userMessages().find((item) => item.id > id) + const preview = + revertPreview() ?? + (await sdk.client.session.revertPreview({ sessionID }).then((result) => result.data ?? undefined)) + if (info()?.revert?.messageID && !preview) throw new Error("Failed to load revert preview") + + const next = (() => { + const items = preview?.items + if (!items) return undefined + const index = items.findIndex((item) => item.id === id) + if (index === -1) throw new Error("Restore target missing from revert preview") + return items[index + 1]?.id + })() const prev = prompt.current().slice() - const last = info()?.revert + const nextDraft = next ? await loadPromptDraft(sessionID, next).catch(() => undefined) : undefined + if (next && !nextDraft) throw new Error("Failed to load next restore draft") - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() - }) + if (next) prompt.set(nextDraft ?? []) + else prompt.reset() const task = !next ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) : halt(sessionID).then(() => sdk.client.session.revert({ sessionID, - messageID: next.id, + messageID: next, }), ) @@ -1676,10 +1722,7 @@ export default function Page() { if (result.data) merge(result.data) }) .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) + prompt.set(prev) fail(err) }) }, @@ -1699,10 +1742,13 @@ export default function Page() { } const rolled = createMemo(() => { + const items = revertPreview()?.items + if (items) return items const id = revertMessageID() if (!id) return [] + const boundary = messages().find((item) => item.id === id) return userMessages() - .filter((item) => item.id >= id) + .filter((item) => (boundary ? !messageBefore(item, boundary) : item.id >= id)) .map((item) => ({ id: item.id, text: line(item.id) })) }) diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 922299bec198..b848083be5eb 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,6 +12,7 @@ import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" +import { messageBefore } from "@/context/revert-page" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/core/util/array" @@ -25,6 +26,7 @@ export type SessionCommandContext = { setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void review?: () => boolean + nextRevertMessageID: () => string | undefined } const withCategory = (category: string) => { @@ -83,10 +85,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return sync.data.message[id] ?? [] } const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] + const beforeMessage = (message: UserMessage, boundaryID: string) => { + const boundary = messages().find((item) => item.id === boundaryID) + if (!boundary) return message.id < boundaryID + return messageBefore(message, boundary) + } const visibleUserMessages = () => { const revert = info()?.revert?.messageID if (!revert) return userMessages() - return userMessages().filter((m) => m.id < revert) + return userMessages().filter((m) => beforeMessage(m, revert)) } const showAllFiles = () => { @@ -295,7 +302,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const revert = info()?.revert?.messageID - const message = findLast(userMessages(), (x) => !revert || x.id < revert) + const message = findLast(userMessages(), (x) => !revert || beforeMessage(x, revert)) if (!message) return await sdk.client.session.revert({ sessionID, messageID: message.id }) @@ -305,7 +312,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { prompt.set(restored) } - const prev = findLast(userMessages(), (x) => x.id < message.id) + const prev = findLast(userMessages(), (x) => beforeMessage(x, message.id)) setActiveMessage(prev) } @@ -316,17 +323,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const revertMessageID = info()?.revert?.messageID if (!revertMessageID) return - const next = userMessages().find((x) => x.id > revertMessageID) - if (!next) { + const preview = + actions.nextRevertMessageID() !== undefined + ? { nextMessageID: actions.nextRevertMessageID() } + : await sdk.client.session.revertPreview({ sessionID }).then((result) => result.data ?? undefined) + const nextMessageID = preview?.nextMessageID + if (!preview && revertMessageID) return + if (!nextMessageID) { await sdk.client.session.unrevert({ sessionID }) prompt.reset() - const last = findLast(userMessages(), (x) => x.id >= revertMessageID) + const last = findLast(userMessages(), (x) => !beforeMessage(x, revertMessageID)) setActiveMessage(last) return } - await sdk.client.session.revert({ sessionID, messageID: next.id }) - const prev = findLast(userMessages(), (x) => x.id < next.id) + await sdk.client.session.revert({ sessionID, messageID: nextMessageID }) + const prev = findLast(userMessages(), (x) => beforeMessage(x, nextMessageID)) setActiveMessage(prev) } diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 2c1ab245a50c..08614727a456 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx @@ -16,8 +16,8 @@ export const { use: useKeybind, provider: KeybindProvider } = createSimpleContex const config = useTuiConfig() const keybinds = createMemo>(() => { return pipe( - (config.keybinds ?? {}) as Record, - mapValues((value) => Keybind.parse(value)), + config.keybinds ?? {}, + mapValues((value) => (typeof value === "string" ? Keybind.parse(value) : [])), ) }) const [store, setStore] = createStore({ diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 24609dd81e4f..52f949f8707f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -27,11 +27,27 @@ import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" -import { batch, onMount } from "solid-js" +import { batch, createEffect, on, onMount } from "solid-js" import * as Log from "@opencode-ai/core/util/log" +import type { Path } from "@opencode-ai/sdk" +import type { Workspace } from "@opencode-ai/sdk/v2" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" import path from "path" import { useKV } from "./kv" +import { linkParam, parseLinkHeader } from "@/util/link-header" +import { + evictFromEnd, + evictFromStart, + hasUserBeforeBoundary, + messageBefore, + messageInsert, + paginationError, + windowNewest, + windowOldest, +} from "@tui/util/pagination" + +/** Maximum messages kept in memory per session */ +const MAX_LOADED_MESSAGES = 500 export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -53,6 +69,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } config: Config session: Session[] + message_page: { + [sessionID: string]: { + hasOlder: boolean + hasNewer: boolean + loading: boolean + loadingDirection?: "older" | "newer" + oldest?: string + newest?: string + olderCursor?: string + newerCursor?: string + error?: string + } + } session_status: { [sessionID: string]: SessionStatus } @@ -65,6 +94,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ message: { [sessionID: string]: Message[] } + message_cursor: { + [messageID: string]: string | undefined + } part: { [messageID: string]: Part[] } @@ -94,10 +126,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ provider: [], provider_default: {}, session: [], + message_page: {}, session_status: {}, session_diff: {}, todo: {}, message: {}, + message_cursor: {}, part: {}, lsp: [], mcp: {}, @@ -111,7 +145,181 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const kv = useKV() + const getRevertMarker = (sessionID: string) => { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (!match.found) return undefined + return store.session[match.index].revert?.messageID + } + + const pageInfo = (link: string) => { + const links = parseLinkHeader(link) + return { + hasOlder: links.prev !== undefined, + hasNewer: links.next !== undefined, + olderCursor: linkParam(links.prev, "before"), + newerCursor: linkParam(links.next, "after"), + } + } + + const edgeCursor = (cursors: Record, id: string | undefined) => + id ? cursors[id] : undefined + + const errorStatus = (error: unknown) => { + if (!error || typeof error !== "object") return undefined + const status = Reflect.get(error, "status") + return typeof status === "number" ? status : undefined + } + + const clearRevert = (sessionID: string) => { + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (!match.found) return + draft[match.index] = { ...draft[match.index], revert: undefined } + }), + ) + } + + const messageIndex = (messages: Message[] | undefined, id: string) => + messages?.findIndex((item) => item.id === id) ?? -1 + + type LoadedMessage = { + info: Message + parts: Part[] + cursor?: string + } + + type LoadedPage = ReturnType & { + items: LoadedMessage[] + oldest?: string + newest?: string + clearedRevert?: boolean + } + + const insertLoadedMessage = (items: LoadedMessage[], message: LoadedMessage) => { + const result = messageInsert( + items.map((item) => item.info), + message.info, + ) + if (!result.found) items.splice(result.index, 0, message) + } + + const loadLatestPage = async (sessionID: string, revertMessageID?: string): Promise => { + const latest = await sdk.client.session.messages({ sessionID, limit: 100 }, { throwOnError: true }) + const latestItems = [...(latest.data ?? [])] + const latestPage = pageInfo(latest.response.headers.get("link") ?? "") + const latestOldest = latestItems.at(0)?.info.id + const latestNewest = latestItems.at(-1)?.info.id + if (!revertMessageID) { + return { + items: latestItems, + oldest: latestOldest, + newest: latestNewest, + ...latestPage, + } + } + + try { + const revert = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + const boundary = revert.data + if (!boundary) { + return { + items: latestItems, + oldest: latestOldest, + newest: latestNewest, + ...latestPage, + } + } + + if ( + hasUserBeforeBoundary( + latestItems.map((item) => item.info), + boundary.info, + ) + ) { + insertLoadedMessage(latestItems, boundary) + return { + items: latestItems, + oldest: latestItems.at(0)?.info.id, + newest: latestNewest ?? latestItems.at(-1)?.info.id, + ...latestPage, + } + } + + let olderCursor = boundary.cursor + if (!olderCursor) + return { + items: latestItems, + oldest: latestOldest, + newest: latestNewest, + ...latestPage, + } + let olderPage = latestPage + let oldestLoaded = boundary.info.id + do { + const older = await sdk.client.session.messages( + { + sessionID, + before: olderCursor, + limit: 100, + }, + { throwOnError: true }, + ) + const olderItems = older.data ?? [] + olderPage = pageInfo(older.response.headers.get("link") ?? "") + oldestLoaded = olderItems.at(0)?.info.id ?? oldestLoaded + for (const message of olderItems) { + insertLoadedMessage(latestItems, message) + } + olderCursor = olderPage.olderCursor ?? "" + } while ( + olderCursor && + !hasUserBeforeBoundary( + latestItems.map((item) => item.info), + boundary.info, + ) + ) + insertLoadedMessage(latestItems, boundary) + return { + items: latestItems, + hasOlder: olderPage.hasOlder, + hasNewer: olderPage.hasNewer || latestPage.hasNewer, + olderCursor: olderPage.olderCursor, + newerCursor: olderPage.newerCursor ?? latestPage.newerCursor, + oldest: oldestLoaded, + newest: latestNewest ?? latestItems.at(-1)?.info.id, + } + } catch (e) { + Log.Default.info("Revert marker fetch failed during latest-page load", { + messageID: revertMessageID, + error: e, + }) + if (errorStatus(e) === 404) { + clearRevert(sessionID) + return { + items: latestItems, + oldest: latestOldest, + newest: latestNewest, + clearedRevert: true, + ...latestPage, + } + } + return { + items: latestItems, + oldest: latestOldest, + newest: latestNewest, + ...latestPage, + } + } + } + const fullSyncedSessions = new Set() + const loadingGuard = new Set() + let syncEpoch = 0 let syncedWorkspace = project.workspace.current() function sessionListQuery(): { scope?: "project"; path?: string } { @@ -133,7 +341,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ event.subscribe((event) => { switch (event.type) { case "server.instance.disposed": - void bootstrap() + const synced = [...fullSyncedSessions] + syncEpoch += 1 + fullSyncedSessions.clear() + loadingGuard.clear() + setStore( + produce((draft) => { + for (const sessionID of synced) { + for (const msg of draft.message[sessionID] ?? []) { + delete draft.part[msg.id] + delete draft.message_cursor[msg.id] + } + delete draft.message[sessionID] + delete draft.message_page[sessionID] + delete draft.todo[sessionID] + delete draft.session_diff[sessionID] + } + }), + ) + void bootstrap().then(() => Promise.allSettled(synced.map((sessionID) => result.session.sync(sessionID)))) break case "permission.replied": { const requests = store.permission[event.properties.sessionID] @@ -231,15 +457,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } case "session.updated": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) + const info = event.properties.info + const match = Binary.search(store.session, info.id, (s) => s.id) + const previous = match.found ? store.session[match.index] : undefined + const revertChanged = + previous?.revert?.messageID !== info.revert?.messageID || previous?.revert?.partID !== info.revert?.partID + if (match.found) { + setStore("session", match.index, reconcile(info)) + if (revertChanged && store.message[info.id]) { + void result.session.jumpToLatest(info.id, { force: true }) + } break } setStore( "session", produce((draft) => { - draft.splice(result.index, 0, event.properties.info) + draft.splice(match.index, 0, info) }), ) break @@ -251,59 +484,137 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } case "message.updated": { - const messages = store.message[event.properties.info.sessionID] + const sessionID = event.properties.info.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const pinned = getRevertMarker(sessionID) if (!messages) { - setStore("message", event.properties.info.sessionID, [event.properties.info]) + setStore("message", sessionID, [event.properties.info]) break } - const result = Binary.search(messages, event.properties.info.id, (m) => m.id) - if (result.found) { - setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + const current = messageIndex(messages, event.properties.info.id) + if (current !== -1) { + setStore("message", sessionID, current, reconcile(event.properties.info)) break } + const loadingNewer = page?.loading && page.loadingDirection === "newer" + const loadingOlder = page?.loading && page.loadingDirection === "older" + if (page?.hasNewer && !loadingNewer) { + break + } + const oldest = page?.oldest ? messages.find((item) => item.id === page.oldest) : undefined + if (oldest && messageBefore(event.properties.info, oldest) && !loadingOlder) { + break + } + const result = messageInsert(messages, event.properties.info) + const preview = [...messages] + preview.splice(result.index, 0, event.properties.info) 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] + if (page) { + const nextOldest = windowOldest(preview, pinned) ?? page.oldest + const nextNewest = windowNewest(preview, pinned) ?? page.newest + setStore("message_page", event.properties.info.sessionID, { + ...page, + newest: nextNewest, + oldest: nextOldest, + }) + } + if (preview.length > MAX_LOADED_MESSAGES) { + const evictCount = preview.length - MAX_LOADED_MESSAGES + const trimmed = [...preview] + const evicted = evictFromStart(trimmed, evictCount, pinned) + const nextOldest = windowOldest(trimmed, pinned) ?? page?.oldest + const nextNewest = windowNewest(trimmed, pinned) ?? page?.newest batch(() => { setStore( "message", event.properties.info.sessionID, produce((draft) => { - draft.shift() + evictFromStart(draft, evictCount, pinned) }), ) setStore( "part", produce((draft) => { - delete draft[oldest.id] + for (const msg of evicted) { + delete draft[msg.id] + } }), ) + setStore( + "message_cursor", + produce((draft) => { + for (const msg of evicted) { + delete draft[msg.id] + } + }), + ) + if (page) { + setStore("message_page", event.properties.info.sessionID, { + ...page, + hasOlder: true, + oldest: nextOldest, + newest: nextNewest, + olderCursor: edgeCursor(store.message_cursor, nextOldest), + }) + } }) } break } case "message.removed": { const messages = store.message[event.properties.sessionID] - const result = Binary.search(messages, event.properties.messageID, (m) => m.id) - if (result.found) { + const page = store.message_page[event.properties.sessionID] + const pinned = getRevertMarker(event.properties.sessionID) + const index = messageIndex(messages, event.properties.messageID) + if (index !== -1) { + const preview = [...messages] + preview.splice(index, 1) + const nextOldest = windowOldest(preview, pinned) ?? preview.at(0)?.id + const nextNewest = windowNewest(preview, pinned) ?? preview.at(-1)?.id + const onlyPinned = preview.length === 1 && preview[0]?.id === pinned setStore( - "message", - event.properties.sessionID, produce((draft) => { - draft.splice(result.index, 1) + draft.message[event.properties.sessionID]?.splice(index, 1) + delete draft.part[event.properties.messageID] + delete draft.message_cursor[event.properties.messageID] + if (page) { + draft.message_page[event.properties.sessionID] = { + ...page, + oldest: nextOldest, + newest: nextNewest, + olderCursor: page.hasOlder + ? preview.length > 0 && !onlyPinned + ? edgeCursor(draft.message_cursor, nextOldest) + : page.olderCursor + : undefined, + newerCursor: page.hasNewer + ? preview.length > 0 && !onlyPinned + ? edgeCursor(draft.message_cursor, nextNewest) + : page.newerCursor + : undefined, + } + } }), ) } break } case "message.part.updated": { + const sessionID = event.properties.part.sessionID + const page = store.message_page[sessionID] + const messages = store.message[sessionID] + const messageExists = messages?.some((m) => m.id === event.properties.part.messageID) + const loadingNewer = page?.loading && page.loadingDirection === "newer" + if (!messageExists && !loadingNewer) { + break + } const parts = store.part[event.properties.part.messageID] if (!parts) { setStore("part", event.properties.part.messageID, [event.properties.part]) @@ -460,7 +771,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) .catch(async (e) => { Log.Default.error("tui bootstrap failed", { - error: e instanceof Error ? e.message : String(e), + error: paginationError(e), name: e instanceof Error ? e.name : undefined, stack: e instanceof Error ? e.stack : undefined, }) @@ -514,27 +825,372 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, async sync(sessionID: string) { if (fullSyncedSessions.has(sessionID)) return - const [session, messages, todo, diff] = await Promise.all([ + const epoch = syncEpoch + const [session, todo, diff] = await Promise.all([ sdk.client.session.get({ sessionID }, { throwOnError: true }), - sdk.client.session.messages({ sessionID, limit: 100 }), sdk.client.session.todo({ sessionID }), sdk.client.session.diff({ sessionID }), ]) + if (epoch !== syncEpoch) return + let sessionInfo = session.data! + const page = await loadLatestPage(sessionID, sessionInfo.revert?.messageID) + if (epoch !== syncEpoch) return + if (page.clearedRevert) sessionInfo = { ...sessionInfo, revert: undefined } setStore( produce((draft) => { const match = Binary.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!) + if (match.found) draft.session[match.index] = sessionInfo + if (!match.found) draft.session.splice(match.index, 0, sessionInfo) draft.todo[sessionID] = todo.data ?? [] - draft.message[sessionID] = messages.data!.map((x) => x.info) - for (const message of messages.data!) { + draft.message[sessionID] = page.items.map((x) => x.info) + for (const message of page.items) { draft.part[message.info.id] = message.parts + draft.message_cursor[message.info.id] = message.cursor } draft.session_diff[sessionID] = diff.data ?? [] + draft.message_page[sessionID] = { + hasOlder: page.hasOlder, + hasNewer: page.hasNewer, + loading: false, + oldest: page.oldest, + newest: page.newest, + olderCursor: page.olderCursor, + newerCursor: page.newerCursor, + error: undefined, + } }), ) + if (epoch !== syncEpoch) return fullSyncedSessions.add(sessionID) }, + async loadOlder(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + const cursor = page?.olderCursor + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const epoch = syncEpoch + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "older", error: undefined }) + + const res = await sdk.client.session.messages( + { sessionID, before: cursor, limit: 100 }, + { throwOnError: true }, + ) + if (epoch !== syncEpoch) return + const info = pageInfo(res.response.headers.get("link") ?? "") + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageOldest = res.data?.at(0)?.info.id + for (const msg of res.data ?? []) { + draft.message_cursor[msg.info.id] = msg.cursor + const match = messageInsert(existing, msg.info) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextOldest = pageOldest ?? draft.message_page[sessionID]?.oldest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromEnd(existing, evictCount, pinned) + for (const msg of evicted) { + delete draft.part[msg.id] + delete draft.message_cursor[msg.id] + } + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder: info.hasOlder, + hasNewer: true, + loading: false, + oldest: nextOldest, + newest: nextNewest, + olderCursor: info.olderCursor, + newerCursor: edgeCursor(draft.message_cursor, nextNewest), + error: undefined, + } + } else { + const nextNewest = windowNewest(existing, pinned) ?? draft.message_page[sessionID]?.newest + draft.message_page[sessionID] = { + hasOlder: info.hasOlder, + hasNewer: draft.message_page[sessionID]?.hasNewer ?? false, + loading: false, + oldest: nextOldest, + newest: nextNewest, + olderCursor: info.olderCursor, + newerCursor: draft.message_page[sessionID]?.newerCursor, + error: undefined, + } + } + }), + ) + } catch (e) { + if (epoch !== syncEpoch) return + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + olderCursor: page?.olderCursor, + newerCursor: page?.newerCursor, + error: paginationError(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async loadNewer(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasNewer) return + const cursor = page?.newerCursor + if (!cursor) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const epoch = syncEpoch + const pinned = getRevertMarker(sessionID) + try { + setStore("message_page", sessionID, { ...page, loading: true, loadingDirection: "newer", error: undefined }) + const res = await sdk.client.session.messages( + { sessionID, after: cursor, limit: 100 }, + { throwOnError: true }, + ) + if (epoch !== syncEpoch) return + const info = pageInfo(res.response.headers.get("link") ?? "") + setStore( + produce((draft) => { + const existing = draft.message[sessionID] ?? [] + const pageNewest = res.data?.at(-1)?.info.id + for (const msg of res.data ?? []) { + draft.message_cursor[msg.info.id] = msg.cursor + const match = messageInsert(existing, msg.info) + if (!match.found) { + existing.splice(match.index, 0, msg.info) + draft.part[msg.info.id] = msg.parts + } + } + const nextNewest = pageNewest ?? draft.message_page[sessionID]?.newest + if (existing.length > MAX_LOADED_MESSAGES) { + const evictCount = existing.length - MAX_LOADED_MESSAGES + const evicted = evictFromStart(existing, evictCount, pinned) + for (const msg of evicted) { + delete draft.part[msg.id] + delete draft.message_cursor[msg.id] + } + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: true, + hasNewer: info.hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + olderCursor: edgeCursor(draft.message_cursor, nextOldest), + newerCursor: info.newerCursor, + error: undefined, + } + } else { + const nextOldest = windowOldest(existing, pinned) ?? draft.message_page[sessionID]?.oldest + draft.message_page[sessionID] = { + hasOlder: draft.message_page[sessionID]?.hasOlder ?? false, + hasNewer: info.hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + olderCursor: draft.message_page[sessionID]?.olderCursor, + newerCursor: info.newerCursor, + error: undefined, + } + } + }), + ) + } catch (e) { + if (epoch !== syncEpoch) return + const page = store.message_page[sessionID] + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: false, + oldest: page?.oldest, + newest: page?.newest, + olderCursor: page?.olderCursor, + newerCursor: page?.newerCursor, + error: paginationError(e), + }) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToLatest(sessionID: string, opts?: { force?: boolean }) { + const page = store.message_page[sessionID] + if (page?.loading) return + if (!opts?.force && !page?.hasNewer) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const epoch = syncEpoch + + try { + const session = store.session.find((s) => s.id === sessionID) + setStore("message_page", sessionID, { + hasOlder: page?.hasOlder ?? false, + hasNewer: page?.hasNewer ?? false, + loading: true, + loadingDirection: "newer", + oldest: page?.oldest, + newest: page?.newest, + olderCursor: page?.olderCursor, + newerCursor: page?.newerCursor, + error: undefined, + }) + + const latest = await loadLatestPage(sessionID, session?.revert?.messageID) + if (epoch !== syncEpoch) return + + setStore( + produce((draft) => { + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(latest.items.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + delete draft.message_cursor[msg.id] + } + } + + draft.message[sessionID] = latest.items.map((m) => m.info) + for (const msg of latest.items) { + draft.part[msg.info.id] = msg.parts + draft.message_cursor[msg.info.id] = msg.cursor + } + draft.message_page[sessionID] = { + hasOlder: latest.hasOlder, + hasNewer: latest.hasNewer, + loading: false, + oldest: latest.oldest, + newest: latest.newest, + olderCursor: latest.olderCursor, + newerCursor: latest.newerCursor, + error: undefined, + } + }), + ) + } catch (e) { + if (epoch !== syncEpoch) return + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = paginationError(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, + async jumpToOldest(sessionID: string) { + const page = store.message_page[sessionID] + if (page?.loading || !page?.hasOlder) return + if (loadingGuard.has(sessionID)) return + loadingGuard.add(sessionID) + const epoch = syncEpoch + + try { + setStore("message_page", sessionID, { + ...page, + loading: true, + loadingDirection: "older", + error: undefined, + }) + + const res = await sdk.client.session.messages( + { sessionID, oldest: "true", limit: 100 }, + { throwOnError: true }, + ) + if (epoch !== syncEpoch) return + + const session = store.session.find((s) => s.id === sessionID) + const revertMessageID = session?.revert?.messageID + + let messages = res.data ?? [] + const pageOldest = messages.at(0)?.info.id + const pageNewest = messages.at(-1)?.info.id + const info = pageInfo(res.response.headers.get("link") ?? "") + + if (revertMessageID && !messages.some((m) => m.info.id === revertMessageID)) { + try { + const revertResult = await sdk.client.session.message( + { sessionID, messageID: revertMessageID }, + { throwOnError: true }, + ) + if (epoch !== syncEpoch) return + if (revertResult.data) { + const index = messageInsert( + messages.map((m) => m.info), + revertResult.data.info, + ) + if (!index.found) messages.splice(index.index, 0, revertResult.data) + } + } catch (e) { + if (epoch !== syncEpoch) return + Log.Default.info("Revert marker fetch failed during jumpToOldest", { + messageID: revertMessageID, + error: e, + }) + if (errorStatus(e) === 404) clearRevert(sessionID) + } + } + + const nextOldest = pageOldest ?? messages.at(0)?.info.id + const nextNewest = pageNewest ?? messages.at(-1)?.info.id + + setStore( + produce((draft) => { + const oldMessages = draft.message[sessionID] ?? [] + const newIds = new Set(messages.map((m) => m.info.id)) + for (const msg of oldMessages) { + if (!newIds.has(msg.id)) { + delete draft.part[msg.id] + delete draft.message_cursor[msg.id] + } + } + + draft.message[sessionID] = messages.map((m) => m.info) + for (const msg of messages) { + draft.part[msg.info.id] = msg.parts + draft.message_cursor[msg.info.id] = msg.cursor + } + draft.message_page[sessionID] = { + hasOlder: info.hasOlder, + hasNewer: info.hasNewer, + loading: false, + oldest: nextOldest, + newest: nextNewest, + olderCursor: info.olderCursor, + newerCursor: info.newerCursor, + error: undefined, + } + }), + ) + } catch (e) { + if (epoch !== syncEpoch) return + setStore( + produce((draft) => { + const p = draft.message_page[sessionID] + if (p) { + p.loading = false + p.error = paginationError(e) + } + }), + ) + } finally { + loadingGuard.delete(sessionID) + } + }, }, bootstrap, } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8855338d1d4b..29e61bda3610 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -21,17 +21,17 @@ import { useEvent } from "@tui/context/event" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" import { selectedForeground, useTheme } from "@tui/context/theme" -import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" +import { + BoxRenderable, + ScrollBoxRenderable, + addDefaultParsers, + MacOSScrollAccel, + type ScrollAcceleration, + TextAttributes, + RGBA, +} from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { - AssistantMessage, - Part, - Provider, - ToolPart, - UserMessage, - TextPart, - ReasoningPart, -} from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -63,7 +63,6 @@ import { DialogTimeline } from "./dialog-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" -import { SubagentFooter } from "./subagent-footer.tsx" import { Flag } from "@opencode-ai/core/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" @@ -73,6 +72,8 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import * as Editor from "../../util/editor" import stripAnsi from "strip-ansi" +import { Footer } from "./footer.tsx" +import { SubagentFooter } from "./subagent-footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" @@ -80,7 +81,6 @@ import { Global } from "@opencode-ai/core/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" -import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" @@ -89,13 +89,13 @@ import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" +import { edgeHints, messageBefore, olderScrollTarget, queueBoundaryLoad } from "@tui/util/pagination" addDefaultParsers(parsers.parsers) const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at" const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show" const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs - const context = createContext<{ width: number sessionID: string @@ -105,7 +105,6 @@ const context = createContext<{ showDetails: () => boolean showGenericToolOutput: () => boolean diffWrapMode: () => "word" | "none" - providers: () => ReadonlyMap sync: ReturnType tui: ReturnType }>() @@ -134,6 +133,7 @@ export function Session() { .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const paging = createMemo(() => sync.data.message_page[route.sessionID]) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -145,6 +145,67 @@ export function Session() { const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) + const LOAD_MORE_THRESHOLD = 5 + + const loadOlder = () => { + const page = paging() + if (!page?.hasOlder || page.loading || !scroll || scroll.isDestroyed) return + if (scroll.scrollTop > LOAD_MORE_THRESHOLD) return + + const anchor = (() => { + const scrollTop = scroll.scrollTop + const children = scroll.getChildren() + for (const child of children) { + if (!child.id) continue + if (child.y + child.height > scrollTop) { + return { id: child.id, offset: scrollTop - child.y } + } + } + return undefined + })() + + const height = scroll.scrollHeight + const scrollTop = scroll.scrollTop + sync.session.loadOlder(route.sessionID).then(() => { + queueMicrotask(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + const nextTop = olderScrollTarget(scroll.getChildren(), scroll.scrollHeight, height, scrollTop, anchor) + if (nextTop !== undefined) scroll.scrollTo(nextTop) + refreshEdges() + }) + }) + }) + } + + const loadNewer = () => { + const page = paging() + if (!page?.hasNewer || page.loading || !scroll || scroll.isDestroyed) return + const bottomDistance = scroll.scrollHeight - scroll.scrollTop - scroll.viewport.height + if (bottomDistance > LOAD_MORE_THRESHOLD) return + sync.session.loadNewer(route.sessionID).then(() => { + queueMicrotask(() => { + requestAnimationFrame(() => { + refreshEdges() + }) + }) + }) + } + + const refreshEdges = () => { + if (!scroll || scroll.isDestroyed) return + const edges = edgeHints(scroll.scrollTop, scroll.scrollHeight, scroll.viewport.height, HINT_THRESHOLD) + setNearTop(edges.nearTop) + setNearBottom(edges.nearBottom) + } + + const scrollMove = (delta: number) => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollBy(delta) + refreshEdges() + queueBoundaryLoad(delta, loadOlder, loadNewer) + } + const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) @@ -162,9 +223,14 @@ export function Session() { const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, _setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) + const [showHeader, setShowHeader] = kv.signal("header_visible", true) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [_animationsEnabled, _setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) + const [nearTop, setNearTop] = createSignal(false) + const [nearBottom, setNearBottom] = createSignal(false) + const [revertPreview, setRevertPreview] = createSignal<{ userCount: number; nextMessageID?: string }>() + const HINT_THRESHOLD = 20 const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -175,7 +241,6 @@ export function Session() { }) const showTimestamps = createMemo(() => timestamps() === "show") const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) - const providers = createMemo(() => Model.index(sync.data.provider)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const toast = useToast() @@ -210,7 +275,9 @@ export function Session() { } editor.reconnect(result.data.directory) await sync.session.sync(sessionID) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + if (route.sessionID !== sessionID || !scroll || scroll.isDestroyed) return + scroll.scrollBy(100_000) + refreshEdges() })().catch((error) => { if (route.sessionID !== sessionID) return toast.show({ @@ -222,6 +289,16 @@ export function Session() { }) }) + createEffect(() => { + if (!scroll || scroll.isDestroyed) return + messages() + queueMicrotask(() => { + requestAnimationFrame(() => { + refreshEdges() + }) + }) + }) + let lastSwitch: string | undefined = undefined event.on("message.part.updated", (evt) => { const part = evt.properties.part @@ -303,7 +380,7 @@ export function Session() { const findNextVisibleMessage = (direction: "next" | "prev"): string | null => { const children = scroll.getChildren() const messagesList = messages() - const scrollTop = scroll.y + const scrollTop = scroll.scrollTop // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content const visibleMessages = children @@ -335,13 +412,16 @@ export function Session() { const targetID = findNextVisibleMessage(direction) if (!targetID) { - scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) + scrollMove(direction === "next" ? scroll.height : -scroll.height) dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } dialog.clear() } @@ -349,6 +429,9 @@ export function Session() { setTimeout(() => { if (!scroll || scroll.isDestroyed) return scroll.scrollTo(scroll.scrollHeight) + requestAnimationFrame(() => { + refreshEdges() + }) }, 50) } @@ -369,7 +452,7 @@ export function Session() { if (children().length === 1) return const sessions = children().filter((x) => !!x.parentID) - let next = sessions.findIndex((x) => x.id === session()?.id) - direction + let next = sessions.findIndex((x) => x.id === session()?.id) + direction if (next >= sessions.length) next = 0 if (next < 0) next = sessions.length - 1 @@ -457,7 +540,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } }} sessionID={route.sessionID} setPrompt={(promptInfo) => prompt?.set(promptInfo)} @@ -481,7 +567,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === messageID }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } }} sessionID={route.sessionID} /> @@ -550,8 +639,15 @@ export function Session() { onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) - const revert = session()?.revert?.messageID - const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") + const page = paging() + if (page?.hasNewer && page.loading) return + if (page?.hasNewer) { + await sync.session.jumpToLatest(route.sessionID) + } + const boundary = revertBoundary() + const message = (sync.data.message[route.sessionID] ?? []).findLast( + (x) => x.role === "user" && (!boundary || messageBefore(x, boundary)), + ) if (!message) return void sdk.client.session .revert({ @@ -586,12 +682,17 @@ export function Session() { slash: { name: "redo", }, - onSelect: (dialog) => { + onSelect: async (dialog) => { dialog.clear() - const messageID = session()?.revert?.messageID - if (!messageID) return - const message = messages().find((x) => x.role === "user" && x.id > messageID) - if (!message) { + const preview = + revertPreview() ?? + (await sdk.client.session + .revertPreview({ sessionID: route.sessionID }) + .then((result) => result.data ?? undefined)) ?? + undefined + const nextMessageID = preview?.nextMessageID + if (!preview && session()?.revert?.messageID) return + if (!nextMessageID) { void sdk.client.session.unrevert({ sessionID: route.sessionID, }) @@ -600,7 +701,7 @@ export function Session() { } void sdk.client.session.revert({ sessionID: route.sessionID, - messageID: message.id, + messageID: nextMessageID, }) }, }, @@ -675,6 +776,15 @@ export function Session() { dialog.clear() }, }, + { + title: showHeader() ? "Hide header" : "Show header", + value: "session.toggle.header", + category: "Session", + onSelect: (dialog) => { + setShowHeader((prev) => !prev) + dialog.clear() + }, + }, { title: showGenericToolOutput() ? "Hide generic tool output" : "Show generic tool output", value: "session.toggle.generic_tool_output", @@ -691,7 +801,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(-scroll.height / 2) + scrollMove(-scroll.height / 2) dialog.clear() }, }, @@ -702,7 +812,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(scroll.height / 2) + scrollMove(scroll.height / 2) dialog.clear() }, }, @@ -713,7 +823,7 @@ export function Session() { category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(-1) + scrollMove(-1) dialog.clear() }, }, @@ -724,7 +834,7 @@ export function Session() { category: "Session", disabled: true, onSelect: (dialog) => { - scroll.scrollBy(1) + scrollMove(1) dialog.clear() }, }, @@ -735,7 +845,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(-scroll.height / 4) + scrollMove(-scroll.height / 4) dialog.clear() }, }, @@ -746,7 +856,7 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollBy(scroll.height / 4) + scrollMove(scroll.height / 4) dialog.clear() }, }, @@ -757,7 +867,23 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(0) + const page = paging() + if (page?.hasOlder && !page.loading) { + sync.session.jumpToOldest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(0) + refreshEdges() + }) + }) + } else { + if (!scroll || scroll.isDestroyed) { + dialog.clear() + return + } + scroll.scrollTo(0) + refreshEdges() + } dialog.clear() }, }, @@ -768,7 +894,23 @@ export function Session() { category: "Session", hidden: true, onSelect: (dialog) => { - scroll.scrollTo(scroll.scrollHeight) + const page = paging() + if (page?.hasNewer && !page.loading) { + sync.session.jumpToLatest(route.sessionID).then(() => { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + scroll.scrollTo(scroll.scrollHeight) + refreshEdges() + }) + }) + } else { + if (!scroll || scroll.isDestroyed) { + dialog.clear() + return + } + scroll.scrollTo(scroll.scrollHeight) + refreshEdges() + } dialog.clear() }, }, @@ -798,7 +940,10 @@ export function Session() { const child = scroll.getChildren().find((child) => { return child.id === message.id }) - if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (child) { + scroll.scrollBy(child.y - scroll.scrollTop - 1) + refreshEdges() + } break } } @@ -826,9 +971,9 @@ export function Session() { keybind: "messages_copy", category: "Session", onSelect: (dialog) => { - const revertID = session()?.revert?.messageID + const boundary = revertBoundary() const lastAssistantMessage = messages().findLast( - (msg) => msg.role === "assistant" && (!revertID || msg.id < revertID), + (msg) => msg.role === "assistant" && (!boundary || messageBefore(msg, boundary)), ) if (!lastAssistantMessage) { toast.show({ message: "No assistant messages found", variant: "error" }) @@ -882,7 +1027,6 @@ export function Session() { thinking: showThinking(), toolDetails: showDetails(), assistantMetadata: showAssistantMetadata(), - providers: sync.data.provider, }, ) await Clipboard.copy(transcript) @@ -927,7 +1071,6 @@ export function Session() { thinking: options.thinking, toolDetails: options.toolDetails, assistantMetadata: options.assistantMetadata, - providers: sync.data.provider, }, ) @@ -1012,13 +1155,47 @@ export function Session() { const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) + let revertPreviewEpoch = 0 + + createEffect( + on( + () => [route.sessionID, revertMessageID()] as const, + ([sessionID, revert]) => { + const epoch = ++revertPreviewEpoch + setRevertPreview(undefined) + if (!revert) { + return + } + void sdk.client.session + .revertPreview({ sessionID }) + .then((result) => { + if (epoch !== revertPreviewEpoch) return + setRevertPreview(result.data ?? undefined) + }) + .catch(() => { + if (epoch !== revertPreviewEpoch) return + setRevertPreview(undefined) + }) + }, + { defer: true }, + ), + ) const revertDiffFiles = createMemo(() => getRevertDiffFiles(revertInfo()?.diff ?? "")) const revertRevertedMessages = createMemo(() => { const messageID = revertMessageID() if (!messageID) return [] - return messages().filter((x) => x.id >= messageID && x.role === "user") + const boundary = messages().find((x) => x.id === messageID) + if (!boundary) return [] + return messages().filter((x) => x.role === "user" && !messageBefore(x, boundary)) + }) + const revertUserCount = createMemo(() => revertPreview()?.userCount ?? revertRevertedMessages().length) + + const revertBoundary = createMemo(() => { + const messageID = revertMessageID() + if (!messageID) return undefined + return messages().find((x) => x.id === messageID) }) const revert = createMemo(() => { @@ -1027,7 +1204,7 @@ export function Session() { if (!info.messageID) return return { messageID: info.messageID, - reverted: revertRevertedMessages(), + revertedCount: revertUserCount(), diff: info.diff, diffFiles: revertDiffFiles(), } @@ -1049,16 +1226,51 @@ export function Session() { showDetails, showGenericToolOutput, diffWrapMode, - providers, sync, tui: tuiConfig, }} > - + + + + Loading older messages... + + + + + (scroll up for more) + + + + + Failed to load: {paging()?.error} + (scroll to retry) + + (scroll = r)} + onMouseScroll={() => { + refreshEdges() + loadOlder() + loadNewer() + }} + onKeyDown={(e) => { + if (["up", "pageup", "home"].includes(e.name)) { + setTimeout(() => { + refreshEdges() + loadOlder() + }, 0) + } + if (["down", "pagedown", "end"].includes(e.name)) { + setTimeout(() => { + refreshEdges() + loadNewer() + }, 0) + } + }} + viewportCulling={true} viewportOptions={{ paddingRight: showScrollbar() ? 1 : 0, }} @@ -1075,7 +1287,6 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - {(message, index) => ( @@ -1113,7 +1324,7 @@ export function Session() { paddingLeft={2} backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} > - {revert()!.reverted.length} message reverted + {revert()!.revertedCount} message reverted {keybind.print("messages_redo")} or /redo to restore @@ -1140,7 +1351,13 @@ export function Session() { ) })()} - = revert()!.messageID}> + { + const boundary = revertBoundary() + if (!boundary) return false + return !messageBefore(message, boundary) + })()} + > <> @@ -1172,6 +1389,16 @@ export function Session() { )} + + + Loading newer messages... + + + + + (scroll down for more) + + 0}> @@ -1182,6 +1409,9 @@ export function Session() { + +