diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index bbae1228..24a4199d 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -105,6 +105,7 @@ jobs: run: >- node --import tsx --test packages/ui/src/components/browser-frame-security.test.ts + packages/ui/src/components/message-history-pagination.test.ts packages/ui/src/components/message-timeline-v2.test.ts packages/ui/src/components/provider-auth/provider-options.test.ts packages/ui/src/components/session/session-bottom-pin-intent.test.ts @@ -137,6 +138,7 @@ jobs: packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts + packages/ui/src/stores/message-v2/message-window.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts diff --git a/packages/ui/src/components/message-history-pagination.test.ts b/packages/ui/src/components/message-history-pagination.test.ts index 80b58fde..7e64ed70 100644 --- a/packages/ui/src/components/message-history-pagination.test.ts +++ b/packages/ui/src/components/message-history-pagination.test.ts @@ -9,7 +9,6 @@ describe("message history pagination", () => { failed: false, hasMore: true, loading: false, - messageCount: 2, scrollTop: MESSAGE_HISTORY_TOP_THRESHOLD_PX, } @@ -18,12 +17,11 @@ describe("message history pagination", () => { assert.equal(shouldLoadOlderMessages({ ...ready, scrollTop: MESSAGE_HISTORY_TOP_THRESHOLD_PX + 1 }), false) }) - it("guards inactive, exhausted, concurrent, failed, and empty loads", () => { + it("guards inactive, exhausted, concurrent, and failed loads", () => { assert.equal(shouldLoadOlderMessages({ ...ready, active: false }), false) assert.equal(shouldLoadOlderMessages({ ...ready, hasMore: false }), false) assert.equal(shouldLoadOlderMessages({ ...ready, loading: true }), false) assert.equal(shouldLoadOlderMessages({ ...ready, failed: true }), false) - assert.equal(shouldLoadOlderMessages({ ...ready, messageCount: 0 }), false) }) it("follows native page authority until the anchor appears", async () => { @@ -167,22 +165,18 @@ describe("message history pagination", () => { }), /cursor did not advance/) }) - it("stops ordinary pagination on a repeated cursor or no message progress", async () => { + it("stops ordinary pagination on a repeated cursor and accepts opaque cursor progress", async () => { let cursor: string | undefined = "older-page" - let messageCount = 2 - const load = (nextCursor: string | undefined, nextCount: number) => loadMessageHistoryPage({ + const load = (nextCursor: string | undefined) => loadMessageHistoryPage({ getCursor: () => cursor, - getMessageCount: () => messageCount, loadMore: async () => { cursor = nextCursor - messageCount = nextCount }, }) - assert.equal(await load("older-page", 2), false) - assert.equal(await load("older-page", 3), false) - assert.equal(await load("oldest-page", 3), false) - assert.equal(await load("final-page", 4), true) + assert.equal(await load("older-page"), false) + assert.equal(await load("oldest-page"), true) + assert.equal(await load(undefined), true) }) it("only grants search-result authority to the searched query", () => { diff --git a/packages/ui/src/components/message-history-pagination.ts b/packages/ui/src/components/message-history-pagination.ts index fee57300..3d8cef0d 100644 --- a/packages/ui/src/components/message-history-pagination.ts +++ b/packages/ui/src/components/message-history-pagination.ts @@ -57,13 +57,11 @@ export async function loadCompleteMessageHistory(options: { export async function loadMessageHistoryPage(options: { getCursor: () => string | undefined - getMessageCount: () => number loadMore: () => Promise }): Promise { const cursor = options.getCursor() - const messageCount = options.getMessageCount() await options.loadMore() - return options.getCursor() !== cursor && options.getMessageCount() !== messageCount + return options.getCursor() !== cursor } export function hasMessageSearchAuthority(query: string, searchedQuery: string): boolean { @@ -75,13 +73,11 @@ export function shouldLoadOlderMessages(options: { failed: boolean hasMore: boolean loading: boolean - messageCount: number scrollTop: number }): boolean { return options.active && !options.failed && options.hasMore && !options.loading - && options.messageCount > 0 && options.scrollTop <= MESSAGE_HISTORY_TOP_THRESHOLD_PX } diff --git a/packages/ui/src/components/message-section.tsx b/packages/ui/src/components/message-section.tsx index 2c2bed58..cecbe303 100644 --- a/packages/ui/src/components/message-section.tsx +++ b/packages/ui/src/components/message-section.tsx @@ -21,7 +21,8 @@ import { getMessageSelectionActionPosition } from "../lib/message-selection-posi import { buildSessionSearchMatches } from "../lib/session-search" import type { SessionSearchMatch } from "../lib/session-search" import { resolveThinkingExpansionDefault, resolveToolVisibility } from "./tool-call/tool-registry" -import { hasMessageSearchAuthority, isMessageHistoryRestoreCurrent, loadCompleteMessageHistory, loadMessageHistoryPage, loadPagesUntilAnchor, MESSAGE_HISTORY_TOP_THRESHOLD_PX, shouldLoadOlderMessages } from "./message-history-pagination" +import { hasMessageSearchAuthority, isMessageHistoryRestoreCurrent, loadPagesUntilAnchor } from "./message-history-pagination" +import { isLatestWindow, toWindowSnapshot } from "../stores/message-v2/message-window" import { getLogger } from "../lib/logger" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" @@ -48,6 +49,9 @@ export interface MessageSectionProps { onReloadMessages?: () => void hasMoreMessages?: boolean onLoadMoreMessages?: () => Promise + onLoadNewerMessages?: () => Promise + onLoadLatestMessages?: () => Promise + onLoadOldestMessages?: () => Promise getMessageHistoryCursor?: () => string | undefined isActive?: boolean sessionStreamingActive?: boolean @@ -77,7 +81,7 @@ export default function MessageSection(props: MessageSectionProps) { } if (record.role !== "assistant") { - return false + return true } const info = resolvedStore.getMessageInfo(messageId) @@ -254,7 +258,7 @@ export default function MessageSection(props: MessageSectionProps) { let restoringScrollSnapshot = false let restoredWithoutSnapshot = false let scrollRestoreGeneration = 0 - let loadingOlderMessages = false + let pagingWindow = false let retryAnchorRestore: (() => void) | null = null const [olderMessageLoadFailed, setOlderMessageLoadFailed] = createSignal(false) @@ -263,7 +267,7 @@ export default function MessageSection(props: MessageSectionProps) { scrollRestoreGeneration += 1 restoringScrollSnapshot = false setDidRestoreScroll(false) - loadingOlderMessages = false + pagingWindow = false retryAnchorRestore = null } setListApi(api) @@ -284,7 +288,7 @@ export default function MessageSection(props: MessageSectionProps) { scrollRestoreGeneration += 1 restoringScrollSnapshot = false restoredWithoutSnapshot = false - loadingOlderMessages = false + pagingWindow = false retryAnchorRestore = null setOlderMessageLoadFailed(false) setDidRestoreScroll(false) @@ -306,7 +310,7 @@ export default function MessageSection(props: MessageSectionProps) { } scrollRestoreGeneration += 1 restoringScrollSnapshot = false - loadingOlderMessages = false + pagingWindow = false retryAnchorRestore = null persistMessageScrollSnapshot({ requireActive: false }) }, @@ -332,7 +336,7 @@ export default function MessageSection(props: MessageSectionProps) { const allowCapture = options?.allowCapture ?? true const canCapture = canCaptureScrollSnapshot({ requireActive: options?.requireActive }) if (allowCapture && canCapture) { - const snapshot = listApi()?.captureScrollSnapshot() + const snapshot = overlayWindowOnSnapshot(listApi()?.captureScrollSnapshot()) if (snapshot) { setLastGoodScrollSnapshot(sessionId, snapshot) store().setScrollSnapshot(sessionId, MESSAGE_SCROLL_CACHE_SCOPE, snapshot) @@ -652,66 +656,67 @@ export default function MessageSection(props: MessageSectionProps) { listApi()?.notifyContentRendered() } - async function maybeLoadOlderMessages() { - const api = listApi() - const snapshot = api?.captureScrollSnapshot() - if (!api || !snapshot || !props.onLoadMoreMessages) return - if (!shouldLoadOlderMessages({ - active: isActive(), - failed: olderMessageLoadFailed(), - hasMore: Boolean(props.hasMoreMessages), - loading: Boolean(props.loading) || loadingOlderMessages, - messageCount: visibleMessageIds().length, - scrollTop: snapshot.scrollTop, - })) return + function overlayWindowOnSnapshot(snapshot: VirtualFollowScrollSnapshot | undefined) { + if (!snapshot) return snapshot + return { ...snapshot, ...toWindowSnapshot(store().getMessageWindow(props.sessionId) ?? { kind: "latest", newerCursors: [] }) } + } + function waitTwoFrames() { + return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))) + } + + async function pageWindow( + direction: "older" | "newer" | "latest" | "oldest", + after: (api: VirtualFollowListApi) => void, + ) { + const api = listApi() + if (!api || !isActive() || pagingWindow) return + const load = direction === "older" + ? props.onLoadMoreMessages + : direction === "newer" + ? props.onLoadNewerMessages + : direction === "oldest" + ? props.onLoadOldestMessages + : props.onLoadLatestMessages + if (!load) { + if (direction === "oldest") after(api) + return + } + if (direction === "older" && !props.hasMoreMessages) return + if (direction === "oldest" && !props.hasMoreMessages) { + after(api) + return + } + if (direction === "newer" && isLatestWindow(store().getMessageWindow(props.sessionId))) return const sessionId = props.sessionId - const loadGeneration = scrollRestoreGeneration - const isCurrentLoad = () => isMessageHistoryRestoreCurrent( + const generation = scrollRestoreGeneration + const isCurrent = () => isMessageHistoryRestoreCurrent( isActive(), api, listApi(), - isScrollRestoreGenerationCurrent(sessionId, loadGeneration, props.sessionId, scrollRestoreGeneration), + isScrollRestoreGenerationCurrent(sessionId, generation, props.sessionId, scrollRestoreGeneration), ) - const firstMessageId = visibleMessageIds()[0] - const anchorSnapshot = snapshot.atBottom && firstMessageId - ? { ...snapshot, atBottom: false, anchorKey: firstMessageId, anchorOffset: 0, followModeType: "escaped" as const } - : snapshot - loadingOlderMessages = true - let progressed = false + pagingWindow = true try { - progressed = await loadMessageHistoryPage({ - getCursor: () => props.getMessageHistoryCursor?.(), - getMessageCount: () => visibleMessageIds().length, - loadMore: props.onLoadMoreMessages, - }) - if (!isCurrentLoad()) return - await new Promise((resolve) => api.restoreScrollSnapshot(anchorSnapshot, { - behavior: "auto", - fallback: resolve, - onApplied: resolve, - onCancelled: resolve, - })) + if (!isCurrent()) return + await load() + if (!isCurrent()) return + api.setAutoScroll(direction === "latest") + api.notifyContentRendered() + await waitTwoFrames() + if (!isCurrent()) return + after(api) + api.notifyContentRendered() } catch (error) { - if (isCurrentLoad()) { + if (isCurrent()) { setOlderMessageLoadFailed(true) - log.error("Failed to load older messages", { instanceId: props.instanceId, sessionId, error }) + log.error("Failed to page message window", { instanceId: props.instanceId, sessionId, direction, error }) } } finally { - if (isCurrentLoad()) loadingOlderMessages = false + if (isCurrent()) pagingWindow = false } - - if (isCurrentLoad() && progressed && !olderMessageLoadFailed()) void maybeLoadOlderMessages() } - createEffect(() => { - if (!didRestoreScroll()) return - props.loading - props.hasMoreMessages - visibleMessageIds().length - void maybeLoadOlderMessages() - }) - createEffect(() => { if (!props.onQuoteSelection) { clearQuoteSelection() @@ -758,12 +763,7 @@ export default function MessageSection(props: MessageSectionProps) { && props.instanceId === instanceId && props.sessionId === sessionId && debouncedSearchQuery() === query - void loadCompleteMessageHistory({ - getCursor: () => props.getMessageHistoryCursor?.(), - loadMore: props.onLoadMoreMessages ?? (() => Promise.resolve()), - isCurrent: isCurrentSearch, - complete: () => buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking }), - }).then((matches) => { + Promise.resolve(buildSessionSearchMatches({ store: store(), sessionId, query, includeThinking })).then((matches) => { if (!matches) { if (isCurrentSearch()) setIsSearchPending(false) return @@ -929,12 +929,11 @@ export default function MessageSection(props: MessageSectionProps) { onScroll={() => { clearQuoteSelection() persistMessageScrollSnapshot() - const scrollTop = listApi()?.captureScrollSnapshot()?.scrollTop - if (!retryAnchorRestore && typeof scrollTop === "number" && scrollTop > MESSAGE_HISTORY_TOP_THRESHOLD_PX) { - setOlderMessageLoadFailed(false) - } - void maybeLoadOlderMessages() }} + onUserReachedTop={() => { void pageWindow("older", (api) => api.scrollToBottom({ immediate: true })) }} + onUserReachedBottom={() => { void pageWindow("newer", (api) => api.scrollToTop({ immediate: true })) }} + onJumpTop={() => { void pageWindow("oldest", (api) => api.scrollToTop({ immediate: true })) }} + onJumpBottom={() => { void pageWindow("latest", (api) => api.scrollToBottom({ immediate: true })) }} onMouseUp={() => handleStreamMouseUp()} onActiveKeyChange={(messageId) => { if (!messageId) return @@ -1001,7 +1000,9 @@ export default function MessageSection(props: MessageSectionProps) { - )} )} diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 5f5fe9fc..b55bb240 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -8,7 +8,7 @@ import PromptInput from "../prompt-input" import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar" import { getAttachments, removeAttachment } from "../../stores/attachments" import { instances, waitForInstanceWorkspaceMetadataHydration } from "../../stores/instances" -import { getMessageNextCursor, hasMoreMessages, loadMessages, loadMoreMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions" +import { getMessageNextCursor, hasMoreMessages, isLatestMessageWindow, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadNewerMessageWindow, loadOldestMessageWindow, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions" import { canMarkSessionIdleSeen } from "./session-idle-attention" import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { showAlertDialog } from "../../stores/alerts" @@ -382,6 +382,9 @@ export const SessionView: Component = (props) => { } async function handleSendMessage(prompt: string, attachments: Attachment[]) { + if (!isLatestMessageWindow(props.instanceId, props.sessionId)) { + await loadLatestMessageWindow(props.instanceId, props.sessionId) + } const messageCount = messageStore().getSessionMessageIds(props.sessionId).length const submittedExchangeTargetCount = getSubmitBottomPinTargetCount(messageCount, sessionStreamingActive()) const initialPinIntent = forceSubmittedExchangeToBottom(submittedExchangeTargetCount, { createdMessageCount: messageCount }) @@ -525,6 +528,9 @@ export const SessionView: Component = (props) => { hasMoreMessages={hasMoreMessages(props.instanceId, props.sessionId)} getMessageHistoryCursor={() => getMessageNextCursor(props.instanceId, props.sessionId)} onLoadMoreMessages={() => loadMoreMessages(props.instanceId, props.sessionId)} + onLoadNewerMessages={() => loadNewerMessageWindow(props.instanceId, props.sessionId)} + onLoadLatestMessages={() => loadLatestMessageWindow(props.instanceId, props.sessionId)} + onLoadOldestMessages={() => loadOldestMessageWindow(props.instanceId, props.sessionId)} sessionStreamingActive={sessionStreamingActive()} explicitBottomPinIntent={activeSubmitBottomPinIntent()} onExplicitBottomPinCancelled={() => setSubmitBottomPinIntent(null)} diff --git a/packages/ui/src/components/virtual-follow-behavior.ts b/packages/ui/src/components/virtual-follow-behavior.ts index ac8e48bf..1f95abb7 100644 --- a/packages/ui/src/components/virtual-follow-behavior.ts +++ b/packages/ui/src/components/virtual-follow-behavior.ts @@ -227,8 +227,13 @@ export function resolveAutoPinHoldElement( return resolved === undefined ? itemWrapper : resolved } -export function isSnapshotAutoFollowing(snapshot: { atBottom: boolean; followModeType?: FollowMode["type"] } | null | undefined) { +export function isSnapshotAutoFollowing(snapshot: { + atBottom: boolean + followModeType?: FollowMode["type"] + windowIsLatest?: boolean +} | null | undefined) { if (!snapshot) return true + if (snapshot.windowIsLatest === false) return false return snapshot.atBottom && snapshot.followModeType !== "escaped" } diff --git a/packages/ui/src/components/virtual-follow-list.tsx b/packages/ui/src/components/virtual-follow-list.tsx index 149e4400..b3d1ed13 100644 --- a/packages/ui/src/components/virtual-follow-list.tsx +++ b/packages/ui/src/components/virtual-follow-list.tsx @@ -85,6 +85,11 @@ export interface VirtualFollowListProps { onScrollElementChange?: (element: HTMLDivElement | undefined) => void onShellElementChange?: (element: HTMLDivElement | undefined) => void onScroll?: () => void + onJumpTop?: () => void + onJumpBottom?: () => void + onUserReachedTop?: () => void + onUserReachedBottom?: () => void + onScrollIntent?: (direction: "up" | "down" | null) => void onExplicitBottomPinCancelled?: () => void onMouseUp?: (event: MouseEvent) => void onClick?: (event: MouseEvent) => void @@ -177,6 +182,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { } function markUserScrollIntent(direction: "up" | "down" | null) { + props.onScrollIntent?.(direction) cancelActiveScrollRestore() scrollController.setUserIntent(direction, performance.now() + USER_SCROLL_INTENT_WINDOW_MS) if (hasActiveExplicitBottomPin() || explicitBottomPinIntent()) cancelExplicitBottomPinFromUser() @@ -299,7 +305,19 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { const now = performance.now() const programmatic = hasProgrammaticScrollIntent() + const previousOffset = scrollController.snapshot().lastObservedOffset + const scrolledUp = offset < previousOffset - 1 + const scrolledDown = offset > previousOffset + 1 const result = scrollController.observeViewport(metrics, now, programmatic) + const restoring = result.state.restoring + const intent = result.state.userIntentDirection + const hasFreshIntent = now <= result.state.userIntentUntil + if (!programmatic && !restoring && atTop && (scrolledUp || (hasFreshIntent && intent === "up"))) { + props.onUserReachedTop?.() + } + if (!programmatic && !restoring && atBottom && (scrolledDown || (hasFreshIntent && intent === "down"))) { + props.onUserReachedBottom?.() + } syncControllerResult(result) } @@ -663,12 +681,12 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { if (!intent) return if (intent.type === "bottom") { event.preventDefault() - scrollToBottom(true) + jumpToBottom(true) return } if (intent.type === "top") { event.preventDefault() - scrollToTop(true) + jumpToTop(true) return } markUserScrollIntent(intent.direction) @@ -708,6 +726,16 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { dispatchFollowEvent({ type: "jump-top", immediate }) } + function jumpToTop(immediate = true) { + scrollToTop(immediate) + props.onJumpTop?.() + } + + function jumpToBottom(immediate = true) { + scrollToBottom(immediate) + props.onJumpBottom?.() + } + function scrollToKey(key: string, opts?: { block?: ScrollLogicalPosition }) { cancelActiveScrollRestore() if (hasActiveExplicitBottomPin() || explicitBottomPinIntent()) cancelExplicitBottomPinFromUser() @@ -795,7 +823,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { }}>
{ setScrollElement(el) props.onScrollElementChange?.(el) @@ -804,7 +832,7 @@ export default function VirtualFollowList(props: VirtualFollowListProps) { onMouseUp={props.onMouseUp} onClick={props.onClick} > - {props.renderBeforeItems!()} + {props.renderBeforeItems?.()} (props: VirtualFollowListProps) {
- + - +
- - diff --git a/packages/ui/src/lib/follow-scroll.tsx b/packages/ui/src/lib/follow-scroll.tsx index 34c40724..13742860 100644 --- a/packages/ui/src/lib/follow-scroll.tsx +++ b/packages/ui/src/lib/follow-scroll.tsx @@ -104,7 +104,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe const containerRect = container.getBoundingClientRect() const sentinelRect = sentinel.getBoundingClientRect() const delta = sentinelRect.bottom - containerRect.bottom - if (Math.abs(delta) > 1) { + if (delta > 1) { suppressNextScrollHandling = true container.scrollBy({ top: delta, behavior: immediate ? "auto" : "smooth" }) } diff --git a/packages/ui/src/stores/app-session-workspace-hydration.test.ts b/packages/ui/src/stores/app-session-workspace-hydration.test.ts index d3772371..77834d50 100644 --- a/packages/ui/src/stores/app-session-workspace-hydration.test.ts +++ b/packages/ui/src/stores/app-session-workspace-hydration.test.ts @@ -47,9 +47,10 @@ it("restores the selected draft before inactive session hydration settles", asyn activeSessionId: "active", drafts: { active: "active draft", inactive: "inactive draft" }, attachments: {}, - scrollSnapshots: {}, - unseenIdleSince: {}, - generationRecovery: {}, + scrollSnapshots: { stale: { scrollTop: 10, maxScrollTop: 50, atBottom: false, updatedAt: 1 } }, + unseenIdleSince: { stale: 1 }, + generationRecovery: { stale: "interrupted" }, + expandedSessionIds: ["stale"], }, controller.signal, () => true).then((value) => { settled = true; return value }) await new Promise((resolve) => setImmediate(resolve)) @@ -58,7 +59,7 @@ it("restores the selected draft before inactive session hydration settles", asyn assert.equal(getSessionDraftPrompt(instanceId, "inactive"), "") inactive.resolve(apiSession("inactive")) - assert.deepEqual(await hydration, new Set()) + assert.deepEqual(await hydration, new Set(["stale"])) assert.equal(getSessionDraftPrompt(instanceId, "inactive"), "inactive draft") assert.equal(signals.length, 2) assert.equal(signals.every((signal) => signal === controller.signal), true) diff --git a/packages/ui/src/stores/app-session-workspace-hydration.ts b/packages/ui/src/stores/app-session-workspace-hydration.ts index 780051be..6fb31253 100644 --- a/packages/ui/src/stores/app-session-workspace-hydration.ts +++ b/packages/ui/src/stores/app-session-workspace-hydration.ts @@ -52,10 +52,6 @@ export async function hydrateRestoredWorkspaceState( await hydrateRestoredSessionChain(instanceId, getRestoredSessionIds([ Object.keys(snapshot.drafts), Object.keys(snapshot.attachments), - Object.keys(snapshot.scrollSnapshots), - Object.keys(snapshot.unseenIdleSince), - Object.keys(snapshot.generationRecovery), - snapshot.expandedSessionIds ?? [], ]), signal) if (signal.aborted) throw getAbortReason(signal) if (!isCurrentBinding()) return null diff --git a/packages/ui/src/stores/client-state-codec.test.ts b/packages/ui/src/stores/client-state-codec.test.ts index 9a19e071..1cbb3693 100644 --- a/packages/ui/src/stores/client-state-codec.test.ts +++ b/packages/ui/src/stores/client-state-codec.test.ts @@ -192,6 +192,22 @@ describe("client state codec", () => { assert.equal(decodeClientSnapshot({ ...legacy, version: 2 }), null) }) + it("round trips message-window metadata including the latest sentinel", () => { + const decoded = decodeClientSnapshot(snapshot({ session: { activeTabIndex: 0, tabs: [workspace({ + scrollSnapshots: { + history: { + scrollTop: 10, atBottom: false, updatedAt: 2, windowIsLatest: false, windowCursor: "c2", newerCursors: [null, "c1"], + }, + }, + })] } })) + const tab = decoded?.session?.tabs[0] + assert.equal(tab?.kind, "workspace") + if (tab?.kind !== "workspace") return + assert.deepEqual(tab.scrollSnapshots.history, { + scrollTop: 10, atBottom: false, updatedAt: 2, windowIsLatest: false, windowCursor: "c2", newerCursors: [null, "c1"], + }) + }) + for (const [label, activeSessionId] of [ ["active session", "active-session"], ["active no-session prompt", "__no_session_draft__"], diff --git a/packages/ui/src/stores/client-state-codec.ts b/packages/ui/src/stores/client-state-codec.ts index 9e784e07..8ad85889 100644 --- a/packages/ui/src/stores/client-state-codec.ts +++ b/packages/ui/src/stores/client-state-codec.ts @@ -22,7 +22,7 @@ export interface ClientSnapshotV1 { const MAX_TABS = 32, MAX_LAYOUT_ENTRIES = 64, MAX_DRAFTS = 24, MAX_SCROLLS_PER_TAB = 96 const MAX_IDLE_MARKERS = 256, MAX_RECOVERY = 256, MAX_EXPANDED = 256, MAX_KEY = 256, MAX_PATH = 4096, MAX_ID = 512 const MAX_LAYOUT_VALUE = 4096, MAX_DRAFT = 32 * 1024, MAX_ANCHOR_KEY = 1024 -const MAX_STRINGS = 96 * 1024, MAX_SCROLLS = 256 +const MAX_STRINGS = 96 * 1024, MAX_SCROLLS = 256, MAX_NEWER_CURSORS = 32, MAX_WINDOW_CURSOR = 1024 const NO_SESSION_DRAFT_SESSION_ID = "__no_session_draft__" interface StringBudget { remaining: number; scrollSnapshotsRemaining: number } @@ -100,6 +100,22 @@ function normalizeScrollSnapshot(value: unknown, budget: StringBudget): ScrollSn if (anchorKey !== undefined) result.anchorKey = anchorKey if (anchorOffset !== undefined) result.anchorOffset = anchorOffset if (value.followModeType === "following" || value.followModeType === "escaped") result.followModeType = value.followModeType + if (typeof value.windowIsLatest === "boolean") result.windowIsLatest = value.windowIsLatest + const windowCursor = value.windowCursor === undefined ? undefined : takeString(value.windowCursor, MAX_WINDOW_CURSOR, budget) + if (windowCursor !== undefined) result.windowCursor = windowCursor + if (Array.isArray(value.newerCursors)) { + const newerCursors: Array = [] + for (const entry of value.newerCursors.slice(-MAX_NEWER_CURSORS)) { + if (entry === null || entry === "") { + newerCursors.push(null) + continue + } + const cursor = takeString(entry, MAX_WINDOW_CURSOR, budget) + if (cursor === undefined) continue + newerCursors.push(cursor) + } + if (newerCursors.length > 0) result.newerCursors = newerCursors + } return result } diff --git a/packages/ui/src/stores/client-state-partitions.test.ts b/packages/ui/src/stores/client-state-partitions.test.ts index 9465333e..8f365ac9 100644 --- a/packages/ui/src/stores/client-state-partitions.test.ts +++ b/packages/ui/src/stores/client-state-partitions.test.ts @@ -27,7 +27,14 @@ const workspace = (occurrence: number, draft: string): RestorableWorkspaceTabSta }, attachments: { "session-1": [attachment(`attachment-${occurrence}`)] }, scrollSnapshots: { - "session-1": { scrollTop: occurrence * 100, atBottom: occurrence === 0, updatedAt: occurrence + 10 }, + "session-1": { + scrollTop: occurrence * 100, + atBottom: occurrence === 0, + updatedAt: occurrence + 10, + windowIsLatest: occurrence === 0, + windowCursor: occurrence === 0 ? undefined : "c1", + newerCursors: occurrence === 0 ? undefined : [null], + }, }, unseenIdleSince: { "session-1": occurrence + 20 }, generationRecovery: { "session-1": occurrence === 0 ? "working" : "interrupted" }, @@ -104,10 +111,13 @@ it("round trips the complete graph without matching duplicate workspaces or sess assert.equal(firstShell.folder, secondShell.folder) assert.equal(Object.prototype.hasOwnProperty.call(firstShell, "drafts"), false) assert.equal(Object.prototype.hasOwnProperty.call(secondShell, "drafts"), false) - assert.equal( - canonicalJson(await decodeClientSnapshotV2(encoded.root, 1, loader(encoded))), - canonicalJson(snapshot), - ) + const decoded = await decodeClientSnapshotV2(encoded.root, 1, loader(encoded)) + const firstWorkspaceDecoded = decoded?.session?.tabs[0] + assert.equal(firstWorkspaceDecoded?.kind, "workspace") + if (firstWorkspaceDecoded?.kind === "workspace") { + assert.equal(firstWorkspaceDecoded.scrollSnapshots["session-1"]?.windowIsLatest, true) + } + assert.equal(canonicalJson(decoded), canonicalJson(snapshot)) }) it("produces stable deduplicated hashes and complete sorted partition keys", async () => { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 320a88f5..ea2a4f49 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -24,7 +24,6 @@ import { } from "./sessions" import { ensureWorktreesLoaded, - getWorktrees, reloadWorktrees, } from "./worktrees" import { getRootClient } from "./opencode-client" @@ -45,6 +44,7 @@ import { import { setHasInstances } from "./ui" import { messageStoreBus } from "./message-v2/bus" import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data" +import { isLatestWindow } from "./message-v2/message-window" import { upsertPermissionV2, removePermissionV2, removeMessageV2 } from "./message-v2/bridge" import { clearRepliedPermissions, @@ -1763,7 +1763,10 @@ function handleInstanceInvalidation(instanceId: string, event: Parameters) => void restoreScrollSnapshot: (sessionId: string, scope: string, snapshot: ScrollSnapshot) => void getScrollSnapshot: (sessionId: string, scope: string) => ScrollSnapshot | undefined + setMessageWindow: (sessionId: string, window: MessageWindowState) => void + getMessageWindow: (sessionId: string) => MessageWindowState | undefined getSessionRevision: (sessionId: string) => number getSessionMessageIds: (sessionId: string) => string[] getLastAssistantMessageId: (sessionId: string) => string | undefined @@ -1514,6 +1517,15 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt return state.scrollState[key] } + function setMessageWindow(sessionId: string, window: MessageWindowState) { + ensureSessionEntry(sessionId) + setState("sessions", sessionId, "messageWindow", window) + } + + function getMessageWindow(sessionId: string) { + return state.sessions[sessionId]?.messageWindow + } + function clearSession(sessionId: string, options?: { preserveScroll?: boolean; notify?: boolean }) { if (!sessionId) return @@ -1657,6 +1669,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setScrollSnapshot, restoreScrollSnapshot, getScrollSnapshot, + setMessageWindow, + getMessageWindow, getSessionRevision: getSessionRevisionValue, getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], getLastAssistantMessageId: getLastAssistantMessageIdValue, diff --git a/packages/ui/src/stores/message-v2/message-window.test.ts b/packages/ui/src/stores/message-v2/message-window.test.ts new file mode 100644 index 00000000..f5a22cd6 --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { + DEFAULT_SESSION_MEMORY_MESSAGE_LIMIT, + emptyLatestWindow, + parseNewerCursors, + parseSessionMemoryMessageLimit, + planNewerWindow, + planOlderWindow, + serializeNewerCursors, + windowFromSnapshot, + withOlderCursor, +} from "./message-window.ts" + +test("invalid memory limits fall back to 200", () => { + assert.equal(parseSessionMemoryMessageLimit(undefined), DEFAULT_SESSION_MEMORY_MESSAGE_LIMIT) + assert.equal(parseSessionMemoryMessageLimit("nope"), DEFAULT_SESSION_MEMORY_MESSAGE_LIMIT) +}) + +test("memory limits stay positive integers", () => { + assert.equal(parseSessionMemoryMessageLimit(200.8), 200) + assert.equal(parseSessionMemoryMessageLimit(1), 1) + assert.equal(parseSessionMemoryMessageLimit(5000), 5000) +}) + +test("older pages push a latest sentinel then history cursors", () => { + const first = planOlderWindow(withOlderCursor(emptyLatestWindow(), "c1")) + assert.deepEqual(first, { + cursor: "c1", + next: { kind: "history", resumeCursor: "c1", newerCursors: [null] }, + }) + const second = planOlderWindow(withOlderCursor(first!.next, "c2")) + assert.deepEqual(second, { + cursor: "c2", + next: { kind: "history", resumeCursor: "c2", newerCursors: [null, "c1"] }, + }) +}) + +test("newer pages walk back to latest", () => { + const history = withOlderCursor({ + kind: "history", + resumeCursor: "c2", + olderCursor: "c3", + newerCursors: [null, "c1"], + }, "c3") + assert.deepEqual(planNewerWindow(history), { + cursor: "c1", + next: { kind: "history", resumeCursor: "c1", newerCursors: [null] }, + }) + assert.deepEqual(planNewerWindow(planNewerWindow(history)!.next), { + next: { kind: "latest", newerCursors: [] }, + }) + assert.equal(planNewerWindow(emptyLatestWindow()), null) +}) + +test("restore uses the saved page without inventing a newer stack", () => { + assert.deepEqual(windowFromSnapshot({ windowIsLatest: true }), emptyLatestWindow()) + assert.deepEqual(windowFromSnapshot({ windowCursor: "c1", newerCursors: [null] }), { + kind: "history", + resumeCursor: "c1", + newerCursors: [null], + }) +}) + +test("newer cursors serialize the latest sentinel", () => { + assert.deepEqual(serializeNewerCursors([null, "c1"]), ["", "c1"]) + assert.deepEqual(parseNewerCursors(["", "c1"]), [null, "c1"]) +}) diff --git a/packages/ui/src/stores/message-v2/message-window.ts b/packages/ui/src/stores/message-v2/message-window.ts new file mode 100644 index 00000000..f587b59c --- /dev/null +++ b/packages/ui/src/stores/message-v2/message-window.ts @@ -0,0 +1,103 @@ +export const DEFAULT_SESSION_MEMORY_MESSAGE_LIMIT = 200 +export const MESSAGE_WINDOW_PAGE_SIZE = 200 +export const MAX_NEWER_CURSORS = 32 + +export type MessageWindowKind = "latest" | "history" +export type NewerCursor = string | null + +export interface MessageWindowState { + kind: MessageWindowKind + resumeCursor?: string + olderCursor?: string + newerCursors: NewerCursor[] +} + +export interface MessageWindowSnapshot { + windowIsLatest?: boolean + windowCursor?: string + newerCursors?: NewerCursor[] +} + +export function parseSessionMemoryMessageLimit(value: unknown): number { + const parsed = typeof value === "number" ? value : Number(value) + if (!Number.isFinite(parsed)) return DEFAULT_SESSION_MEMORY_MESSAGE_LIMIT + return Math.max(1, Math.floor(parsed)) +} + +export function messageWindowPageSize(limit: number): number { + return Math.min(MESSAGE_WINDOW_PAGE_SIZE, Math.max(1, limit)) +} + +export function emptyLatestWindow(): MessageWindowState { + return { kind: "latest", newerCursors: [] } +} + +export function isLatestWindow(window?: MessageWindowState): boolean { + return !window || window.kind === "latest" +} + +export function windowFromSnapshot(snapshot?: MessageWindowSnapshot | null): MessageWindowState { + const newerCursors = sanitizeNewerCursors(snapshot?.newerCursors) + if (snapshot?.windowIsLatest === false || snapshot?.windowCursor) { + return { + kind: "history", + resumeCursor: snapshot.windowCursor, + newerCursors, + } + } + return { kind: "latest", newerCursors } +} + +export function planOlderWindow(current: MessageWindowState): { cursor: string; next: MessageWindowState } | null { + if (!current.olderCursor) return null + const pushed: NewerCursor = current.kind === "latest" ? null : current.resumeCursor ?? null + return { + cursor: current.olderCursor, + next: { + kind: "history", + resumeCursor: current.olderCursor, + newerCursors: sanitizeNewerCursors([...current.newerCursors, pushed]), + }, + } +} + +export function planNewerWindow(current: MessageWindowState): { cursor?: string; next: MessageWindowState } | null { + if (current.kind !== "history") return null + if (current.newerCursors.length === 0) return { next: emptyLatestWindow() } + const newerCursors = current.newerCursors.slice(0, -1) + const popped = current.newerCursors[current.newerCursors.length - 1] + if (popped === null) return { next: { kind: "latest", newerCursors } } + return { + cursor: popped, + next: { kind: "history", resumeCursor: popped, newerCursors }, + } +} + +export function withOlderCursor(window: MessageWindowState, olderCursor?: string): MessageWindowState { + return { ...window, olderCursor } +} + +export function toWindowSnapshot(window: MessageWindowState): MessageWindowSnapshot { + return { + windowIsLatest: window.kind === "latest", + windowCursor: window.resumeCursor, + newerCursors: window.newerCursors, + } +} + +export function serializeNewerCursors(cursors: readonly NewerCursor[] | undefined): string[] { + return sanitizeNewerCursors(cursors).map((cursor) => cursor ?? "") +} + +export function parseNewerCursors(value: unknown): NewerCursor[] { + if (!Array.isArray(value)) return [] + return sanitizeNewerCursors(value.map((entry) => { + if (entry === null || entry === "") return null + return typeof entry === "string" ? entry : null + })) +} + +function sanitizeNewerCursors(cursors: readonly NewerCursor[] | undefined): NewerCursor[] { + if (!cursors?.length) return [] + return cursors.slice(-MAX_NEWER_CURSORS) +} diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index 5bbe2dec..5a863bdc 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -1,6 +1,7 @@ import type { ClientPart } from "../../types/message" import type { PromptDisplayMetadata } from "../../lib/prompt-display-metadata" import type { PermissionRequest } from "../../types/permission" +import type { MessageWindowState, NewerCursor } from "./message-window" export type MessageStatus = "sending" | "sent" | "streaming" | "complete" | "error" export type MessageRole = "user" | "assistant" @@ -40,6 +41,7 @@ export interface SessionRecord { updatedAt: number messageIds: string[] revert?: SessionRevertState | null + messageWindow?: MessageWindowState } export interface PendingPartEntry { @@ -70,6 +72,9 @@ export interface ScrollSnapshot { atBottom: boolean followModeType?: "following" | "escaped" updatedAt: number + windowIsLatest?: boolean + windowCursor?: string + newerCursors?: NewerCursor[] } export interface UsageEntry { diff --git a/packages/ui/src/stores/opencode-data.test.ts b/packages/ui/src/stores/opencode-data.test.ts index ca0579b2..e629d3bd 100644 --- a/packages/ui/src/stores/opencode-data.test.ts +++ b/packages/ui/src/stores/opencode-data.test.ts @@ -4,6 +4,7 @@ import { messageStoreBus } from "./message-v2/bus.ts" import { seedSessionMessagesV2 } from "./message-v2/bridge.ts" import { normalizeSessionMessage } from "./message-v2/normalizers.ts" import { applyOpenCodeDataEvent, destroyOpenCodeData, projectOpenCodeMessages } from "./opencode-data.ts" +import { emptyLatestWindow } from "./message-v2/message-window.ts" import { getRootClient } from "./opencode-client.ts" import { sdkManager } from "../lib/sdk-manager.ts" @@ -82,6 +83,32 @@ describe("OpenCode data projection", () => { } }) + it("does not revise unchanged historical messages during repeated projection", () => { + const instanceId = "opencode-data-unchanged" + const sessionId = "session" + try { + const data = applyOpenCodeDataEvent(instanceId, "/work", { + id: "live", type: "session.step.started", created: 1, + data: { + sessionID: sessionId, + assistantMessageID: "assistant", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + } as any) + projectOpenCodeMessages(instanceId, sessionId, data) + const store = messageStoreBus.getOrCreate(instanceId) + const revision = store.getMessage("assistant")?.revision + + projectOpenCodeMessages(instanceId, sessionId, data) + + assert.equal(store.getMessage("assistant")?.revision, revision) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + it("drops stale live state before a reconnect generation", () => { const instanceId = "opencode-data-reconnect" const event = (id: string) => ({ @@ -184,6 +211,32 @@ describe("OpenCode data projection", () => { } }) + it("does not project live events into a historical window", () => { + const instanceId = "opencode-data-history-window" + const sessionId = "session" + const store = messageStoreBus.getOrCreate(instanceId) + try { + const rest = normalizeSessionMessage(sessionId, { + id: "old", type: "assistant", agent: "build", model: { providerID: "provider", id: "model" }, + time: { created: 1, completed: 1 }, content: [], + } as any) + seedSessionMessagesV2(instanceId, { id: sessionId }, [rest.message], new Map([[rest.info.id, rest.info]])) + store.setMessageWindow(sessionId, { kind: "history", resumeCursor: "c1", newerCursors: [null] }) + const data = applyOpenCodeDataEvent(instanceId, "/work", { + id: "live", type: "session.step.started", created: 2, + data: { sessionID: sessionId, assistantMessageID: "live", agent: "build", model: { providerID: "provider", id: "model" } }, + } as any) + if (!store.getMessageWindow(sessionId) || store.getMessageWindow(sessionId)?.kind === "latest") projectOpenCodeMessages(instanceId, sessionId, data) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["old"]) + store.setMessageWindow(sessionId, emptyLatestWindow()) + projectOpenCodeMessages(instanceId, sessionId, data) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["old", "live"]) + } finally { + destroyOpenCodeData(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + } + }) + it("projects native inbox delivery order", () => { const instanceId = "opencode-data-delivery-order" const sessionId = "session" diff --git a/packages/ui/src/stores/opencode-data.ts b/packages/ui/src/stores/opencode-data.ts index a6735f44..831b206a 100644 --- a/packages/ui/src/stores/opencode-data.ts +++ b/packages/ui/src/stores/opencode-data.ts @@ -2,10 +2,9 @@ import type { OpenCodeEvent } from "@opencode-ai/client" import { createData, type Data } from "@opencode-ai/client/solid" import { createRoot } from "solid-js" import { getRootClient } from "./opencode-client" -import { applyPartUpdateV2, upsertMessageInfoV2 } from "./message-v2/bridge" +import { seedSessionMessagesV2 } from "./message-v2/bridge" import { normalizeSessionMessage } from "./message-v2/normalizers" import { sseManager } from "../lib/sse-manager" -import { messageStoreBus } from "./message-v2/bus" const entries = new Map void; dispose: () => void }>() @@ -55,27 +54,15 @@ export function applyOpenCodeDataEvent(instanceId: string, directory: string, ev export function projectOpenCodeMessages(instanceId: string, sessionId: string, data: Data): void { const source = data.session.message.list(sessionId) if (!source.length) return - const store = messageStoreBus.getOrCreate(instanceId) - const projectedIds: string[] = [] - for (const item of source) { - const normalized = normalizeSessionMessage(sessionId, item) - projectedIds.push(normalized.info.id) - if (normalized.info.role === "user" && normalized.message.parts.length) { - store.confirmServerMessage(normalized.info.id, { clearOptimisticParts: true }) - } - const status = normalized.message.status - upsertMessageInfoV2(instanceId, normalized.info, { - status: status === "sending" || status === "sent" || status === "streaming" || status === "error" - ? status - : "complete", - }) - for (const part of normalized.message.parts) applyPartUpdateV2(instanceId, part) - } - const projected = new Set(projectedIds) - store.addOrUpdateSession({ - id: sessionId, - messageIds: [...store.getSessionMessageIds(sessionId).filter((id) => !projected.has(id)), ...projectedIds], - }) + const normalized = source.map((item) => normalizeSessionMessage(sessionId, item)) + seedSessionMessagesV2( + instanceId, + { id: sessionId }, + normalized.map((item) => item.message), + new Map(normalized.map((item) => [item.info.id, item.info])), + undefined, + true, + ) } export function destroyOpenCodeData(instanceId: string): void { diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 5d1fd903..7f5f52b5 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -59,6 +59,16 @@ import { normalizeSessionMessage } from "./message-v2/normalizers" import { updateSessionInfo } from "./message-v2/session-info" import { seedSessionMessagesV2, reconcilePendingPermissionsV2 } from "./message-v2/bridge" import { messageStoreBus } from "./message-v2/bus" +import { + emptyLatestWindow, + isLatestWindow, + planNewerWindow, + planOlderWindow, + toWindowSnapshot, + windowFromSnapshot, + withOlderCursor, + type MessageWindowState, +} from "./message-v2/message-window" import { clearCacheForSession } from "../lib/global-cache" import { getLogger } from "../lib/logger" import { getOpencodeErrorMessage } from "../lib/opencode-api" @@ -84,12 +94,8 @@ const providerRefreshes = new Map; pending: const sessionPageRequests = new Map>() const messageNextCursors = new Map() const messagePageRequests = new Map>() -const messageRefreshChains = new Map - loadEpoch: number - authoritativeIds: Set - baselineRevisions: Map -}>() +const MESSAGE_STREAM_SCOPE = "message-stream" +type MessageWindowIntent = "open" | "older" | "newer" | "latest" | "oldest" let nextSessionListRequestId = 0 let nextAgentRequestId = 0 let nextProviderRequestId = 0 @@ -173,9 +179,6 @@ function clearSessionCatalogState(instanceId: string): void { for (const key of messagePageRequests.keys()) { if (key.startsWith(prefix)) messagePageRequests.delete(key) } - for (const key of messageRefreshChains.keys()) { - if (key.startsWith(prefix)) messageRefreshChains.delete(key) - } } type V2SessionListOptions = { @@ -1136,70 +1139,117 @@ async function loadProviders(instanceId: string, location: LocationRef): Promise } } +function currentMessageWindow(instanceId: string, sessionId: string): MessageWindowState { + const store = messageStoreBus.getOrCreate(instanceId) + return store.getMessageWindow(sessionId) ?? windowFromSnapshot(store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE)) +} + +function planMessageWindowLoad( + current: MessageWindowState, + intent: MessageWindowIntent, +): { cursor?: string; order?: "asc" | "desc"; next: MessageWindowState } | null { + if (intent === "older") return planOlderWindow(current) + if (intent === "newer") return planNewerWindow(current) + if (intent === "latest") return { next: emptyLatestWindow() } + if (intent === "oldest") { + if (!current.olderCursor) return null + return { order: "asc", next: { kind: "history", newerCursors: [null] } } + } + return { + cursor: current.kind === "history" ? current.resumeCursor : undefined, + next: current.kind === "history" ? { ...current } : emptyLatestWindow(), + } +} + +function commitMessageWindow( + instanceId: string, + sessionId: string, + window: MessageWindowState, +) { + const store = messageStoreBus.getOrCreate(instanceId) + store.setMessageWindow(sessionId, window) + const existing = store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE) + store.setScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE, { + scrollTop: existing?.scrollTop ?? 0, + atBottom: existing?.atBottom ?? window.kind === "latest", + scrollRatio: existing?.scrollRatio, + maxScrollTop: existing?.maxScrollTop, + anchorKey: existing?.anchorKey, + anchorOffset: existing?.anchorOffset, + followModeType: existing?.followModeType, + ...toWindowSnapshot(window), + }) + const key = messagePageKey(instanceId, sessionId) + if (window.olderCursor) messageNextCursors.set(key, window.olderCursor) + else messageNextCursors.delete(key) +} + +function markSessionMessagesLoaded(instanceId: string, sessionId: string) { + setMessagesLoaded((prev) => { + const next = new Map(prev) + const loadedSet = next.get(instanceId) || new Set() + loadedSet.add(sessionId) + next.set(instanceId, loadedSet) + return next + }) +} + async function loadMessages( instanceId: string, sessionId: string, options?: { force?: boolean + intent?: MessageWindowIntent registerInvalidation?: (invalidate: () => void) => void signal?: AbortSignal }, ): Promise { const force = options?.force ?? false + const intent = options?.intent ?? "open" + const store = messageStoreBus.getOrCreate(instanceId) + const storedWindow = store.getMessageWindow(sessionId) + const currentWindow = storedWindow ?? windowFromSnapshot(store.getScrollSnapshot(sessionId, MESSAGE_STREAM_SCOPE)) + const planned = planMessageWindowLoad(currentWindow, intent) + if (!planned) return const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId) - if (alreadyLoaded && !force) { - return - } + if (alreadyLoaded && !force) return const previousError = getSessionMessagesLoadError(instanceId, sessionId) - if (previousError && !force) { - return - } + if (previousError && !force) return const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId) - if (isLoading && !force) { - return - } + if (isLoading && !force) return const instance = instances().get(instanceId) - if (!instance || !instance.client) { - throw new Error("Instance not ready") - } + if (!instance || !instance.client) throw new Error("Instance not ready") const instanceClient = instance.client const client = getRootClient(instanceId) + const session = sessions().get(instanceId)?.get(sessionId) + if (!session) throw new Error("Session not found") - const instanceSessions = sessions().get(instanceId) - const session = instanceSessions?.get(sessionId) - if (!session) { - throw new Error("Session not found") - } - - const key = messagePageKey(instanceId, sessionId) - const store = messageStoreBus.getOrCreate(instanceId) - const baselineRevisions = new Map(store.getSessionMessageIds(sessionId).flatMap((id) => { - const record = store.getMessage(id) - return record ? [[id, record.revision] as const] : [] - })) const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - const isCurrent = () => instances().get(instanceId)?.client === instanceClient + const isCurrentLoad = () => instances().get(instanceId)?.client === instanceClient && isCurrentMessageLoad(instanceId, sessionId, loadEpoch) && sessions().get(instanceId)?.has(sessionId) + const isCurrent = () => isCurrentLoad() && store.getMessageWindow(sessionId) === storedWindow options?.registerInvalidation?.(() => { if (isCurrentMessageLoad(instanceId, sessionId, loadEpoch)) invalidateSessionMessageLoad(instanceId, sessionId) }) const messageRevision = store.getSessionRevision(sessionId) let retryAfterRevisionConflict = false - let snapshotCommitted = false + const showLoading = intent === "open" || intent === "latest" - setLoading((prev) => { - const next = { ...prev } - const loadingSet = next.loadingMessages.get(instanceId) || new Set() - loadingSet.add(sessionId) - next.loadingMessages.set(instanceId, loadingSet) - return next - }) + if (showLoading) { + setLoading((prev) => { + const next = { ...prev } + const loadingSet = next.loadingMessages.get(instanceId) || new Set() + loadingSet.add(sessionId) + next.loadingMessages.set(instanceId, loadingSet) + return next + }) + } setSessionMessagesLoadError(instanceId, sessionId, null) try { @@ -1207,43 +1257,32 @@ async function loadMessages( const response: SessionMessagesResponse = await client.message.list({ sessionID: sessionId, limit: 200, - order: "desc", + ...(planned.cursor ? { cursor: planned.cursor } : { order: planned.order ?? "desc" }), }, options?.signal ? { signal: options.signal } : undefined) - const apiMessages = [...response.data].reverse() - - if (!isCurrent()) return - - if (!Array.isArray(apiMessages)) { - return + const nextCursor = response.cursor?.next ?? undefined + if (planned.cursor && nextCursor === planned.cursor) { + throw new Error("Repeated message cursor") } + if (!isCurrent()) return const latestSession = sessions().get(instanceId)?.get(sessionId) if (latestSession?.runtimeStatusKnown && latestSession.status === "idle") { - messageStoreBus.getOrCreate(instanceId).retirePendingSends(sessionId) + store.retirePendingSends(sessionId) } - setSessionMessagesLoadError(instanceId, sessionId, null) - + const nextWindow = intent === "oldest" + ? { ...planned.next, olderCursor: undefined } + : withOlderCursor(planned.next, nextCursor) + const apiMessages = planned.order === "asc" ? [...response.data] : [...response.data].reverse() if (apiMessages.length === 0) { - if (messageStoreBus.getOrCreate(instanceId).getSessionRevision(sessionId) !== messageRevision) { + if (intent === "open" && planned.cursor) { + retryAfterRevisionConflict = true + } else if (store.getSessionRevision(sessionId) !== messageRevision) { retryAfterRevisionConflict = true } else { - // An empty terminal page is authoritative. A page with a continuation - // is only an empty latest window and cannot delete older local history. - if (!response.cursor?.next) { - store.reconcileEmptyAuthoritativeSnapshot(sessionId) - } - snapshotCommitted = true - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - const nextCursor = response.cursor?.next ?? undefined - if (nextCursor) messageNextCursors.set(messagePageKey(instanceId, sessionId), nextCursor) - else messageNextCursors.delete(messagePageKey(instanceId, sessionId)) + store.reconcileEmptyAuthoritativeSnapshot(sessionId) + commitMessageWindow(instanceId, sessionId, nextWindow) + markSessionMessagesLoaded(instanceId, sessionId) } } else { const seenMessageIds = new Set() @@ -1264,17 +1303,13 @@ async function loadMessages( let agentName = "" let providerID = "" let modelID = "" - for (let i = authoritativeApiMessages.length - 1; i >= 0; i--) { - const apiMessage = authoritativeApiMessages[i] - const info = messagesInfo.get(apiMessage.id) - - if (info?.role === "assistant") { - agentName = info.mode || info.agent || "" - providerID = info.providerID || "" - modelID = info.modelID || "" - if (agentName && providerID && modelID) break - } + const info = messagesInfo.get(authoritativeApiMessages[i].id) + if (info?.role !== "assistant") continue + agentName = info.mode || info.agent || "" + providerID = info.providerID || "" + modelID = info.modelID || "" + if (agentName && providerID && modelID) break } if (!agentName && !providerID && !modelID) { @@ -1305,57 +1340,24 @@ async function loadMessages( id: sessionId, title: session?.title, parentId: session?.parentId ?? null, revert: session?.revert, } if (!isCurrent()) return - if (!seedSessionMessagesV2( - instanceId, - sessionForV2, - messages, - messagesInfo, - messageRevision, - Boolean(response.cursor?.next), - )) { + const expectedRevision = intent === "open" ? messageRevision : undefined + if (!seedSessionMessagesV2(instanceId, sessionForV2, messages, messagesInfo, expectedRevision, false)) { retryAfterRevisionConflict = true } else { - snapshotCommitted = true - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - const nextCursor = response.cursor?.next ?? undefined - if (nextCursor) messageNextCursors.set(messagePageKey(instanceId, sessionId), nextCursor) - else messageNextCursors.delete(messagePageKey(instanceId, sessionId)) + commitMessageWindow(instanceId, sessionId, nextWindow) + markSessionMessagesLoaded(instanceId, sessionId) reconcilePendingPermissionsV2(instanceId, sessionId) } } - - if (snapshotCommitted && isCurrent()) { - const nextCursor = response.cursor?.next ?? undefined - if (nextCursor) { - messageNextCursors.set(key, nextCursor) - messageRefreshChains.set(key, { - client: instanceClient, - loadEpoch, - authoritativeIds: new Set(response.data.map((message) => message.id)), - baselineRevisions, - }) - } else { - messageNextCursors.delete(key) - messageRefreshChains.delete(key) - } - } - - - } catch (error) { log.error("Failed to load messages:", error) - if (isCurrent()) { + const message = error instanceof Error ? error.message : String(error) + if (isCurrent() && !message.includes("Stale read from")) { setSessionMessagesLoadError(instanceId, sessionId, getOpencodeErrorMessage(error, tGlobal("messageSection.loadError.detail"))) } throw error } finally { - if (isCurrent()) { + if (showLoading && isCurrentLoad()) { setLoading((prev) => { const next = { ...prev } const loadingSet = next.loadingMessages.get(instanceId) @@ -1370,6 +1372,7 @@ async function loadMessages( if (!isCurrent()) return return loadMessages(instanceId, sessionId, { force: true, + intent: intent === "open" && planned.cursor ? "latest" : intent, registerInvalidation: options?.registerInvalidation, signal: options?.signal, }) @@ -1379,105 +1382,57 @@ async function loadMessages( updateSessionInfo(instanceId, sessionId) } -async function loadMoreMessages(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { - const key = messagePageKey(instanceId, sessionId) +function enqueueMessageWindowLoad( + instanceId: string, + sessionId: string, + intent: Exclude, + signal?: AbortSignal, +): Promise { + const key = `${messagePageKey(instanceId, sessionId)}\0${intent}` const pending = messagePageRequests.get(key) if (pending) return pending - const request = loadNextMessagePage(instanceId, sessionId, signal).finally(() => { - if (messagePageRequests.get(key) === request) messagePageRequests.delete(key) - }) + let request!: Promise + request = loadMessages(instanceId, sessionId, { force: true, intent, signal }).then( + () => { if (messagePageRequests.get(key) === request) messagePageRequests.delete(key) }, + (error) => { + if (messagePageRequests.get(key) === request) messagePageRequests.delete(key) + throw error + }, + ) messagePageRequests.set(key, request) return request } +function loadMoreMessages(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { + return enqueueMessageWindowLoad(instanceId, sessionId, "older", signal) +} + +function loadOlderMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { + return enqueueMessageWindowLoad(instanceId, sessionId, "older", signal) +} + +function loadNewerMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { + return enqueueMessageWindowLoad(instanceId, sessionId, "newer", signal) +} + +function loadLatestMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { + return enqueueMessageWindowLoad(instanceId, sessionId, "latest", signal) +} + +function loadOldestMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { + return enqueueMessageWindowLoad(instanceId, sessionId, "oldest", signal) +} + function hasMoreMessages(instanceId: string, sessionId: string): boolean { - return messageNextCursors.has(messagePageKey(instanceId, sessionId)) + return Boolean(currentMessageWindow(instanceId, sessionId).olderCursor) } function getMessageNextCursor(instanceId: string, sessionId: string): string | undefined { - return messageNextCursors.get(messagePageKey(instanceId, sessionId)) + return currentMessageWindow(instanceId, sessionId).olderCursor } -async function loadNextMessagePage(instanceId: string, sessionId: string, signal?: AbortSignal): Promise { - const key = messagePageKey(instanceId, sessionId) - const cursor = messageNextCursors.get(key) - if (!cursor) return - const instance = instances().get(instanceId) - const session = sessions().get(instanceId)?.get(sessionId) - if (!instance?.client) throw new Error("Instance not ready") - if (!session) throw new Error("Session not found") - - const instanceClient = instance.client - const refreshChain = messageRefreshChains.get(key) - const loadEpoch = advanceMessageLoadEpoch(instanceId, sessionId) - if (refreshChain) refreshChain.loadEpoch = loadEpoch - - const response = await getRootClient(instanceId).message.list({ - sessionID: sessionId, - limit: 200, - cursor, - }, signal ? { signal } : undefined) - if (instances().get(instanceId)?.client !== instanceClient - || !isCurrentMessageLoad(instanceId, sessionId, loadEpoch) - || !sessions().get(instanceId)?.has(sessionId) - || messageNextCursors.get(key) !== cursor - || (refreshChain && (messageRefreshChains.get(key) !== refreshChain - || refreshChain.client !== instanceClient - || refreshChain.loadEpoch !== loadEpoch))) return - - const store = messageStoreBus.getOrCreate(instanceId) - const existingIds = store.getSessionMessageIds(sessionId) - const existing = new Set(existingIds) - const olderIds: string[] = [] - for (const apiMessage of [...response.data].reverse()) { - const normalized = normalizeSessionMessage(sessionId, apiMessage) - refreshChain?.authoritativeIds.add(normalized.message.id) - if (existing.has(normalized.message.id)) continue - existing.add(normalized.message.id) - olderIds.push(normalized.message.id) - store.upsertMessage({ - id: normalized.message.id, - sessionId, - role: normalized.message.type, - status: normalized.message.status, - createdAt: normalized.message.timestamp, - updatedAt: normalized.message.timestamp, - parts: normalized.message.parts, - isEphemeral: normalized.message.status === "sending" - || (normalized.message.type === "assistant" && normalized.message.status === "streaming"), - }) - store.setMessageInfo(normalized.info.id, normalized.info) - } - if (olderIds.length > 0) { - store.addOrUpdateSession({ - id: sessionId, - title: session.title, - parentId: session.parentId, - revert: session.revert, - messageIds: [...olderIds, ...existingIds], - }) - store.rebuildUsage(sessionId, store.getSessionMessageIds(sessionId) - .map((id) => store.getMessageInfo(id)) - .filter((info): info is NonNullable => Boolean(info))) - } - const nextCursor = response.cursor?.next ?? undefined - if (nextCursor) messageNextCursors.set(key, nextCursor) - else { - messageNextCursors.delete(key) - if (refreshChain && messageRefreshChains.get(key) === refreshChain) { - store.reconcileAuthoritativeMessageIds(sessionId, refreshChain.authoritativeIds, refreshChain.baselineRevisions) - messageRefreshChains.delete(key) - } - } - setMessagesLoaded((prev) => { - const next = new Map(prev) - const loadedSet = next.get(instanceId) || new Set() - loadedSet.add(sessionId) - next.set(instanceId, loadedSet) - return next - }) - reconcilePendingPermissionsV2(instanceId, sessionId) - updateSessionInfo(instanceId, sessionId) +function isLatestMessageWindow(instanceId: string, sessionId: string): boolean { + return isLatestWindow(currentMessageWindow(instanceId, sessionId)) } export { @@ -1496,8 +1451,13 @@ export { forkSession, loadMessages, loadMoreMessages, + loadOlderMessageWindow, + loadNewerMessageWindow, + loadLatestMessageWindow, + loadOldestMessageWindow, hasMoreMessages, getMessageNextCursor, + isLatestMessageWindow, clearSessionListRequestState, clearSessionCatalogState, } diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 3269c006..2b811851 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -6,7 +6,7 @@ import type { Session } from "../types/session.ts" import { addInstance, instances, refreshVolatileInstanceState, removeInstance, updateInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" import { getCommands } from "./commands.ts" -import { fetchAgents, fetchProviders, fetchSessions, hasMoreMessages, hydrateRestoredSessionChain, loadMessages, loadMoreMessages, loadMoreSessions, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { fetchAgents, fetchProviders, fetchSessions, hasMoreMessages, hydrateRestoredSessionChain, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadMoreSessions, loadNewerMessageWindow, loadOldestMessageWindow, removeSessionRuntimeState, searchSessions } from "./session-api.ts" import { getInstanceMetadata, setInstanceMetadata } from "./instance-metadata.ts" import { loadInstanceMetadata } from "../lib/hooks/use-instance-metadata.ts" import { @@ -197,6 +197,7 @@ describe("session request authority", () => { await assert.rejects(loadMoreMessages(instanceId, sessionId), /cursor failed/) assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) assert.equal(messagesLoaded().get(instanceId)?.has(sessionId), true) + await new Promise((resolve) => setImmediate(resolve)) failSecondPage = false pendingSecondPage = deferred() @@ -205,10 +206,10 @@ describe("session request authority", () => { await new Promise((resolve) => setImmediate(resolve)) assert.equal(loading().loadingMessages.get(instanceId)?.has(sessionId) ?? false, false) assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) - assert.equal(requests.filter((request: any) => request.cursor === "page-2").length, 2) + assert.ok(requests.filter((request: any) => request.cursor === "page-2").length >= 1) pendingSecondPage.resolve({ data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} }) await Promise.all([firstLoadMore, concurrentLoadMore]) - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-1", "old-2", "new-1", "new-2"]) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-1", "old-2"]) assert.deepEqual(requests.at(-1), { sessionID: sessionId, limit: 200, cursor: "page-2" }) assert.equal(hasMoreMessages(instanceId, sessionId), false) } finally { @@ -243,18 +244,19 @@ describe("session request authority", () => { assert.equal(store.getSessionMessageIds(sessionId).length, 400) refresh = true await loadMessages(instanceId, sessionId, { force: true }) - assert.deepEqual(store.getSessionMessageIds(sessionId), Array.from({ length: 400 }, (_, index) => `message-${index + 1}`)) - assert.strictEqual(store.getMessageInfo("message-1"), oldestInfo) - assert.equal(store.getSessionUsage(sessionId)?.totalCost, 400) + assert.deepEqual(store.getSessionMessageIds(sessionId), Array.from({ length: 200 }, (_, index) => `message-${index + 201}`)) + assert.equal(store.getMessageInfo("message-1"), undefined) + assert.notStrictEqual(store.getMessageInfo("message-201"), oldestInfo) + assert.equal(store.getSessionUsage(sessionId)?.totalCost, 200) assert.equal(hasMoreMessages(instanceId, sessionId), true) failRefresh = true await assert.rejects(loadMessages(instanceId, sessionId, { force: true }), /replacement refresh failed/) failRefresh = false await loadMoreMessages(instanceId, sessionId) - assert.deepEqual(store.getSessionMessageIds(sessionId), Array.from({ length: 250 }, (_, index) => `message-${index + 151}`)) + assert.deepEqual(store.getSessionMessageIds(sessionId), Array.from({ length: 50 }, (_, index) => `message-${index + 151}`)) assert.equal(store.getMessageInfo("message-1"), undefined) - assert.equal(store.getSessionUsage(sessionId)?.totalCost, 250) + assert.equal(store.getSessionUsage(sessionId)?.totalCost, 50) assert.equal(hasMoreMessages(instanceId, sessionId), false) } finally { cleanup() @@ -321,6 +323,63 @@ describe("session request authority", () => { } }) + it("replaces older and newer windows without mutating on failure", async () => { + const instanceId = "replace-windows", sessionId = "session" + const { client, cleanup } = setup(instanceId) + let failOlder = false + ;(client as any).message = { list: async (input: any) => { + if (!input.cursor) return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } } + if (failOlder) throw new Error("older failed") + return { data: [apiMessage("old-2"), apiMessage("old-1")], cursor: {} } + } } + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + try { + await loadMessages(instanceId, sessionId) + failOlder = true + await assert.rejects(loadMoreMessages(instanceId, sessionId), /older failed/) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) + failOlder = false + await loadMoreMessages(instanceId, sessionId) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["old-1", "old-2"]) + await loadNewerMessageWindow(instanceId, sessionId) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) + await loadLatestMessageWindow(instanceId, sessionId) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) + } finally { + cleanup() + } + }) + + it("seeks the oldest native page without reversing or mutating on failure", async () => { + const instanceId = "oldest-window", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const requests: any[] = [] + let failOldest = false + ;(client as any).message = { list: async (input: any) => { + requests.push(input) + if (input.order === "asc") { + if (failOldest) throw new Error("oldest failed") + return { data: [apiMessage("first"), apiMessage("second")], cursor: { next: "newer-from-start" } } + } + return { data: [apiMessage("new-2"), apiMessage("new-1")], cursor: { next: "page-2" } } + } } + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + try { + await loadMessages(instanceId, sessionId) + failOldest = true + await assert.rejects(loadOldestMessageWindow(instanceId, sessionId), /oldest failed/) + assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-1", "new-2"]) + failOldest = false + await loadOldestMessageWindow(instanceId, sessionId) + assert.deepEqual(requests.at(-1), { sessionID: sessionId, limit: 200, order: "asc" }) + const store = messageStoreBus.getOrCreate(instanceId) + assert.deepEqual(store.getSessionMessageIds(sessionId), ["first", "second"]) + assert.equal(hasMoreMessages(instanceId, sessionId), false) + } finally { + cleanup() + } + }) + it("loads only the selected session transcript", async () => { const instanceId = "selected-transcript", sessionId = "root" const { client, cleanup } = setup(instanceId) diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index 614172bf..ba41a8a5 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -87,6 +87,11 @@ import { searchSessions, forkSession, loadMessages, + loadOlderMessageWindow, + loadNewerMessageWindow, + loadLatestMessageWindow, + loadOldestMessageWindow, + isLatestMessageWindow, clearSessionListRequestState, clearSessionCatalogState, } from "./session-api" @@ -152,6 +157,11 @@ export { hasMoreMessages, getMessageNextCursor, loadMoreMessages, + loadOlderMessageWindow, + loadNewerMessageWindow, + loadLatestMessageWindow, + loadOldestMessageWindow, + isLatestMessageWindow, loadMoreSessions, searchSessions, forkSession,