mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-23 23:33:35 +00:00
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:
parent
021dee3422
commit
be268f7c04
23 changed files with 693 additions and 362 deletions
2
.github/workflows/pr-build.yml
vendored
2
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
68
packages/ui/src/stores/message-v2/message-window.test.ts
Normal file
68
packages/ui/src/stores/message-v2/message-window.test.ts
Normal 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"])
|
||||
})
|
||||
103
packages/ui/src/stores/message-v2/message-window.ts
Normal file
103
packages/ui/src/stores/message-v2/message-window.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue