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.
This commit is contained in:
Pascal André 2026-08-23 18:19:49 +02:00
parent 021dee3422
commit be268f7c04
No known key found for this signature in database
23 changed files with 693 additions and 362 deletions

View file

@ -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

View file

@ -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", () => {

View file

@ -57,13 +57,11 @@ export async function loadCompleteMessageHistory<T>(options: {
export async function loadMessageHistoryPage(options: {
getCursor: () => string | undefined
getMessageCount: () => number
loadMore: () => Promise<void>
}): Promise<boolean> {
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
}

View file

@ -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<void>
onLoadNewerMessages?: () => Promise<void>
onLoadLatestMessages?: () => Promise<void>
onLoadOldestMessages?: () => Promise<void>
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<void>((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<void>((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) {
<button
type="button"
class="message-scroll-button"
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToTop())}
onPointerUp={(event) => runScrollControlAction(event, () => {
void pageWindow("oldest", (next) => next.scrollToTop({ immediate: true }))
})}
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
@ -1013,7 +1014,9 @@ export default function MessageSection(props: MessageSectionProps) {
<button
type="button"
class="message-scroll-button"
onPointerUp={(event) => runScrollControlAction(event, () => api.scrollToBottom())}
onPointerUp={(event) => runScrollControlAction(event, () => {
void pageWindow("latest", (next) => next.scrollToBottom({ immediate: true }))
})}
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
>
<span class="message-scroll-icon" aria-hidden="true">
@ -1035,7 +1038,7 @@ export default function MessageSection(props: MessageSectionProps) {
setOlderMessageLoadFailed(false)
const retry = retryAnchorRestore
if (retry) retry()
else void maybeLoadOlderMessages()
else void pageWindow("older", (api) => api.scrollToBottom({ immediate: true }))
}}
>
{t("messageSection.loadError.reload")}
@ -1043,7 +1046,7 @@ export default function MessageSection(props: MessageSectionProps) {
</div>
</Show>
<Show when={!props.loading && !props.loadError && visibleMessageIds().length === 0}>
<Show when={!props.loading && !props.loadError && !props.hasMoreMessages && visibleMessageIds().length === 0}>
<Show
when={emptyStateVariant() === "no-session"}
fallback={
@ -1089,15 +1092,13 @@ export default function MessageSection(props: MessageSectionProps) {
</div>
</Show>
<Show when={!props.loading && props.loadError}>
{(loadError) => (
<LoadErrorState
title={t("messageSection.loadError.title")}
error={loadError()}
retryLabel={t("messageSection.loadError.reload")}
onRetry={() => props.onReloadMessages?.()}
/>
)}
<Show when={!props.loading && Boolean(props.loadError)}>
<LoadErrorState
title={t("messageSection.loadError.title")}
error={props.loadError!}
retryLabel={t("messageSection.loadError.reload")}
onRetry={() => props.onReloadMessages?.()}
/>
</Show>
</>
)}
@ -1221,9 +1222,8 @@ export default function MessageSection(props: MessageSectionProps) {
</div>
</Show>
<Show when={quoteSelection()}>
{(selection) => (
<div class="message-quote-popover" style={{ top: `${selection().top}px`, left: `${selection().left}px` }}>
<Show when={Boolean(quoteSelection())}>
<div class="message-quote-popover" style={{ top: `${quoteSelection()!.top}px`, left: `${quoteSelection()!.left}px` }}>
<div class="message-quote-button-group">
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
{t("messageSection.quote.addAsQuote")}
@ -1236,7 +1236,6 @@ export default function MessageSection(props: MessageSectionProps) {
</button>
</div>
</div>
)}
</Show>
</>
)}

View file

@ -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<SessionViewProps> = (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<SessionViewProps> = (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)}

View file

@ -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"
}

View file

@ -85,6 +85,11 @@ export interface VirtualFollowListProps<T> {
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<T>(props: VirtualFollowListProps<T>) {
}
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
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<T>(props: VirtualFollowListProps<T>) {
}}>
<div
class="message-stream"
tabIndex={-1}
tabIndex={0}
ref={el => {
setScrollElement(el)
props.onScrollElementChange?.(el)
@ -804,7 +832,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
onMouseUp={props.onMouseUp}
onClick={props.onClick}
>
<Show when={props.renderBeforeItems}>{props.renderBeforeItems!()}</Show>
{props.renderBeforeItems?.()}
<Virtualizer
ref={setVirtuaHandle}
scrollRef={scrollElement()}
@ -819,23 +847,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
</Virtualizer>
</div>
<Show when={props.renderOverlay}>
<Show when={Boolean(props.renderOverlay)}>
<div class="virtual-follow-list-overlay">{props.renderOverlay!()}</div>
</Show>
<Show when={props.renderControls}>
<Show when={Boolean(props.renderControls)}>
<div class="virtual-follow-list-controls-container">{props.renderControls!(state, api)}</div>
</Show>
<Show when={!props.renderControls && (showScrollTopButton() || showScrollBottomButton()) && props.scrollToTopAriaLabel && props.scrollToBottomAriaLabel}>
<div class="message-scroll-button-wrapper">
<Show when={showScrollTopButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label={props.scrollToTopAriaLabel!()}>
<button type="button" class="message-scroll-button" onClick={() => jumpToTop()} aria-label={props.scrollToTopAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>
<Show when={showScrollBottomButton()}>
<button type="button" class="message-scroll-button" onClick={() => scrollToBottom(true)} aria-label={props.scrollToBottomAriaLabel!()}>
<button type="button" class="message-scroll-button" onClick={() => jumpToBottom(true)} aria-label={props.scrollToBottomAriaLabel!()}>
<span class="message-scroll-icon" aria-hidden="true"></span>
</button>
</Show>

View file

@ -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" })
}

View file

@ -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<void>((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)

View file

@ -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

View file

@ -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__"],

View file

@ -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<string | null> = []
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
}

View file

@ -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 () => {

View file

@ -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<NonNul
: event.type === "form.created"
? event.data.form.sessionID
: undefined
if (sessionId && event.type.startsWith("session.")) projectOpenCodeMessages(instanceId, sessionId, data)
if (sessionId && event.type.startsWith("session.") && (
activeSessionId().get(instanceId) === sessionId
&& isLatestWindow(messageStoreBus.getOrCreate(instanceId).getMessageWindow(sessionId))
)) projectOpenCodeMessages(instanceId, sessionId, data)
if (sessionId && event.type === "session.inbox.cancelled") {
removeMessageV2(instanceId, event.data.inboxID, sessionId)
}

View file

@ -29,6 +29,7 @@ import type {
SessionUsageState,
UsageEntry,
} from "./types"
import type { MessageWindowState } from "./message-window"
const storeLog = getLogger("session")
@ -245,6 +246,8 @@ export interface InstanceMessageStore {
setScrollSnapshot: (sessionId: string, scope: string, snapshot: Omit<ScrollSnapshot, "updatedAt">) => 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,

View file

@ -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"])
})

View file

@ -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)
}

View file

@ -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 {

View file

@ -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"

View file

@ -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<string, { data: Data; emit: (event: OpenCodeEvent) => 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 {

View file

@ -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<string, { promise: Promise<boolean>; pending:
const sessionPageRequests = new Map<string, Promise<void>>()
const messageNextCursors = new Map<string, string>()
const messagePageRequests = new Map<string, Promise<void>>()
const messageRefreshChains = new Map<string, {
client: NonNullable<Instance["client"]>
loadEpoch: number
authoritativeIds: Set<string>
baselineRevisions: Map<string, number>
}>()
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<void> {
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<string>()
@ -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<void> {
const key = messagePageKey(instanceId, sessionId)
function enqueueMessageWindowLoad(
instanceId: string,
sessionId: string,
intent: Exclude<MessageWindowIntent, "open">,
signal?: AbortSignal,
): Promise<void> {
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<void>
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<void> {
return enqueueMessageWindowLoad(instanceId, sessionId, "older", signal)
}
function loadOlderMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise<void> {
return enqueueMessageWindowLoad(instanceId, sessionId, "older", signal)
}
function loadNewerMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise<void> {
return enqueueMessageWindowLoad(instanceId, sessionId, "newer", signal)
}
function loadLatestMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise<void> {
return enqueueMessageWindowLoad(instanceId, sessionId, "latest", signal)
}
function loadOldestMessageWindow(instanceId: string, sessionId: string, signal?: AbortSignal): Promise<void> {
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<void> {
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<typeof info> => 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,
}

View file

@ -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<void>((resolve) => setImmediate(resolve))
failSecondPage = false
pendingSecondPage = deferred<any>()
@ -205,10 +206,10 @@ describe("session request authority", () => {
await new Promise<void>((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)

View file

@ -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,