perf: isolate chat streaming renders and reduce sidebar render cost (#1672)
Reworks the chat and session-sidebar render paths to cut render cascades, memory
churn, and UI jank on large sessions and big session trees. Behavior is preserved;
the changes are about *when* and *how much* the UI re-renders.
## Chat streaming
- Freeze the streaming message's parts in the bulk turn projection during streaming,
and re-inject live parts only in an isolated tail leaf, so a ~60/sec delta stream
no longer re-runs the whole-session projection or re-renders unrelated rows.
session with referential reuse of unchanged turns.
- Memoize message rows with field-aware comparators instead of reference equality.
- Replace the manual child-session polling in the task tool with the live SSE
stream + a one-shot load, removing a fetch/settle state machine.
## History loading & scroll
- Load an initial page fast, then prepend one older page in the background so the
scroll container has headroom and "load older on scroll-up" fires before the user
hits the absolute top.
- Compensate scroll synchronously (in a layout effect, before paint) for prepends —
including background prepends that don't originate from a user scroll — so the
viewport stays stable instead of judder-correcting on the next frame.
## Markdown rendering
- Render markdown synchronously *styled* on first paint (paragraphs, lists, code
cards, tables, inline code) instead of raw escaped text; the async pass then only
upgrades syntax-highlight colors. Eliminates the flash of full-width raw text.
- Load KaTeX CSS eagerly with the main bundle instead of inside the lazy markdown
chunk, avoiding a late stylesheet injection on first render.
## Sidebar
- Hoist per-row recursive tree walks out of row comparators into per-group
precomputed sets/keys; batch live-session lookups into a single map; add a
group-level memo boundary.
- Isolate rename drafts so per-keystroke typing doesn't repaint the row tree.
## Sync layer
- Add a staleness guard so a slow message fetch can't repopulate a session the user
navigated away from.
- Throw on fetch failure for authoritative loaders so a transient blip can't read as
an empty server response.
## Cleanup
- Remove dead code (unused hooks, params, duplicated inline types) surfaced while
reworking the above.
## Known issue
- A rare, purely cosmetic first-paint width flash can still appear on large sessions;
it has no behavioral or data impact and is tracked for a follow-up runtime trace.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
import {
|
||||
areRequestArraysReferentiallyEqual,
|
||||
collectScopedBlockingRequests,
|
||||
} from "./scoped-blocking-requests"
|
||||
|
||||
const session = (id: string, parentID?: string): Session => ({ id, parentID }) as Session
|
||||
|
||||
describe("scoped blocking requests", () => {
|
||||
test("collects requests for the current session subtree", () => {
|
||||
const rootRequest = { id: "perm_root" }
|
||||
const childRequest = { id: "perm_child" }
|
||||
const grandchildRequest = { id: "perm_grandchild" }
|
||||
const siblingRequest = { id: "perm_sibling" }
|
||||
const empty: Array<typeof rootRequest> = []
|
||||
|
||||
const result = collectScopedBlockingRequests(
|
||||
[
|
||||
session("ses_root"),
|
||||
session("ses_child", "ses_root"),
|
||||
session("ses_grandchild", "ses_child"),
|
||||
session("ses_sibling"),
|
||||
],
|
||||
{
|
||||
ses_root: [rootRequest],
|
||||
ses_child: [childRequest],
|
||||
ses_grandchild: [grandchildRequest],
|
||||
ses_sibling: [siblingRequest],
|
||||
},
|
||||
"ses_root",
|
||||
empty,
|
||||
)
|
||||
|
||||
expect(result).toEqual([rootRequest, childRequest, grandchildRequest])
|
||||
})
|
||||
|
||||
test("returns the provided empty array when no scoped requests exist", () => {
|
||||
const empty: Array<{ id: string }> = []
|
||||
|
||||
expect(collectScopedBlockingRequests([session("ses_root")], {}, "ses_root", empty)).toBe(empty)
|
||||
expect(collectScopedBlockingRequests([session("ses_root")], {}, null, empty)).toBe(empty)
|
||||
})
|
||||
|
||||
test("compares request arrays by item identity", () => {
|
||||
const first = { id: "perm_1" }
|
||||
const second = { id: "perm_2" }
|
||||
|
||||
expect(areRequestArraysReferentiallyEqual([first, second], [first, second])).toBe(true)
|
||||
expect(areRequestArraysReferentiallyEqual([first, second], [second, first])).toBe(false)
|
||||
expect(areRequestArraysReferentiallyEqual([first], [{ id: "perm_1" }])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type BlockingRequest = { id: string }
|
||||
|
||||
export const computeSubtreeIds = (sessions: Session[], rootId: string): Set<string> => {
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) continue
|
||||
const list = childrenByParent.get(session.parentID) ?? []
|
||||
list.push(session.id)
|
||||
childrenByParent.set(session.parentID, list)
|
||||
}
|
||||
|
||||
const ids = new Set<string>([rootId])
|
||||
const queue = [rootId]
|
||||
for (const id of queue) {
|
||||
const children = childrenByParent.get(id)
|
||||
if (!children) continue
|
||||
for (const childId of children) {
|
||||
if (ids.has(childId)) continue
|
||||
ids.add(childId)
|
||||
queue.push(childId)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
export const areRequestArraysReferentiallyEqual = <T extends BlockingRequest>(left: T[], right: T[]): boolean => {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index] !== right[index]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const collectScopedBlockingRequests = <T extends BlockingRequest>(
|
||||
sessions: Session[],
|
||||
requestsBySession: Record<string, T[] | undefined>,
|
||||
sessionID: string | null,
|
||||
empty: T[],
|
||||
): T[] => {
|
||||
if (!sessionID) return empty
|
||||
|
||||
const scopedIds = computeSubtreeIds(sessions, sessionID)
|
||||
if (scopedIds.size === 0) return empty
|
||||
|
||||
const seen = new Set<string>()
|
||||
const result: T[] = []
|
||||
for (const id of scopedIds) {
|
||||
const entries = requestsBySession[id]
|
||||
if (!entries) continue
|
||||
for (const entry of entries) {
|
||||
if (seen.has(entry.id)) continue
|
||||
seen.add(entry.id)
|
||||
result.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return result.length === 0 ? empty : result
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/
|
||||
import { useConfigStore } from "@/stores/useConfigStore"
|
||||
import { registerSessionDirectory } from "./sync-refs"
|
||||
import { isSyntheticPart } from "@/lib/messages/synthetic"
|
||||
import { materializeSessionSnapshots } from "./materialization"
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
import { retry } from "./retry"
|
||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { sessionEvents } from "@/lib/sessionEvents"
|
||||
import {
|
||||
@@ -24,7 +27,7 @@ import {
|
||||
type SessionMetadataRecord,
|
||||
} from "@/lib/sessionReviewMetadata"
|
||||
|
||||
const MESSAGE_REFETCH_LIMIT = 200
|
||||
const MESSAGE_REFETCH_LIMIT = 100
|
||||
const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const UNREVERT_REFETCH_ATTEMPTS = 3
|
||||
const UNREVERT_REFETCH_RETRY_MS = 150
|
||||
@@ -1037,3 +1040,71 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
|
||||
// Clear existing attachments and restore file parts from the forked message.
|
||||
restoreFilePartsToInput(fileParts)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imperative fetch path — starts message loading on the same tick as
|
||||
// setCurrentSession, before the React commit cycle fires useEffect.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FETCH_MESSAGES_LOADING = new Set<string>()
|
||||
const DESKTOP_INITIAL_PAGE_SIZE = 50
|
||||
const CONSTRAINED_INITIAL_PAGE_SIZE = 30
|
||||
|
||||
const getFetchPageSize = () => {
|
||||
if (isVSCodeRuntime() || isMobileSurfaceRuntime()) return CONSTRAINED_INITIAL_PAGE_SIZE
|
||||
return DESKTOP_INITIAL_PAGE_SIZE
|
||||
}
|
||||
|
||||
export async function fetchMessagesForSession(sessionID: string, directory?: string | null): Promise<void> {
|
||||
const resolvedDir = directory ?? dir()
|
||||
if (!resolvedDir) return
|
||||
|
||||
const s = sdk()
|
||||
const store = directory
|
||||
? dirStoreForDirectory(directory)
|
||||
: dirStore()
|
||||
|
||||
if (getSessionMaterializationStatus(store.getState(), sessionID).renderable) return
|
||||
|
||||
const loadingKey = `${resolvedDir}:${sessionID}`
|
||||
if (FETCH_MESSAGES_LOADING.has(loadingKey)) return
|
||||
|
||||
FETCH_MESSAGES_LOADING.add(loadingKey)
|
||||
|
||||
try {
|
||||
const result = await retry(async () => {
|
||||
const response = await s.session.messages({
|
||||
sessionID,
|
||||
directory: resolvedDir,
|
||||
limit: getFetchPageSize(),
|
||||
})
|
||||
return response
|
||||
})
|
||||
|
||||
const records = (assertSdkSuccess(result, "session.messages") ?? [])
|
||||
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
|
||||
if (records.length === 0) return
|
||||
|
||||
// Staleness guard: a rapid session switch may have moved the user off this
|
||||
// session while the fetch was in flight. Skip the write so a slow fetch
|
||||
// can't repopulate (and un-evict) a session already navigated away from.
|
||||
if (useSessionUIStore.getState().currentSessionId !== sessionID) return
|
||||
|
||||
store.setState((state) => {
|
||||
const materialized = materializeSessionSnapshots(
|
||||
state,
|
||||
sessionID,
|
||||
records.map((record: { info: Message; parts?: Part[] }) => ({
|
||||
info: stripMessageDiffSnapshots(record.info),
|
||||
parts: record.parts ?? [],
|
||||
})),
|
||||
{ skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS },
|
||||
)
|
||||
return { message: materialized.message, part: materialized.part }
|
||||
})
|
||||
} catch {
|
||||
// Transient failure — the reactive path in ChatContainer will retry
|
||||
} finally {
|
||||
FETCH_MESSAGES_LOADING.delete(loadingKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
|
||||
import { buildSessionMessageRecordsSnapshot } from './sync-context';
|
||||
import { INITIAL_STATE, type State } from './types';
|
||||
|
||||
const message = (id: string, role: 'user' | 'assistant', parentID?: string): Message => ({
|
||||
id,
|
||||
role,
|
||||
sessionID: 'ses_1',
|
||||
...(parentID ? { parentID } : {}),
|
||||
time: { created: 1 },
|
||||
} as Message);
|
||||
|
||||
const textPart = (id: string, text: string): Part => ({
|
||||
id,
|
||||
type: 'text',
|
||||
text,
|
||||
} as Part);
|
||||
|
||||
const state = (partial: Partial<State>): State => ({
|
||||
...INITIAL_STATE,
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('buildSessionMessageRecordsSnapshot', () => {
|
||||
test('only suspends part updates for the active streaming message', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const assistant1 = message('assistant_1', 'assistant', 'user_1');
|
||||
const assistant2 = message('assistant_2', 'assistant', 'user_1');
|
||||
const messages = [user, assistant1, assistant2];
|
||||
const assistant1InitialParts = [textPart('assistant_1_initial', 'initial')];
|
||||
const assistant2InitialParts = [textPart('assistant_2_initial', 'initial')];
|
||||
|
||||
const previous = buildSessionMessageRecordsSnapshot(
|
||||
state({
|
||||
message: { ses_1: messages },
|
||||
part: {
|
||||
assistant_1: assistant1InitialParts,
|
||||
assistant_2: assistant2InitialParts,
|
||||
},
|
||||
}),
|
||||
'ses_1',
|
||||
undefined,
|
||||
true,
|
||||
'assistant_1',
|
||||
);
|
||||
|
||||
const assistant1FinalParts = [textPart('assistant_1_final', 'final')];
|
||||
const assistant2LiveParts = [textPart('assistant_2_live', 'live')];
|
||||
const next = buildSessionMessageRecordsSnapshot(
|
||||
state({
|
||||
message: { ses_1: messages },
|
||||
part: {
|
||||
assistant_1: assistant1FinalParts,
|
||||
assistant_2: assistant2LiveParts,
|
||||
},
|
||||
}),
|
||||
'ses_1',
|
||||
previous,
|
||||
true,
|
||||
'assistant_2',
|
||||
);
|
||||
|
||||
expect(next.byId.get('assistant_1')?.parts).toBe(assistant1FinalParts);
|
||||
expect(next.byId.get('assistant_2')?.parts).toBe(assistant2InitialParts);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
revertToMessage as revertToMessageAction,
|
||||
unrevertSession as unrevertSessionAction,
|
||||
forkFromMessage as forkFromMessageAction,
|
||||
fetchMessagesForSession,
|
||||
} from "./session-actions"
|
||||
import { useInputStore, type SyntheticContextPart } from "./input-store"
|
||||
import { useSelectionStore } from "./selection-store"
|
||||
@@ -497,6 +498,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
|
||||
writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null })
|
||||
|
||||
// Kick off the message fetch on the same tick, before React commits the
|
||||
// state change and fires ChatContainer.useEffect. The fetch is
|
||||
// fire-and-forget — any transient failure gets retried by the reactive path.
|
||||
if (id) {
|
||||
void fetchMessagesForSession(id, resolvedDir)
|
||||
}
|
||||
|
||||
try {
|
||||
if (resolvedDir && directoryState.currentDirectory !== resolvedDir) {
|
||||
directoryState.setDirectory(resolvedDir, { showOverlay: false })
|
||||
|
||||
@@ -23,7 +23,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
|
||||
import { retry } from "./retry"
|
||||
import { updateStreamingState } from "./streaming"
|
||||
import { setActionRefs } from "./session-actions"
|
||||
import { setSyncRefs } from "./sync-refs"
|
||||
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
||||
import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize"
|
||||
import { syncDebug } from "./debug"
|
||||
import { getReconnectCandidateSessionIds } from "./reconnect-recovery"
|
||||
@@ -47,6 +47,8 @@ import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
|
||||
import { setSessionPrefetch } from "./session-prefetch-cache"
|
||||
import { listGlobalSessionPages } from "@/stores/globalSessions"
|
||||
import { useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
|
||||
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
@@ -260,6 +262,7 @@ async function materializeSessionFromServer(
|
||||
directory: string,
|
||||
sessionID: string,
|
||||
store: StoreApi<DirectoryStore>,
|
||||
options?: { isStale?: () => boolean },
|
||||
) {
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||
const result = await retry(async () => {
|
||||
@@ -278,6 +281,8 @@ async function materializeSessionFromServer(
|
||||
complete: !cursor,
|
||||
})
|
||||
|
||||
if (options?.isStale?.()) return
|
||||
|
||||
store.setState((state: DirectoryStore) => {
|
||||
const materialized = materializeSessionSnapshots(
|
||||
state,
|
||||
@@ -326,12 +331,12 @@ type UiNotificationPayload = {
|
||||
body?: unknown
|
||||
tag?: unknown
|
||||
kind?: unknown
|
||||
sessionId?: unknown
|
||||
directory?: unknown
|
||||
requireHidden?: unknown
|
||||
desktopNotificationDelivered?: unknown
|
||||
desktopStdoutActive?: unknown
|
||||
}
|
||||
sessionId?: unknown
|
||||
directory?: unknown
|
||||
requireHidden?: unknown
|
||||
desktopNotificationDelivered?: unknown
|
||||
desktopStdoutActive?: unknown
|
||||
}
|
||||
|
||||
const asOptionalString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") return undefined
|
||||
@@ -350,9 +355,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
|
||||
}
|
||||
|
||||
const notification = properties as UiNotificationPayload
|
||||
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
||||
return true
|
||||
}
|
||||
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
|
||||
return true
|
||||
}
|
||||
|
||||
const notifications = getRegisteredRuntimeAPIs()?.notifications
|
||||
if (!notifications?.notifyAgentCompletion) {
|
||||
@@ -1657,10 +1662,10 @@ export function SyncProvider(props: {
|
||||
ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState())
|
||||
}
|
||||
},
|
||||
global: {
|
||||
config: globalState.config,
|
||||
projects: globalState.projects,
|
||||
},
|
||||
global: {
|
||||
config: globalState.config,
|
||||
projects: globalState.projects,
|
||||
},
|
||||
loadSessions: (dir) => retry(async () => {
|
||||
const rootSessions = (await listGlobalSessionPages(props.sdk, {
|
||||
directory: dir,
|
||||
@@ -1751,11 +1756,11 @@ export function SyncProvider(props: {
|
||||
const generation = ++globalBootstrapGeneration
|
||||
bootingRoot = true
|
||||
const globalActions = useGlobalSyncStore.getState().actions
|
||||
bootstrapGlobal(props.sdk, (patch) => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
globalActions.set(patch)
|
||||
}
|
||||
})
|
||||
bootstrapGlobal(props.sdk, (patch) => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
globalActions.set(patch)
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (globalBootstrapGeneration === generation) {
|
||||
bootedAt = Date.now()
|
||||
@@ -1771,7 +1776,7 @@ export function SyncProvider(props: {
|
||||
bootingRoot = false
|
||||
}
|
||||
}
|
||||
}, [props.sdk])
|
||||
}, [props.sdk])
|
||||
|
||||
// Event pipeline — created once per mount. No class, no start/stop.
|
||||
// Abort controller owned by the pipeline closure. Cleanup aborts + flushes.
|
||||
@@ -2177,6 +2182,77 @@ export function useSessions(directory?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
const selectPermissionRequestsBySession = (state: State) => state.permission
|
||||
const selectQuestionRequestsBySession = (state: State) => state.question
|
||||
|
||||
type ScopedBlockingRequestCache<T extends { id: string }> = {
|
||||
sessionID: string | null
|
||||
sessions: Session[] | null
|
||||
requestsBySession: Record<string, T[] | undefined> | null
|
||||
result: T[]
|
||||
}
|
||||
|
||||
function useScopedBlockingRequests<T extends { id: string }>(
|
||||
sessionID: string | null,
|
||||
directory: string | undefined,
|
||||
selectRequestsBySession: (state: State) => Record<string, T[] | undefined>,
|
||||
empty: T[],
|
||||
): T[] {
|
||||
const cacheRef = useRef<ScopedBlockingRequestCache<T>>({
|
||||
sessionID: null,
|
||||
sessions: null,
|
||||
requestsBySession: null,
|
||||
result: empty,
|
||||
})
|
||||
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => {
|
||||
const requestsBySession = selectRequestsBySession(state)
|
||||
const cache = cacheRef.current
|
||||
if (
|
||||
cache.sessionID === sessionID
|
||||
&& cache.sessions === state.session
|
||||
&& cache.requestsBySession === requestsBySession
|
||||
) {
|
||||
return cache.result
|
||||
}
|
||||
|
||||
const next = collectScopedBlockingRequests(state.session, requestsBySession, sessionID, empty)
|
||||
const result = areRequestArraysReferentiallyEqual(cache.result, next) ? cache.result : next
|
||||
cacheRef.current = {
|
||||
sessionID,
|
||||
sessions: state.session,
|
||||
requestsBySession,
|
||||
result,
|
||||
}
|
||||
return result
|
||||
}, [empty, selectRequestsBySession, sessionID]),
|
||||
directory,
|
||||
)
|
||||
}
|
||||
|
||||
export function useScopedBlockingPermissions(sessionID: string | null, directory?: string): PermissionRequest[] {
|
||||
return useScopedBlockingRequests(sessionID, directory, selectPermissionRequestsBySession, EMPTY_PERMISSION_REQUESTS)
|
||||
}
|
||||
|
||||
export function useScopedBlockingQuestions(sessionID: string | null, directory?: string): QuestionRequest[] {
|
||||
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
|
||||
}
|
||||
|
||||
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
|
||||
return useDirectorySync(
|
||||
useCallback((state: State) => {
|
||||
if (!sessionID) return null
|
||||
const current = state.session.find((s) => s.id === sessionID)
|
||||
if (!current?.parentID) return null
|
||||
return state.session.find((s) => s.id === current.parentID)
|
||||
?? getAllSyncSessions().find((s) => s.id === current.parentID)
|
||||
?? null
|
||||
}, [sessionID]),
|
||||
directory,
|
||||
)
|
||||
}
|
||||
|
||||
const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => {
|
||||
const directory = (session as Session & { directory?: string | null }).directory ?? ''
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID ?? ''
|
||||
@@ -2348,14 +2424,6 @@ const getConcatenatedTextFromParts = (parts: Part[]): string => {
|
||||
return text
|
||||
}
|
||||
|
||||
const getFirstTextFromParts = (parts: Part[]): string => {
|
||||
for (const part of parts) {
|
||||
const text = getPartText(part)
|
||||
if (text.length > 0) return text
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SessionMessageRecord = { info: Message; parts: Part[] }
|
||||
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
|
||||
|
||||
@@ -2365,6 +2433,7 @@ type SessionMessageRecordsSnapshot = {
|
||||
visibleMessages: Message[]
|
||||
revertMessageID?: string
|
||||
suspendPartUpdates: boolean
|
||||
suspendedPartUpdatesMessageID?: string
|
||||
list: SessionMessageRecord[]
|
||||
byId: Map<string, SessionMessageRecord>
|
||||
}
|
||||
@@ -2376,8 +2445,12 @@ const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
|
||||
const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
|
||||
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
|
||||
|
||||
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
|
||||
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}`
|
||||
const getSessionMessageRecordsCacheKey = (
|
||||
sessionID: string,
|
||||
suspendPartUpdates: boolean,
|
||||
suspendedPartUpdatesMessageID?: string,
|
||||
): string => (
|
||||
`${sessionID}\u0000${suspendPartUpdates ? 1 : 0}\u0000${suspendedPartUpdatesMessageID ?? ""}`
|
||||
)
|
||||
|
||||
const getSessionMessageRecordsCache = (store: StoreApi<DirectoryStore>): Map<string, SessionMessageRecordsSnapshot> => {
|
||||
@@ -2393,10 +2466,11 @@ const readCachedSessionMessageRecordsSnapshot = (
|
||||
store: StoreApi<DirectoryStore>,
|
||||
sessionID: string,
|
||||
suspendPartUpdates: boolean,
|
||||
suspendedPartUpdatesMessageID?: string,
|
||||
): SessionMessageRecordsSnapshot | undefined => {
|
||||
const cache = sessionMessageRecordsCache.get(store)
|
||||
if (!cache) return undefined
|
||||
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates)
|
||||
const key = getSessionMessageRecordsCacheKey(sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
|
||||
const cached = cache.get(key)
|
||||
if (!cached) return undefined
|
||||
cache.delete(key)
|
||||
@@ -2410,7 +2484,11 @@ const rememberSessionMessageRecordsSnapshot = (
|
||||
): void => {
|
||||
if (!snapshot.sessionID) return
|
||||
const cache = getSessionMessageRecordsCache(store)
|
||||
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
|
||||
const key = getSessionMessageRecordsCacheKey(
|
||||
snapshot.sessionID,
|
||||
snapshot.suspendPartUpdates,
|
||||
snapshot.suspendedPartUpdatesMessageID,
|
||||
)
|
||||
const constrainedMaxMessages = isVSCodeRuntime()
|
||||
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
|
||||
: isMobileSurfaceRuntime()
|
||||
@@ -2442,17 +2520,23 @@ export function dropCachedSessionMessageRecordsSnapshots(
|
||||
if (!cache) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
if (!sessionID) continue
|
||||
cache.delete(getSessionMessageRecordsCacheKey(sessionID, false))
|
||||
cache.delete(getSessionMessageRecordsCacheKey(sessionID, true))
|
||||
const prefix = `${sessionID}\u0000`
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (key.startsWith(prefix)) {
|
||||
cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotPartsMatchState = (snapshot: SessionMessageRecordsSnapshot, state: State): boolean => {
|
||||
if (snapshot.suspendPartUpdates) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const record of snapshot.list) {
|
||||
if (snapshot.suspendPartUpdates) {
|
||||
const suspendedID = snapshot.suspendedPartUpdatesMessageID
|
||||
if (!suspendedID || record.info.id === suspendedID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ((state.part[record.info.id] ?? EMPTY_PARTS) !== record.parts) {
|
||||
return false
|
||||
}
|
||||
@@ -2466,8 +2550,9 @@ const getReusableSessionMessageRecordsSnapshot = (
|
||||
state: State,
|
||||
sessionID: string,
|
||||
suspendPartUpdates: boolean,
|
||||
suspendedPartUpdatesMessageID?: string,
|
||||
): SessionMessageRecordsSnapshot | undefined => {
|
||||
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
|
||||
const cached = readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
|
||||
if (!cached) return undefined
|
||||
const sourceMessages = state.message[sessionID] ?? EMPTY_MESSAGES
|
||||
const session = state.session.find((candidate) => candidate.id === sessionID)
|
||||
@@ -2476,6 +2561,7 @@ const getReusableSessionMessageRecordsSnapshot = (
|
||||
cached.sourceMessages === sourceMessages
|
||||
&& cached.revertMessageID === revertMessageID
|
||||
&& cached.suspendPartUpdates === suspendPartUpdates
|
||||
&& cached.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
|
||||
&& snapshotPartsMatchState(cached, state)
|
||||
) {
|
||||
return cached
|
||||
@@ -2516,12 +2602,16 @@ export function buildSessionMessageRecordsSnapshot(
|
||||
sessionID: string,
|
||||
previous?: SessionMessageRecordsSnapshot,
|
||||
suspendPartUpdates = false,
|
||||
suspendedPartUpdatesMessageID?: string,
|
||||
): SessionMessageRecordsSnapshot {
|
||||
const { sourceMessages, visibleMessages, revertMessageID } = getVisibleMessagesForSession(state, sessionID, previous)
|
||||
const nextById = new Map<string, SessionMessageRecord>()
|
||||
const nextList = visibleMessages.map((message) => {
|
||||
const previousRecord = previous?.byId.get(message.id)
|
||||
const parts = suspendPartUpdates && previousRecord
|
||||
const shouldSuspendParts = suspendPartUpdates
|
||||
&& previousRecord
|
||||
&& (!suspendedPartUpdatesMessageID || message.id === suspendedPartUpdatesMessageID)
|
||||
const parts = shouldSuspendParts
|
||||
? previousRecord.parts
|
||||
: (state.part[message.id] ?? EMPTY_PARTS)
|
||||
|
||||
@@ -2535,6 +2625,8 @@ export function buildSessionMessageRecordsSnapshot(
|
||||
|
||||
const unchanged = Boolean(previous)
|
||||
&& previous?.visibleMessages === visibleMessages
|
||||
&& previous.suspendPartUpdates === suspendPartUpdates
|
||||
&& previous.suspendedPartUpdatesMessageID === suspendedPartUpdatesMessageID
|
||||
&& previous.list.length === nextList.length
|
||||
&& previous.list.every((record, index) => record === nextList[index])
|
||||
|
||||
@@ -2548,6 +2640,7 @@ export function buildSessionMessageRecordsSnapshot(
|
||||
visibleMessages,
|
||||
revertMessageID,
|
||||
suspendPartUpdates,
|
||||
suspendedPartUpdatesMessageID,
|
||||
list: nextList,
|
||||
byId: nextById,
|
||||
}
|
||||
@@ -2577,20 +2670,21 @@ export function useSessionTextMessages(sessionID: string, directory?: string): S
|
||||
}
|
||||
|
||||
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
|
||||
const records = useSessionMessageRecords(sessionID, directory)
|
||||
const userMessages = useMemo(() => records.filter((record) => record.info.role === 'user'), [records])
|
||||
const store = useDirectoryStore(directory)
|
||||
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
|
||||
|
||||
return useMemo(() => {
|
||||
const history: string[] = []
|
||||
for (let index = userMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = userMessages[index]
|
||||
const text = getFirstTextFromParts(message.parts)
|
||||
if (text.length > 0) {
|
||||
history.push(text)
|
||||
}
|
||||
}
|
||||
return history
|
||||
}, [userMessages])
|
||||
const getSnapshot = useCallback(() => {
|
||||
const next = buildUserMessageHistorySnapshot(store.getState(), sessionID, snapshotRef.current)
|
||||
snapshotRef.current = next
|
||||
return next.history
|
||||
}, [sessionID, store])
|
||||
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
return store.subscribe(notify)
|
||||
}, [sessionID, store])
|
||||
|
||||
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2603,7 +2697,7 @@ export function useUserMessageHistory(sessionID: string, directory?: string): st
|
||||
export function useSessionMessageRecords(
|
||||
sessionID: string,
|
||||
directory?: string,
|
||||
options?: { suspendPartUpdates?: boolean },
|
||||
options?: { suspendPartUpdates?: boolean; suspendPartUpdatesForMessageId?: string | null },
|
||||
) {
|
||||
const store = useDirectoryStore(directory)
|
||||
const snapshotRef = useRef<SessionMessageRecordsSnapshot>({
|
||||
@@ -2612,6 +2706,7 @@ export function useSessionMessageRecords(
|
||||
visibleMessages: EMPTY_MESSAGES,
|
||||
revertMessageID: undefined,
|
||||
suspendPartUpdates: Boolean(options?.suspendPartUpdates),
|
||||
suspendedPartUpdatesMessageID: options?.suspendPartUpdatesForMessageId ?? undefined,
|
||||
list: [],
|
||||
byId: new Map(),
|
||||
})
|
||||
@@ -2623,7 +2718,14 @@ export function useSessionMessageRecords(
|
||||
|
||||
const state = store.getState()
|
||||
const suspendPartUpdates = Boolean(options?.suspendPartUpdates)
|
||||
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(store, state, sessionID, suspendPartUpdates)
|
||||
const suspendedPartUpdatesMessageID = options?.suspendPartUpdatesForMessageId ?? undefined
|
||||
const reusableSnapshot = getReusableSessionMessageRecordsSnapshot(
|
||||
store,
|
||||
state,
|
||||
sessionID,
|
||||
suspendPartUpdates,
|
||||
suspendedPartUpdatesMessageID,
|
||||
)
|
||||
if (reusableSnapshot) {
|
||||
snapshotRef.current = reusableSnapshot
|
||||
return reusableSnapshot.list
|
||||
@@ -2631,18 +2733,19 @@ export function useSessionMessageRecords(
|
||||
|
||||
const previousSnapshot = snapshotRef.current.sessionID === sessionID
|
||||
? snapshotRef.current
|
||||
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates)
|
||||
: readCachedSessionMessageRecordsSnapshot(store, sessionID, suspendPartUpdates, suspendedPartUpdatesMessageID)
|
||||
|
||||
const nextSnapshot = buildSessionMessageRecordsSnapshot(
|
||||
state,
|
||||
sessionID,
|
||||
previousSnapshot,
|
||||
suspendPartUpdates,
|
||||
suspendedPartUpdatesMessageID,
|
||||
)
|
||||
snapshotRef.current = nextSnapshot
|
||||
rememberSessionMessageRecordsSnapshot(store, nextSnapshot)
|
||||
return nextSnapshot.list
|
||||
}, [options?.suspendPartUpdates, sessionID, store])
|
||||
}, [options?.suspendPartUpdates, options?.suspendPartUpdatesForMessageId, sessionID, store])
|
||||
|
||||
const subscribe = useCallback((notify: () => void) => {
|
||||
if (!sessionID) return () => undefined
|
||||
@@ -2671,6 +2774,7 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
|
||||
const syncDirectory = useSyncDirectory()
|
||||
const resolvedDirectory = directory ?? syncDirectory
|
||||
const store = useDirectoryStore(resolvedDirectory)
|
||||
const requestGenerationRef = React.useRef(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionID) return
|
||||
@@ -2685,11 +2789,14 @@ export function useEnsureSessionMessages(sessionID: string, directory?: string)
|
||||
// Already loading this session for this directory
|
||||
if (_ensureMessagesLoading.has(loadingKey)) return
|
||||
|
||||
const generation = ++requestGenerationRef.current
|
||||
const isStale = () => generation !== requestGenerationRef.current
|
||||
|
||||
_ensureMessagesLoading.add(loadingKey)
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await materializeSessionFromServer(resolvedDirectory, sessionID, store)
|
||||
await materializeSessionFromServer(resolvedDirectory, sessionID, store, { isStale })
|
||||
} catch {
|
||||
// Transient failure — next navigation or reconnect will retry
|
||||
} finally {
|
||||
|
||||
@@ -22,10 +22,11 @@ import {
|
||||
import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./materialization"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const INITIAL_MESSAGE_PAGE_SIZE = 150
|
||||
const INITIAL_MESSAGE_PAGE_SIZE = 50
|
||||
const VSCODE_INITIAL_MESSAGE_PAGE_SIZE = 30
|
||||
const MOBILE_INITIAL_MESSAGE_PAGE_SIZE = 30
|
||||
const HISTORY_MESSAGE_PAGE_SIZE = 200
|
||||
const HISTORY_MESSAGE_PAGE_SIZE = 100
|
||||
const INITIAL_PAGE_EXPANSION_LIMITS = [100, 150] as const
|
||||
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||
const MAX_SEEN_DIRS = 30
|
||||
const VSCODE_SESSION_CACHE_LIMIT = 4
|
||||
@@ -40,6 +41,12 @@ const seenByDirectory = new Map<string, Set<string>>()
|
||||
// all request the same session during startup; coalesce them into one HTTP load.
|
||||
const syncSessionInflightByKey = new Map<string, Promise<void>>()
|
||||
|
||||
// Per-session generation counter. When a newer syncSession request starts for
|
||||
// the same session, older in-flight requests become stale and must not write
|
||||
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
|
||||
// sidebar) from having each completed fetch fight for focus.
|
||||
const syncSessionGenerationByKey = new Map<string, number>()
|
||||
|
||||
type SyncMeta = {
|
||||
limit: number
|
||||
cursor: string | undefined
|
||||
@@ -88,6 +95,9 @@ const getInitialMessagePageSize = () => {
|
||||
if (isMobileSurfaceRuntime()) return MOBILE_INITIAL_MESSAGE_PAGE_SIZE
|
||||
return INITIAL_MESSAGE_PAGE_SIZE
|
||||
}
|
||||
const getInitialPageExpansionLimits = () => isConstrainedSessionRuntime()
|
||||
? VSCODE_INITIAL_PAGE_EXPANSION_LIMITS
|
||||
: INITIAL_PAGE_EXPANSION_LIMITS
|
||||
const getDefaultMeta = (): SyncMeta => ({ limit: getInitialMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
||||
|
||||
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
||||
@@ -333,7 +343,7 @@ export function useSync() {
|
||||
|
||||
// Load messages for a session
|
||||
const loadMessages = useCallback(
|
||||
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend" }) => {
|
||||
async (sessionID: string, options?: { before?: string; mode?: "replace" | "prepend"; isStale?: () => boolean }) => {
|
||||
const m = getMetaFor(sessionID)
|
||||
if (m.loading) return
|
||||
setMetaFor(sessionID, { loading: true })
|
||||
@@ -342,13 +352,13 @@ export function useSync() {
|
||||
const limit = options?.before ? HISTORY_MESSAGE_PAGE_SIZE : m.limit
|
||||
let page = await fetchMessages(sessionID, limit, options?.before)
|
||||
|
||||
// Constrained shells keep the initial page small for switch performance. Some
|
||||
// sessions have a very large final turn, so the latest 30 records can
|
||||
// Keep the initial page small for switch performance. Some sessions
|
||||
// have a very large final turn, so the latest records can
|
||||
// contain only assistant/tool records and no user boundary. That makes
|
||||
// turn projection render an empty chat until the user manually loads
|
||||
// older messages. Expand only this initial tail fetch, with a hard cap.
|
||||
if (!options?.before && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) {
|
||||
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
|
||||
if (!options?.before && !page.complete && !hasUserMessage(page.session)) {
|
||||
for (const nextLimit of getInitialPageExpansionLimits()) {
|
||||
if (nextLimit <= limit) continue
|
||||
page = await fetchMessages(sessionID, nextLimit)
|
||||
if (page.complete || hasUserMessage(page.session)) break
|
||||
@@ -362,6 +372,11 @@ export function useSync() {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
}
|
||||
|
||||
if (options?.isStale?.()) {
|
||||
setMetaFor(sessionID, { loading: false })
|
||||
return
|
||||
}
|
||||
|
||||
const current = store.getState()
|
||||
const materialized = materializeSessionSnapshots(
|
||||
current,
|
||||
@@ -373,6 +388,11 @@ export function useSync() {
|
||||
{ skipPartTypes: SKIP_PARTS, mode: options?.mode === "prepend" ? "prepend" : "merge" },
|
||||
)
|
||||
|
||||
if (options?.isStale?.()) {
|
||||
setMetaFor(sessionID, { loading: false })
|
||||
return
|
||||
}
|
||||
|
||||
setMetaFor(sessionID, {
|
||||
limit: materialized.messages.length,
|
||||
cursor: merged.cursor,
|
||||
@@ -404,6 +424,13 @@ export function useSync() {
|
||||
const existing = syncSessionInflightByKey.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
// This is a new request. Bump generation so any older request that
|
||||
// might still be finishing (e.g. from a previous component lifecycle)
|
||||
// knows it is stale and should not write to the store.
|
||||
const generation = (syncSessionGenerationByKey.get(key) ?? 0) + 1
|
||||
syncSessionGenerationByKey.set(key, generation)
|
||||
const isStale = () => syncSessionGenerationByKey.get(key) !== generation
|
||||
|
||||
const current = store.getState()
|
||||
const m = getMetaFor(sessionID)
|
||||
const materialization = getSessionMaterializationStatus(current, sessionID)
|
||||
@@ -449,7 +476,7 @@ export function useSync() {
|
||||
assertSdkSuccess(response, "session.get")
|
||||
return response
|
||||
})
|
||||
if (result.data) {
|
||||
if (result.data && !isStale()) {
|
||||
const nextSession = stripSessionDiffSnapshots(result.data)
|
||||
const s = store.getState()
|
||||
const sessions = [...s.session]
|
||||
@@ -459,15 +486,30 @@ export function useSync() {
|
||||
} else {
|
||||
sessions.splice(idx.index, 0, nextSession)
|
||||
}
|
||||
store.setState({ session: sessions })
|
||||
if (!isStale()) {
|
||||
store.setState({ session: sessions })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[sync] failed to fetch session", sessionID, e)
|
||||
}
|
||||
})()
|
||||
: Promise.resolve(),
|
||||
shouldLoadMessages ? loadMessages(sessionID) : Promise.resolve(),
|
||||
shouldLoadMessages ? loadMessages(sessionID, { isStale }) : Promise.resolve(),
|
||||
])
|
||||
|
||||
// Progressive mount: after the initial page resolves, if the session
|
||||
// isn't stale and the server indicated more messages, dispatch a
|
||||
// second fetch to prepend older history. The user sees the first page
|
||||
// immediately; the rest arrive shortly after. This gives the scroll
|
||||
// container headroom above the viewport so the "load older on
|
||||
// scroll-up" trigger fires before the user hits the absolute top.
|
||||
if (!isStale()) {
|
||||
const currentMeta = getMetaFor(sessionID)
|
||||
if (currentMeta.cursor && !currentMeta.complete) {
|
||||
loadMessages(sessionID, { before: currentMeta.cursor, mode: "prepend", isStale })
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
syncSessionInflightByKey.set(key, promise)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from './types';
|
||||
|
||||
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot } from './user-message-history';
|
||||
|
||||
const message = (id: string, role: 'user' | 'assistant'): Message => ({
|
||||
id,
|
||||
role,
|
||||
sessionID: 'ses_1',
|
||||
time: { created: 1 },
|
||||
} as Message);
|
||||
|
||||
const textPart = (id: string, text: string): Part => ({
|
||||
id,
|
||||
type: 'text',
|
||||
text,
|
||||
} as Part);
|
||||
|
||||
const state = (partial: Partial<State>): Pick<State, 'session' | 'message' | 'part'> => ({
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
...partial,
|
||||
});
|
||||
|
||||
describe('buildUserMessageHistorySnapshot', () => {
|
||||
test('returns a shared empty snapshot without a session id', () => {
|
||||
expect(buildUserMessageHistorySnapshot(state({}), '')).toBe(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT);
|
||||
});
|
||||
|
||||
test('keeps history stable when assistant parts change', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const assistant = message('assistant_1', 'assistant');
|
||||
const userParts = [textPart('part_user', 'hello')];
|
||||
const first = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user, assistant] },
|
||||
part: { user_1: userParts, assistant_1: [textPart('part_a', 'stream')] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
const second = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user, assistant] },
|
||||
part: { user_1: userParts, assistant_1: [textPart('part_a2', 'streaming')] },
|
||||
}),
|
||||
'ses_1',
|
||||
first,
|
||||
);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(second.history).toEqual(['hello']);
|
||||
});
|
||||
|
||||
test('updates history when a user part changes', () => {
|
||||
const user = message('user_1', 'user');
|
||||
const first = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user] },
|
||||
part: { user_1: [textPart('part_user', 'hello')] },
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
const second = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
message: { ses_1: [user] },
|
||||
part: { user_1: [textPart('part_user_updated', 'updated')] },
|
||||
}),
|
||||
'ses_1',
|
||||
first,
|
||||
);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.history).toEqual(['updated']);
|
||||
});
|
||||
|
||||
test('excludes user messages hidden by session revert state', () => {
|
||||
const beforeRevert = message('user_1', 'user');
|
||||
const reverted = message('user_2', 'user');
|
||||
|
||||
const snapshot = buildUserMessageHistorySnapshot(
|
||||
state({
|
||||
session: [{ id: 'ses_1', revert: { messageID: 'user_2' } } as State['session'][number]],
|
||||
message: { ses_1: [beforeRevert, reverted] },
|
||||
part: {
|
||||
user_1: [textPart('part_user_1', 'kept')],
|
||||
user_2: [textPart('part_user_2', 'reverted')],
|
||||
},
|
||||
}),
|
||||
'ses_1',
|
||||
);
|
||||
|
||||
expect(snapshot.history).toEqual(['kept']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Message, Part } from '@opencode-ai/sdk/v2/client';
|
||||
import type { State } from './types';
|
||||
|
||||
type UserMessageHistoryRecord = {
|
||||
message: Message;
|
||||
parts: Part[];
|
||||
};
|
||||
|
||||
export type UserMessageHistorySnapshot = {
|
||||
sessionID: string;
|
||||
revertMessageID?: string;
|
||||
records: UserMessageHistoryRecord[];
|
||||
history: string[];
|
||||
};
|
||||
|
||||
const EMPTY_PARTS: Part[] = [];
|
||||
const EMPTY_RECORDS: UserMessageHistoryRecord[] = [];
|
||||
const EMPTY_HISTORY: string[] = [];
|
||||
|
||||
export const EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT: UserMessageHistorySnapshot = {
|
||||
sessionID: '',
|
||||
revertMessageID: undefined,
|
||||
records: EMPTY_RECORDS,
|
||||
history: EMPTY_HISTORY,
|
||||
};
|
||||
|
||||
const getPartText = (part: Part): string => {
|
||||
if (part?.type !== 'text') return '';
|
||||
const text = (part as { text?: unknown }).text;
|
||||
return typeof text === 'string' ? text : '';
|
||||
};
|
||||
|
||||
const getFirstTextFromParts = (parts: Part[]): string => {
|
||||
for (const part of parts) {
|
||||
const text = getPartText(part);
|
||||
if (text.length > 0) return text;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const areRecordsEqual = (left: UserMessageHistoryRecord[], right: UserMessageHistoryRecord[]): boolean => {
|
||||
if (left === right) return true;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
if (left[index]?.message !== right[index]?.message || left[index]?.parts !== right[index]?.parts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const buildUserMessageHistorySnapshot = (
|
||||
state: Pick<State, 'session' | 'message' | 'part'>,
|
||||
sessionID: string,
|
||||
previous: UserMessageHistorySnapshot = EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT,
|
||||
): UserMessageHistorySnapshot => {
|
||||
if (!sessionID) {
|
||||
return EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT;
|
||||
}
|
||||
|
||||
const messages = state.message[sessionID] ?? [];
|
||||
const session = state.session.find((candidate) => candidate.id === sessionID);
|
||||
const revertMessageID = (session as { revert?: { messageID?: string } } | undefined)?.revert?.messageID;
|
||||
const records: UserMessageHistoryRecord[] = [];
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
if (revertMessageID && message.id >= revertMessageID) {
|
||||
continue;
|
||||
}
|
||||
records.push({
|
||||
message,
|
||||
parts: state.part[message.id] ?? EMPTY_PARTS,
|
||||
});
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && previous.records.length === 0
|
||||
? previous
|
||||
: { sessionID, revertMessageID, records: EMPTY_RECORDS, history: EMPTY_HISTORY };
|
||||
}
|
||||
|
||||
if (previous.sessionID === sessionID && previous.revertMessageID === revertMessageID && areRecordsEqual(previous.records, records)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const history: string[] = [];
|
||||
for (const record of records) {
|
||||
const text = getFirstTextFromParts(record.parts);
|
||||
if (text.length > 0) {
|
||||
history.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionID, revertMessageID, records, history };
|
||||
};
|
||||
Reference in New Issue
Block a user