From be268f7c040ab5d8099ff8dfdccd4b2e92147ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 23 Aug 2026 18:19:49 +0200 Subject: [PATCH] fix(ui): replace the resident 200-message window instead of growing it Home and Fin now fetch oldest or latest and swap the visible page. Older and newer pages do the same. Live events only land on the latest window so history stays still. A restored history cursor that returns no messages no longer marks the session loaded. Open retries latest, which was why transcripts went black after refresh. Stale Solid reads during that load are ignored. Tests cover window planning, replace-on-failure, and the empty-cursor retry. --- .github/workflows/pr-build.yml | 2 + .../message-history-pagination.test.ts | 18 +- .../components/message-history-pagination.ts | 6 +- .../ui/src/components/message-section.tsx | 161 ++++---- .../src/components/session/session-view.tsx | 8 +- .../src/components/virtual-follow-behavior.ts | 7 +- .../ui/src/components/virtual-follow-list.tsx | 44 ++- packages/ui/src/lib/follow-scroll.tsx | 2 +- .../app-session-workspace-hydration.test.ts | 9 +- .../stores/app-session-workspace-hydration.ts | 4 - .../ui/src/stores/client-state-codec.test.ts | 16 + packages/ui/src/stores/client-state-codec.ts | 18 +- .../stores/client-state-partitions.test.ts | 20 +- packages/ui/src/stores/instances.ts | 7 +- .../src/stores/message-v2/instance-store.ts | 14 + .../stores/message-v2/message-window.test.ts | 68 ++++ .../src/stores/message-v2/message-window.ts | 103 +++++ packages/ui/src/stores/message-v2/types.ts | 5 + packages/ui/src/stores/opencode-data.test.ts | 53 +++ packages/ui/src/stores/opencode-data.ts | 33 +- packages/ui/src/stores/session-api.ts | 372 ++++++++---------- .../stores/session-request-authority.test.ts | 75 +++- packages/ui/src/stores/sessions.ts | 10 + 23 files changed, 693 insertions(+), 362 deletions(-) create mode 100644 packages/ui/src/stores/message-v2/message-window.test.ts create mode 100644 packages/ui/src/stores/message-v2/message-window.ts 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,