diff --git a/FORK.md b/FORK.md index f85c872600c..941a7d1eb42 100644 --- a/FORK.md +++ b/FORK.md @@ -19,6 +19,11 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Exception: upstream actualization PRs may preserve upstream commit structure when that makes future syncs easier to audit. - Staged formatting tolerates chunks containing only ignored files so large upstream actualization commits can pass the pre-commit hook. +### Compatibility + +- Fork backend changes must remain compatible with non-fork clients. Upstream clients must be able to use a fork server without fork-specific assumptions or protocol failures. +- Fork database changes and migrations must leave the database usable by regular upstream builds. The upstream backend must still open and use the database, and the upstream frontend must still work through that backend. Prefer sidecar databases for fork-only data. + ### Protocol Compatibility - Fork protocol extensions use separate extension RPC methods and additive optional capabilities. @@ -63,6 +68,16 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### Fork Persistence - Fork-only goal persistence is stored in a sidecar database named `state-tarik02.sqlite`. +- Bounded live-thread snapshots use fork-namespaced cache keys, so the fork never decodes legacy + unbounded snapshot JSON during startup and upstream clients ignore the fork cache entries. +- The web client keeps fetched thread-history rows in an unbounded, normalized sidecar IndexedDB + cache. Messages, activities, and plans are stored once and reused for covered history ranges. + Cached records are schema-decoded independently so large activity batches cannot stall on a + cooperative runtime yield. An authoritative bounded snapshot clears cached segments because it + may replace an event replay that contained a thread revert. +- The mobile client keeps recently fetched thread-history pages in a bounded, session-only + in-memory LRU. It avoids repeat requests while browsing nearby segments without adding mobile + database state or migrations, and it does not request the web-only minimap outline. ### Goals UI @@ -88,6 +103,23 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### UX Changes +- Progressive thread history is a client-local beta setting and defaults to off. Disabled clients + use the full-history subscription and do not request bounded message pages. +- Thread detail snapshots keep a bounded live tail. Historical browsing uses bounded, + bidirectional keyset windows cut only at user-turn boundaries, while the web minimap indexes every + user message across the full thread and loads the selected segment on demand. One LegendList owns + both live and historical scrolling, and its visible-content anchoring stabilizes page changes and + late row measurements. The local scrollbar only represents the current bounded segment; the + minimap is the global conversation-indexed scrubber. Historical windows retain every raw message + and work-telemetry row belonging to their turns, and only display activity for turns represented + by the active message window. Reaching a segment edge loads the adjacent window + without moving visible rows. Scroll-to-end returns directly to the bounded live tail. +- Paginated history responses use the same client-facing activity payload projection as full thread + snapshots, and command output omitted by that projection is removed in SQLite before schema decode. + The persisted activity remains unchanged. +- Failed segment requests release their pending navigation target. Keyboard, pointer, touch, or wheel + input immediately cancels target alignment. Minimap dragging follows the pointer immediately while + throttling segment requests, and only the latest explicit jump may replace the active segment. - Desktop context-menu style is configurable from Appearance settings. - The sidebar follows the active thread when it appears or when navigation originates elsewhere. - Sidebar environments can be hidden or shown dynamically from the project toolbar. diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ba9ff2d5cde..93c67a2a10f 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -22,6 +22,7 @@ const clientSettings: ClientSettings = { environmentIdentificationMode: "artwork", favorites: [], glassOpacity: 80, + progressiveThreadHistoryEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3, sidebarProjectGroupingMode: "repository_path", diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 6573c9e1187..bd6e37d1254 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -20,6 +20,7 @@ const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; +const THREAD_CACHE_KEY_NAMESPACE = "tarik02:bounded-v1"; const StoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), @@ -61,6 +62,10 @@ const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsR type CacheOperation = ConnectionPersistenceError["operation"]; +function threadCacheKey(threadId: string) { + return `${THREAD_CACHE_KEY_NAMESPACE}:${threadId}`; +} + function persistenceError(operation: CacheOperation, cause: unknown) { return new ConnectionPersistenceError({ operation, @@ -140,7 +145,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { database, environmentId, kind: "thread", - cacheKey: threadId, + cacheKey: threadCacheKey(threadId), operation: "load-thread", decode: decodeStoredThreadSnapshot, select: (stored) => @@ -158,12 +163,18 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* database - .saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) + .saveCache( + environmentId, + "thread", + threadCacheKey(threadId), + THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + payload, + ) .pipe(Effect.mapError(mapDatabaseError("save-thread"))); }), removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => database - .removeCache(environmentId, "thread", threadId) + .removeCache(environmentId, "thread", threadCacheKey(threadId)) .pipe(Effect.mapError(mapDatabaseError("remove-thread"))), ), loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) => diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10..a31cfb36132 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -6,6 +6,7 @@ import { PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, + ThreadHistoryCacheStore, } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, @@ -208,26 +209,33 @@ const providedCapabilitiesLayer = capabilitiesLayer.pipe( Layer.provide(Runtime.runtimeContextLayer), ); -const environmentOwnedDataCleanupLayer = Layer.succeed( +const environmentOwnedDataCleanupLayer = Layer.effect( EnvironmentOwnedDataCleanup, - EnvironmentOwnedDataCleanup.of({ - clear: (environmentId) => - Effect.all( - [ - Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), - Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), - ], - { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), + Effect.gen(function* () { + const historyCache = yield* ThreadHistoryCacheStore; + return EnvironmentOwnedDataCleanup.of({ + clear: (environmentId) => + Effect.all( + [ + Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), + Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), + historyCache.clear(environmentId), + ], + { concurrency: "unbounded", discard: true }, + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + cause, + }), + ), ), - ), + }); }), ); +const providedEnvironmentOwnedDataCleanupLayer = environmentOwnedDataCleanupLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); type ConnectionPlatformLayerSource = | typeof providedConnectionStorageLayer @@ -236,7 +244,7 @@ type ConnectionPlatformLayerSource = | typeof wakeupsLayer | typeof providedCapabilitiesLayer | typeof platformConnectionSourceLayer - | typeof environmentOwnedDataCleanupLayer; + | typeof providedEnvironmentOwnedDataCleanupLayer; export const connectionPlatformLayer: Layer.Layer< Layer.Success, @@ -249,5 +257,5 @@ export const connectionPlatformLayer: Layer.Layer< wakeupsLayer, providedCapabilitiesLayer, platformConnectionSourceLayer, - environmentOwnedDataCleanupLayer, + providedEnvironmentOwnedDataCleanupLayer, ); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f354bcd29ac..351327923f5 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -558,7 +558,11 @@ function GeneralSettingsSection() { */ function BetaSettingsSection() { const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = useThreadListV2Enabled(); + const progressiveThreadHistoryEnabled = + AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.progressiveThreadHistoryEnabled === true; return ( @@ -569,10 +573,16 @@ function BetaSettingsSection() { value={threadListV2Enabled} onValueChange={(value) => savePreferences({ threadListV2Enabled: value })} /> + savePreferences({ progressiveThreadHistoryEnabled: value })} + /> - One flat thread list in creation order. Active work renders as cards; settled threads - collapse to compact rows. Switch back any time. + Thread List v2 simplifies navigation. Progressive Thread History loads large conversations + in pages instead of downloading the full thread. ); diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..796c0392451 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -61,6 +61,10 @@ export interface ThreadDetailScreenProps { readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ readonly threadSyncStatus?: EnvironmentThreadStatus; + readonly hasPreviousMessages?: boolean; + readonly hasNextMessages?: boolean; + readonly isLoadingPreviousMessages?: boolean; + readonly isLoadingNextMessages?: boolean; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; @@ -78,6 +82,8 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; readonly onSendMessage: () => Promise; readonly onReconnectEnvironment: () => void; + readonly onLoadPreviousMessages?: () => void; + readonly onLoadNextMessages?: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -370,6 +376,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + hasPreviousMessages={props.hasPreviousMessages} + hasNextMessages={props.hasNextMessages} + isLoadingPreviousMessages={props.isLoadingPreviousMessages} + isLoadingNextMessages={props.isLoadingNextMessages} + onLoadPreviousMessages={props.onLoadPreviousMessages} + onLoadNextMessages={props.onLoadNextMessages} skills={selectedProviderSkills} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 8ad117c8635..33fc4d1bc57 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -163,6 +163,12 @@ export interface ThreadFeedProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly hasPreviousMessages?: boolean; + readonly hasNextMessages?: boolean; + readonly isLoadingPreviousMessages?: boolean; + readonly isLoadingNextMessages?: boolean; + readonly onLoadPreviousMessages?: () => void; + readonly onLoadNextMessages?: () => void; readonly skills?: ReadonlyArray; } @@ -1842,7 +1848,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // anchor scrolls also lets it correct a scroll that landed on a // stale end target once the anchor row finishes measuring. maintainScrollAtEnd={ - disclosureToggleSettling + disclosureToggleSettling || props.hasNextMessages ? false : { animated: true, @@ -1890,10 +1896,36 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // overflow the viewport (the padding clamps to zero). alignItemsAtEnd initialScrollAtEnd + onStartReached={props.hasPreviousMessages ? props.onLoadPreviousMessages : undefined} + onStartReachedThreshold={0.8} + onEndReached={props.hasNextMessages ? props.onLoadNextMessages : undefined} + onEndReachedThreshold={0.8} onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ - usesNativeAutomaticInsets ? null : + props.isLoadingPreviousMessages ? ( + + + + + Loading earlier messages... + + + + ) : usesNativeAutomaticInsets ? null : ( + + ) + } + ListFooterComponent={ + props.isLoadingNextMessages ? ( + + + Loading newer messages... + + ) : null } contentContainerStyle={{ paddingTop: 12, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 23d38c0d56d..a0298e38a8e 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -58,7 +58,7 @@ import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-s import { useSelectedThreadRequests } from "../../state/use-selected-thread-requests"; import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; -import { threadEnvironment } from "../../state/threads"; +import { environmentThreads, threadEnvironment } from "../../state/threads"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, @@ -190,12 +190,22 @@ function ThreadRouteContent( useThreadSelection(); const selectedThreadDetailState = props.selectedThreadDetailState; const selectedThreadDetail = Option.getOrNull(selectedThreadDetailState.data); + const hasNextMessages = selectedThreadDetail?.messageHistory?.hasMoreAfter === true; const { selectedThreadCwd } = useSelectedThreadWorktree(); const composer = useThreadComposerState(); const gitState = useSelectedThreadGitState(); const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { + reportFailure: false, + }); + const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + reportFailure: false, + }); + const showLatestMessages = useAtomCommand(environmentThreads.showLatestMessages, { + reportFailure: false, + }); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -314,6 +324,35 @@ function ThreadRouteContent( } onReconnectEnvironment(environmentId); }, [environmentId, onReconnectEnvironment]); + const handleLoadPreviousMessages = useCallback(() => { + if (selectedThread === null) { + return; + } + void loadPreviousMessages({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [loadPreviousMessages, selectedThread]); + const handleLoadNextMessages = useCallback(() => { + if (selectedThread === null) { + return; + } + void loadNextMessages({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [loadNextMessages, selectedThread]); + const handleSendMessage = useCallback(async () => { + const messageId = await composer.onSendMessage(); + if (messageId === null || selectedThread === null || hasNextMessages === false) { + return messageId; + } + await showLatestMessages({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + return messageId; + }, [composer.onSendMessage, hasNextMessages, selectedThread, showLatestMessages]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -761,6 +800,16 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + hasPreviousMessages={selectedThreadDetail?.messageHistory?.hasMoreBefore === true} + hasNextMessages={hasNextMessages} + isLoadingPreviousMessages={ + selectedThreadDetailState.history.kind === "ready" && + selectedThreadDetailState.history.loading === "before" + } + isLoadingNextMessages={ + selectedThreadDetailState.history.kind === "ready" && + selectedThreadDetailState.history.loading === "after" + } activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} @@ -775,8 +824,10 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={handleStopThread} - onSendMessage={composer.onSendMessage} + onSendMessage={handleSendMessage} onReconnectEnvironment={handleReconnectEnvironment} + onLoadPreviousMessages={handleLoadPreviousMessages} + onLoadNextMessages={handleLoadNextMessages} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} diff --git a/apps/mobile/src/persistence/layer.ts b/apps/mobile/src/persistence/layer.ts index 5a9d1dacff3..275752c9ea2 100644 --- a/apps/mobile/src/persistence/layer.ts +++ b/apps/mobile/src/persistence/layer.ts @@ -1,3 +1,7 @@ +import { + makeInMemoryThreadHistoryCacheStore, + ThreadHistoryCacheStore, +} from "@t3tools/client-runtime/platform"; import * as Layer from "effect/Layer"; import * as EnvironmentCacheStore from "../connection/environment-cache-store"; @@ -6,11 +10,17 @@ import * as MobilePreferences from "./mobile-preferences"; import * as MobileSecureStorage from "./mobile-secure-storage"; import * as MobileStorage from "./mobile-storage"; +const threadHistoryCacheLayer = Layer.succeed( + ThreadHistoryCacheStore, + makeInMemoryThreadHistoryCacheStore(12), +); + const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer); const dependentLayer = Layer.mergeAll( MobilePreferences.layer, MobileStorage.layer, EnvironmentCacheStore.layer, + threadHistoryCacheLayer, ).pipe(Layer.provide(baseLayer)); export const layer = Layer.merge(baseLayer, dependentLayer); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 6b1018e2a0a..39fe5062576 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -30,6 +30,7 @@ export interface Preferences { * see `resolveThreadListV2Enabled`. */ readonly threadListV2Enabled?: boolean; + readonly progressiveThreadHistoryEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -70,18 +71,7 @@ export class MobilePreferencesStore extends Context.Service< >()("@t3tools/mobile/persistence/MobilePreferencesStore") {} function sanitizePreferences(parsed: Preferences): Preferences { - const preferences: { - liveActivitiesEnabled?: boolean; - baseFontSize?: number; - terminalFontSize?: number | null; - markdownFontSize?: number; - codeFontSize?: number | null; - codeWordBreak?: boolean; - connectOnboardingOptOutAccounts?: ReadonlyArray; - collapsedProjectGroups?: readonly string[]; - projectGroupingEnabled?: boolean; - threadListV2Enabled?: boolean; - } = {}; + const preferences: { -readonly [Key in keyof Preferences]: Preferences[Key] } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; @@ -113,6 +103,12 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if ( + parsed.progressiveThreadHistoryEnabled === true || + parsed.progressiveThreadHistoryEnabled === false + ) { + preferences.progressiveThreadHistoryEnabled = parsed.progressiveThreadHistoryEnabled; + } return preferences; } diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f247123051..ebb91f1dee0 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -9,14 +9,39 @@ import { } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "./atom-registry"; +import { mobilePreferencesAtom } from "./preferences"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); -export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime, { + loadHistoryOutline: false, + messagePagination: { + enabled: () => { + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + return ( + AsyncResult.isSuccess(preferences) && + preferences.value.progressiveThreadHistoryEnabled === true + ); + }, + changes: Stream.suspend(() => + AtomRegistry.toStream(appAtomRegistry, mobilePreferencesAtom).pipe( + Stream.map( + (preferences) => + AsyncResult.isSuccess(preferences) && + preferences.value.progressiveThreadHistoryEnabled === true, + ), + Stream.changes, + Stream.drop(1), + ), + ), + }, +}); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 82ff42f247a..f570e476f93 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import * as Option from "effect/Option"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -15,7 +16,7 @@ import { type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; -import { useSelectedThreadDetail } from "./use-thread-detail"; +import { useSelectedThreadDetailState } from "./use-thread-detail"; import { useThreadSelection } from "./use-thread-selection"; import { useAtomCommand } from "./use-atom-command"; @@ -64,7 +65,7 @@ export function useSelectedThreadRequests() { "thread user input response", ); const { selectedThread: selectedThreadShell } = useThreadSelection(); - const selectedThread = useSelectedThreadDetail(); + const selectedThread = Option.getOrNull(useSelectedThreadDetailState().liveData); const userInputDraftsByRequestKey = useAtomValue(userInputDraftsByRequestKeyAtom); const [respondingApprovalId, setRespondingApprovalId] = useState(null); const [respondingUserInputId, setRespondingUserInputId] = useState( diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index fe093c451e2..6464646b211 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -108,6 +108,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -202,6 +206,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -286,6 +294,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -355,6 +367,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -409,6 +425,10 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 67896961b38..6c825be434a 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -2,6 +2,7 @@ import type { OrchestrationEvent, OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryPage, } from "@t3tools/contracts"; function asRecord(value: unknown): Record | null { @@ -159,6 +160,13 @@ export function projectActivityPayload( activity: OrchestrationThreadActivity, ): OrchestrationThreadActivity { const payload = asRecord(activity.payload); + if (payload && (activity.kind === "goal.updated" || activity.kind === "goal.cleared")) { + const detail = asTrimmedString(payload.detail); + return { + ...activity, + payload: detail ? { detail } : {}, + }; + } const data = asRecord(payload?.data); if (!payload || !data || payload.itemType === "mcp_tool_call") { return activity; @@ -218,7 +226,7 @@ function isResolvableContextWindowActivity(activity: OrchestrationThreadActivity /** * Drops all but the last resolvable context-window activity per turn from a - * snapshot. Clients only ever read the latest usage value (walking the array + * thread payload. Clients only ever read the latest usage value (walking the array * backwards), so shipping the full history — often thousands of rows on long * threads — buys nothing. Retention is per turn rather than per thread because * a live `thread.reverted` makes the client discard whole turns; keeping each @@ -247,6 +255,14 @@ function dropStaleContextWindowActivities( ); } +function projectThreadActivities( + activities: ReadonlyArray, +): ReadonlyArray { + return dropStaleContextWindowActivities(activities) + .filter((activity) => activity.kind !== "tool.started" && activity.kind !== "task.started") + .map(projectActivityPayload); +} + export function projectThreadDetailSnapshot( snapshot: OrchestrationThreadDetailSnapshot, ): OrchestrationThreadDetailSnapshot { @@ -254,13 +270,20 @@ export function projectThreadDetailSnapshot( ...snapshot, thread: { ...snapshot.thread, - activities: dropStaleContextWindowActivities(snapshot.thread.activities).map( - projectActivityPayload, - ), + activities: projectThreadActivities(snapshot.thread.activities), }, }; } +export function projectThreadHistoryPage( + page: OrchestrationThreadHistoryPage, +): OrchestrationThreadHistoryPage { + return { + ...page, + activities: projectThreadActivities(page.activities), + }; +} + export function projectActivityEvent(event: OrchestrationEvent): OrchestrationEvent { if (event.type !== "thread.activity-appended") { return event; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 9574d2fd884..c25636ff4bd 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -204,6 +204,10 @@ describe("OrchestrationEngine", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 36ae74148c8..11731a640c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -53,6 +53,7 @@ import { ProjectionThreadGoalRepository } from "../../persistence/Services/Proje import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; +import { makeProjectionThreadHistoryQuery } from "./ProjectionThreadHistoryQuery.ts"; import { ProjectionSnapshotQuery, type ProjectionFullThreadDiffContext, @@ -310,8 +311,45 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } +function mapThreadMessageRow( + row: Schema.Schema.Type, +): OrchestrationMessage { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + if (row.attachments !== null) { + return Object.assign(message, { attachments: row.attachments }); + } + return message; +} + +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + if (row.sequence !== null) { + return Object.assign(activity, { sequence: row.sequence }); + } + return activity; +} + const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const threadHistoryQuery = yield* makeProjectionThreadHistoryQuery(); const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -542,7 +580,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { tone, kind, summary, - payload_json AS "payload", + CASE + WHEN json_extract(payload_json, '$.itemType') = 'command_execution' + THEN json_remove(payload_json, '$.data.item.aggregatedOutput') + ELSE payload_json + END AS "payload", sequence, created_at AS "createdAt" FROM projection_thread_activities @@ -2121,84 +2163,99 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + const getThreadDetail = (threadId: ThreadId, turnLimit?: number) => Effect.gen(function* () { - const [ - threadRow, - messageRows, - proposedPlanRows, - activityRows, - checkpointRows, - latestTurnRow, - sessionRow, - ] = yield* Effect.all([ - getActiveThreadRowById({ threadId }).pipe( - Effect.flatMap((option) => - Option.isNone(option) - ? Effect.succeed(Option.none()) - : withThreadGoals([option.value]).pipe(Effect.map((rows) => Option.some(rows[0]!))), - ), - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", - "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + const turnPage = + turnLimit === undefined + ? null + : yield* threadHistoryQuery.getLatestTurnPage({ threadId, limit: turnLimit }); + const [threadRow, messageRows, checkpointRows, latestTurnRow, sessionRow] = yield* Effect.all( + [ + getActiveThreadRowById({ threadId }).pipe( + Effect.flatMap((option) => + Option.isNone(option) + ? Effect.succeed(Option.none()) + : withThreadGoals([option.value]).pipe(Effect.map((rows) => Option.some(rows[0]!))), ), - ), - ), - listThreadMessageRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", - "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", - ), - ), - ), - listThreadProposedPlanRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", - "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getThread:query", + "ProjectionSnapshotQuery.getThreadDetailById:getThread:decodeRow", + ), ), ), - ), - listThreadActivityRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", - "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + (turnLimit === undefined + ? listThreadMessageRowsByThread({ threadId }) + : Effect.succeed([]) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:listMessages:decodeRows", + ), ), ), - ), - listCheckpointRowsByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", - "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + listCheckpointRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:query", + "ProjectionSnapshotQuery.getThreadDetailById:listCheckpoints:decodeRows", + ), ), ), - ), - getLatestTurnRowByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", - "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + getLatestTurnRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:query", + "ProjectionSnapshotQuery.getThreadDetailById:getLatestTurn:decodeRow", + ), ), ), - ), - getThreadSessionRowByThread({ threadId }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", - "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + getThreadSessionRowByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:getSession:query", + "ProjectionSnapshotQuery.getThreadDetailById:getSession:decodeRow", + ), ), ), - ), - ]); + ], + ); - if (Option.isNone(threadRow)) { + if (Option.isNone(threadRow) || (turnPage !== null && Option.isNone(turnPage))) { return Option.none(); } + const turnHistoryPage = turnPage === null ? null : turnPage.value; + const history = + turnHistoryPage !== null + ? { + proposedPlans: turnHistoryPage.proposedPlans, + activities: turnHistoryPage.activities, + } + : yield* Effect.all([ + listThreadProposedPlanRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:query", + "ProjectionSnapshotQuery.getThreadDetailById:listPlans:decodeRows", + ), + ), + ), + listThreadActivityRowsByThread({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:query", + "ProjectionSnapshotQuery.getThreadDetailById:listActivities:decodeRows", + ), + ), + ), + ]).pipe( + Effect.map(([proposedPlanRows, activityRows]) => ({ + proposedPlans: proposedPlanRows.map(mapProposedPlanRow), + activities: activityRows.map(mapThreadActivityRow), + })), + ); const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2219,37 +2276,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, goal: threadRow.value.goal, - messages: messageRows.map((row) => { - const message = { - id: row.messageId, - role: row.role, - text: row.text, - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; - if (row.attachments !== null) { - return Object.assign(message, { attachments: row.attachments }); - } - return message; - }), - proposedPlans: proposedPlanRows.map(mapProposedPlanRow), - activities: activityRows.map((row) => { - const activity = { - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }; - if (row.sequence !== null) { - return Object.assign(activity, { sequence: row.sequence }); - } - return activity; - }), + messages: + turnHistoryPage === null + ? messageRows.map(mapThreadMessageRow) + : turnHistoryPage.messages, + ...(turnHistoryPage === null ? {} : { messageHistory: turnHistoryPage.messageHistory }), + proposedPlans: history.proposedPlans, + activities: history.activities, checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2271,8 +2304,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + getThreadDetail(threadId); + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( threadId, + turnLimit, ) => // Read the thread detail and the snapshot sequence within a single // transaction so the sequence is consistent with the returned state; a @@ -2282,7 +2319,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql .withTransaction( Effect.gen(function* () { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetail(threadId, turnLimit); if (Option.isNone(thread)) { return Option.none(); } @@ -2300,6 +2337,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getThreadMessagePage: ProjectionSnapshotQueryShape["getThreadMessagePage"] = (input) => + threadHistoryQuery.getTurnPage({ ...input, limit: input.turnLimit }); + const getThreadMessagePageAfter: ProjectionSnapshotQueryShape["getThreadMessagePageAfter"] = ( + input, + ) => threadHistoryQuery.getTurnPageAfter({ ...input, limit: input.turnLimit }); + const getThreadMessagePageAround: ProjectionSnapshotQueryShape["getThreadMessagePageAround"] = ( + input, + ) => threadHistoryQuery.getTurnPageAround({ ...input, limit: input.turnLimit }); + const getThreadHistoryOutline: ProjectionSnapshotQueryShape["getThreadHistoryOutline"] = ( + threadId, + ) => threadHistoryQuery.getHistoryOutline(threadId); + return { getCommandReadModel, getSnapshot, @@ -2316,6 +2365,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadShellById, getThreadDetailById, getThreadDetailSnapshot, + getThreadMessagePage, + getThreadMessagePageAfter, + getThreadMessagePageAround, + getThreadHistoryOutline, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Layers/ProjectionThreadHistoryQuery.ts b/apps/server/src/orchestration/Layers/ProjectionThreadHistoryQuery.ts new file mode 100644 index 00000000000..dc596d3ecb3 --- /dev/null +++ b/apps/server/src/orchestration/Layers/ProjectionThreadHistoryQuery.ts @@ -0,0 +1,649 @@ +import { + ChatAttachment, + IsoDateTime, + MessageId, + NonNegativeInt, + OrchestrationThreadHistoryPage, + PositiveInt, + ThreadId, + type OrchestrationMessage, + type OrchestrationProposedPlan, + type OrchestrationThreadActivity, + type OrchestrationThreadHistoryOutline, + type OrchestrationThreadMessageCursor, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { + toPersistenceDecodeError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; +import { ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; + +const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( + Struct.assign({ + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + }), +); +const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( + Struct.assign({ + payload: Schema.fromJsonString(Schema.Unknown), + sequence: Schema.NullOr(NonNegativeInt), + }), +); +const ThreadIdLookupInput = Schema.Struct({ + threadId: ThreadId, +}); +const ThreadMessagePageLookupInput = Schema.Struct({ + threadId: ThreadId, + cursorCreatedAt: IsoDateTime, + cursorMessageId: MessageId, + fetchLimit: PositiveInt, +}); +const ThreadTurnPageLookupInput = Schema.Struct({ + threadId: ThreadId, + mode: Schema.Literals(["latest", "before", "after", "around"]), + cursorCreatedAt: Schema.NullOr(IsoDateTime), + cursorMessageId: Schema.NullOr(MessageId), + fetchLimit: PositiveInt, +}); +const ThreadMessageCursorLookupInput = Schema.Struct({ + threadId: ThreadId, + cursorCreatedAt: IsoDateTime, + cursorMessageId: MessageId, +}); +const ThreadMessageIdLookupInput = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); +const ThreadHistoryRangeLookupInput = Schema.Struct({ + threadId: ThreadId, + startCreatedAt: IsoDateTime, + endCreatedAt: Schema.NullOr(IsoDateTime), +}); +const ProjectionThreadIdLookupRowSchema = Schema.Struct({ + threadId: ThreadId, +}); +const ProjectionThreadMessageCountRowSchema = Schema.Struct({ + count: NonNegativeInt, +}); +const ProjectionThreadHistoryLandmarkRowSchema = Schema.Struct({ + messageId: MessageId, + ordinal: NonNegativeInt, + messageIndex: NonNegativeInt, + createdAt: IsoDateTime, + preview: Schema.String, + totalUserMessages: NonNegativeInt, +}); + +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { + return (cause: unknown): ProjectionRepositoryError => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(decodeOperation)(cause) + : toPersistenceSqlError(sqlOperation)(cause); +} + +function mapThreadMessageRow( + row: Schema.Schema.Type, +): OrchestrationMessage { + const message = { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + return row.attachments === null + ? message + : Object.assign(message, { attachments: row.attachments }); +} + +function mapThreadActivityRow( + row: Schema.Schema.Type, +): OrchestrationThreadActivity { + const activity = { + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }; + return row.sequence === null ? activity : Object.assign(activity, { sequence: row.sequence }); +} + +function mapProposedPlanRow( + row: Schema.Schema.Type, +): OrchestrationProposedPlan { + return { + id: row.planId, + turnId: row.turnId, + planMarkdown: row.planMarkdown, + implementedAt: row.implementedAt, + implementationThreadId: row.implementationThreadId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export const makeProjectionThreadHistoryQuery = Effect.fn("ProjectionThreadHistoryQuery.make")( + function* () { + const sql = yield* SqlClient.SqlClient; + const getActiveThread = SqlSchema.findOneOption({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadIdLookupRowSchema, + execute: ({ threadId }) => + sql` + SELECT thread_id AS "threadId" + FROM projection_threads + WHERE thread_id = ${threadId} + AND deleted_at IS NULL + AND archived_at IS NULL + LIMIT 1 + `, + }); + const countMessageRows = SqlSchema.findOne({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageCountRowSchema, + execute: ({ threadId }) => + sql` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + const countMessageRowsBefore = SqlSchema.findOne({ + Request: ThreadMessageCursorLookupInput, + Result: ProjectionThreadMessageCountRowSchema, + execute: ({ threadId, cursorCreatedAt, cursorMessageId }) => + sql` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND (created_at, message_id) < (${cursorCreatedAt}, ${cursorMessageId}) + `, + }); + const countMessageRowsThrough = SqlSchema.findOne({ + Request: ThreadMessageCursorLookupInput, + Result: ProjectionThreadMessageCountRowSchema, + execute: ({ threadId, cursorCreatedAt, cursorMessageId }) => + sql` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND (created_at, message_id) <= (${cursorCreatedAt}, ${cursorMessageId}) + `, + }); + const listMessageRowsAfter = SqlSchema.findAll({ + Request: ThreadMessagePageLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, cursorCreatedAt, cursorMessageId, fetchLimit }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND (created_at, message_id) > (${cursorCreatedAt}, ${cursorMessageId}) + ORDER BY created_at ASC, message_id ASC + LIMIT ${fetchLimit} + `, + }); + const listTurnMessageRows = SqlSchema.findAll({ + Request: ThreadTurnPageLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, mode, cursorCreatedAt, cursorMessageId, fetchLimit }) => + sql` + WITH user_messages AS ( + SELECT + created_at, + message_id, + ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS ordinal, + COUNT(*) OVER () AS total + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND role = 'user' + ), + stats AS ( + SELECT COALESCE(MAX(total), 0) AS total + FROM user_messages + ), + window_start AS ( + SELECT CASE ${mode} + WHEN 'latest' THEN MAX(0, total - ${fetchLimit}) + WHEN 'before' THEN MAX( + 0, + ( + SELECT COUNT(*) + FROM user_messages + WHERE (created_at, message_id) < (${cursorCreatedAt}, ${cursorMessageId}) + ) - ${fetchLimit} + ) + WHEN 'after' THEN ( + SELECT COUNT(*) + FROM user_messages + WHERE (created_at, message_id) <= (${cursorCreatedAt}, ${cursorMessageId}) + ) + ELSE MAX( + 0, + MIN( + COALESCE( + ( + SELECT ordinal + FROM user_messages + WHERE (created_at, message_id) <= (${cursorCreatedAt}, ${cursorMessageId}) + ORDER BY created_at DESC, message_id DESC + LIMIT 1 + ), + 0 + ) - CAST((${fetchLimit} - 1) / 2 AS INTEGER), + total - ${fetchLimit} + ) + ) + END AS ordinal, + total + FROM stats + ), + window AS ( + SELECT + ordinal AS start_ordinal, + CASE ${mode} + WHEN 'before' THEN ( + SELECT COUNT(*) + FROM user_messages + WHERE (created_at, message_id) < (${cursorCreatedAt}, ${cursorMessageId}) + ) + ELSE MIN(total, ordinal + ${fetchLimit}) + END AS end_ordinal, + total + FROM window_start + ), + start_boundary AS ( + SELECT created_at, message_id + FROM user_messages + WHERE ordinal = (SELECT start_ordinal FROM window) + ), + end_boundary AS ( + SELECT created_at, message_id + FROM user_messages + WHERE ordinal = (SELECT end_ordinal FROM window) + ) + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND ( + (SELECT total FROM window) = 0 + OR ( + (created_at, message_id) >= ( + SELECT created_at, message_id FROM start_boundary + ) + AND ( + NOT EXISTS (SELECT 1 FROM end_boundary) + OR (created_at, message_id) < ( + SELECT created_at, message_id FROM end_boundary + ) + ) + ) + ) + ORDER BY created_at ASC, message_id ASC + `, + }); + const getMessageRowById = SqlSchema.findOneOption({ + Request: ThreadMessageIdLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, messageId }) => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND message_id = ${messageId} + LIMIT 1 + `, + }); + const listActivityRowsInRange = SqlSchema.findAll({ + Request: ThreadHistoryRangeLookupInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, startCreatedAt, endCreatedAt }) => + sql` + SELECT + activity_id AS "activityId", + thread_id AS "threadId", + turn_id AS "turnId", + tone, + kind, + summary, + CASE + WHEN json_extract(payload_json, '$.itemType') = 'command_execution' + THEN json_remove(payload_json, '$.data.item.aggregatedOutput') + ELSE payload_json + END AS "payload", + sequence, + created_at AS "createdAt" + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND created_at >= ${startCreatedAt} + AND (${endCreatedAt} IS NULL OR created_at < ${endCreatedAt}) + ORDER BY sequence ASC, created_at ASC, activity_id ASC + `, + }); + const listProposedPlanRowsInRange = SqlSchema.findAll({ + Request: ThreadHistoryRangeLookupInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId, startCreatedAt, endCreatedAt }) => + sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + AND created_at >= ${startCreatedAt} + AND (${endCreatedAt} IS NULL OR created_at < ${endCreatedAt}) + ORDER BY created_at ASC, plan_id ASC + `, + }); + const listHistoryLandmarkRows = SqlSchema.findAll({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadHistoryLandmarkRowSchema, + execute: ({ threadId }) => + sql` + WITH message_order AS ( + SELECT + message_id, + role, + created_at, + ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS message_index + FROM projection_thread_messages + WHERE thread_id = ${threadId} + ), + user_messages AS ( + SELECT + message_id, + created_at, + message_index, + ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS ordinal, + COUNT(*) OVER () AS total_user_messages + FROM message_order + WHERE role = 'user' + ) + SELECT + user_messages.message_id AS "messageId", + user_messages.ordinal, + user_messages.message_index AS "messageIndex", + user_messages.created_at AS "createdAt", + substr(messages.text, 1, 240) AS preview, + user_messages.total_user_messages AS "totalUserMessages" + FROM user_messages + INNER JOIN projection_thread_messages AS messages + ON messages.message_id = user_messages.message_id + ORDER BY user_messages.ordinal ASC + `, + }); + + const loadRange = Effect.fn("ProjectionThreadHistoryQuery.loadRange")(function* (input: { + readonly threadId: ThreadId; + readonly startCreatedAt: string; + readonly endCreatedAt: string | null; + }) { + const [activityRows, proposedPlanRows] = yield* Effect.all([ + listActivityRowsInRange(input), + listProposedPlanRowsInRange(input), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.loadRange:query", + "ProjectionThreadHistoryQuery.loadRange:decodeRows", + ), + ), + ); + return { + activities: activityRows.map(mapThreadActivityRow), + proposedPlans: proposedPlanRows.map(mapProposedPlanRow), + }; + }); + const getTurnPageByCursor = Effect.fn("ProjectionThreadHistoryQuery.getTurnPageByCursor")( + function* ( + input: + | { + readonly threadId: ThreadId; + readonly mode: "latest"; + readonly cursor: null; + readonly limit: number; + } + | { + readonly threadId: ThreadId; + readonly mode: "before" | "after" | "around"; + readonly cursor: OrchestrationThreadMessageCursor; + readonly limit: number; + }, + ) { + const [thread, rows, totalMessageCount] = yield* Effect.all([ + getActiveThread({ threadId: input.threadId }), + listTurnMessageRows({ + threadId: input.threadId, + mode: input.mode, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorMessageId: input.cursor?.messageId ?? null, + fetchLimit: input.limit, + }), + countMessageRows({ threadId: input.threadId }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getTurnPageByCursor:query", + "ProjectionThreadHistoryQuery.getTurnPageByCursor:decodeRows", + ), + ), + ); + if (Option.isNone(thread)) { + return Option.none(); + } + + const firstRow = rows[0]; + const lastRow = rows.at(-1); + const startIndex = + firstRow === undefined + ? input.cursor === null + ? totalMessageCount.count + : (yield* ( + input.mode === "after" + ? countMessageRowsThrough({ + threadId: input.threadId, + cursorCreatedAt: input.cursor.createdAt, + cursorMessageId: input.cursor.messageId, + }) + : countMessageRowsBefore({ + threadId: input.threadId, + cursorCreatedAt: input.cursor.createdAt, + cursorMessageId: input.cursor.messageId, + }) + ).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getTurnPageByCursor:countEmptyPage:query", + "ProjectionThreadHistoryQuery.getTurnPageByCursor:countEmptyPage:decodeRow", + ), + ), + )).count + : (yield* countMessageRowsBefore({ + threadId: input.threadId, + cursorCreatedAt: firstRow.createdAt, + cursorMessageId: firstRow.messageId, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getTurnPageByCursor:countBefore:query", + "ProjectionThreadHistoryQuery.getTurnPageByCursor:countBefore:decodeRow", + ), + ), + )).count; + const nextRow = + lastRow === undefined || input.mode === "latest" + ? undefined + : (yield* listMessageRowsAfter({ + threadId: input.threadId, + cursorCreatedAt: lastRow.createdAt, + cursorMessageId: lastRow.messageId, + fetchLimit: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getTurnPageByCursor:nextBoundary:query", + "ProjectionThreadHistoryQuery.getTurnPageByCursor:nextBoundary:decodeRow", + ), + ), + ))[0]; + const endIndex = startIndex + rows.length; + const historyRange = + firstRow === undefined + ? { activities: [], proposedPlans: [] } + : yield* loadRange({ + threadId: input.threadId, + startCreatedAt: firstRow.createdAt, + endCreatedAt: + input.mode === "before" ? input.cursor.createdAt : (nextRow?.createdAt ?? null), + }); + return Option.some({ + messages: rows.map(mapThreadMessageRow), + activities: historyRange.activities, + proposedPlans: historyRange.proposedPlans, + messageHistory: { + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessageCount.count, + startIndex, + endIndex, + totalMessages: totalMessageCount.count, + cursor: + firstRow === undefined + ? null + : { + createdAt: firstRow.createdAt, + messageId: firstRow.messageId, + }, + }, + }); + }, + ); + const getLatestTurnPage = (input: { readonly threadId: ThreadId; readonly limit: number }) => + getTurnPageByCursor({ ...input, mode: "latest", cursor: null }); + const getTurnPage = (input: { + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + readonly limit: number; + }) => getTurnPageByCursor({ ...input, mode: "before", cursor: input.before }); + const getTurnPageAfter = (input: { + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + readonly limit: number; + }) => getTurnPageByCursor({ ...input, mode: "after", cursor: input.after }); + const getTurnPageAround = Effect.fn("ProjectionThreadHistoryQuery.getTurnPageAround")( + function* (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly limit: number; + }) { + const target = yield* getMessageRowById({ + threadId: input.threadId, + messageId: input.messageId, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getTurnPageAround:targetQuery", + "ProjectionThreadHistoryQuery.getTurnPageAround:decodeTarget", + ), + ), + ); + if (Option.isNone(target)) { + return Option.none(); + } + return yield* getTurnPageByCursor({ + ...input, + mode: "around", + cursor: { + createdAt: target.value.createdAt, + messageId: target.value.messageId, + }, + }); + }, + ); + const getHistoryOutline = Effect.fn("ProjectionThreadHistoryQuery.getHistoryOutline")( + function* (threadId: ThreadId) { + const [thread, rows] = yield* Effect.all([ + getActiveThread({ threadId }), + listHistoryLandmarkRows({ threadId }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadHistoryQuery.getHistoryOutline:query", + "ProjectionThreadHistoryQuery.getHistoryOutline:decodeRows", + ), + ), + ); + if (Option.isNone(thread)) { + return Option.none(); + } + + return Option.some({ + totalUserMessages: rows[0]?.totalUserMessages ?? 0, + landmarks: rows.map((row) => ({ + messageId: row.messageId, + ordinal: row.ordinal, + messageIndex: row.messageIndex, + createdAt: row.createdAt, + preview: row.preview, + })), + }); + }, + ); + + return { + getLatestTurnPage, + getTurnPage, + getTurnPageAfter, + getTurnPageAround, + getHistoryOutline, + }; + }, +); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 64138fb7559..d7950134eaa 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -17,7 +17,11 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, + OrchestrationThreadMessageCursor, OrchestrationThreadShell, + MessageId, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -177,7 +181,30 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, + turnLimit?: number, ) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePage: (input: { + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + readonly turnLimit: number; + }) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePageAfter: (input: { + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + readonly turnLimit: number; + }) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePageAround: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly turnLimit: number; + }) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadHistoryOutline: ( + threadId: ThreadId, + ) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 659665e47b5..8b71c05421d 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -7,7 +7,10 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; -import { projectThreadDetailSnapshot } from "./ActivityPayloadProjection.ts"; +import { + projectThreadDetailSnapshot, + projectThreadHistoryPage, +} from "./ActivityPayloadProjection.ts"; import { normalizeDispatchCommand } from "./Normalizer.ts"; import { annotateEnvironmentRequest, @@ -61,7 +64,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(args.params.threadId) + .getThreadDetailSnapshot(args.params.threadId, args.query.turnLimit) .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), @@ -73,6 +76,96 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( return projectThreadDetailSnapshot(snapshot.value); }), ) + .handle( + "threadMessages", + Effect.fn("environment.orchestration.threadMessages")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const page = yield* projectionSnapshotQuery + .getThreadMessagePage({ + threadId: args.params.threadId, + before: { + createdAt: args.query.beforeCreatedAt, + messageId: args.query.beforeMessageId, + }, + turnLimit: args.query.turnLimit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return projectThreadHistoryPage(page.value); + }), + ) + .handle( + "threadMessagesAfter", + Effect.fn("environment.orchestration.threadMessagesAfter")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const page = yield* projectionSnapshotQuery + .getThreadMessagePageAfter({ + threadId: args.params.threadId, + after: { + createdAt: args.query.afterCreatedAt, + messageId: args.query.afterMessageId, + }, + turnLimit: args.query.turnLimit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return projectThreadHistoryPage(page.value); + }), + ) + .handle( + "threadMessagesAround", + Effect.fn("environment.orchestration.threadMessagesAround")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const page = yield* projectionSnapshotQuery + .getThreadMessagePageAround({ + threadId: args.params.threadId, + messageId: args.params.messageId, + turnLimit: args.query.turnLimit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return projectThreadHistoryPage(page.value); + }), + ) + .handle( + "threadHistoryOutline", + Effect.fn("environment.orchestration.threadHistoryOutline")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + const outline = yield* projectionSnapshotQuery + .getThreadHistoryOutline(args.params.threadId) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(outline)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return outline.value; + }), + ) .handle( "dispatch", Effect.fn("environment.orchestration.dispatch")(function* (args) { diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index cf8defbd61f..65223e25a3c 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -44,6 +44,10 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }); diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1646bdbc8f0..744c705e97d 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -213,6 +213,10 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0c41f742682..f3f2073394e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5897,6 +5897,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("forwards the requested socket thread turn limit to the snapshot query", () => + Effect.gen(function* () { + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + let requestedTurnLimit: number | undefined; + yield* buildAppUnderTest({ + layers: { + projectionSnapshotQuery: { + getThreadDetailSnapshot: (_threadId, turnLimit) => { + requestedTurnLimit = turnLimit; + return Effect.succeed(Option.some({ snapshotSequence: 1, thread })); + }, + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: defaultThreadId, + turnLimit: 10, + }).pipe(Stream.take(1), Stream.runDrain), + ), + ); + + assert.equal(requestedTurnLimit, 10); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("buffers shell events published while the fallback snapshot loads", () => Effect.gen(function* () { const liveEvents = yield* PubSub.unbounded(); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e3f7e482b2e..54da228320c 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -97,6 +97,10 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(AnalyticsService.AnalyticsService, { @@ -161,6 +165,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -206,6 +214,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { @@ -257,6 +269,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), searchThreads: () => Effect.succeed({ matches: [] }), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d07e79c7f89..02bd1c58065 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -970,16 +970,21 @@ const makeWsRpcLayer = ( ); }; - const loadThreadSnapshot = Effect.fn("Ws.loadThreadSnapshot")(function* (threadId: ThreadId) { - const snapshot = yield* projectionSnapshotQuery.getThreadDetailSnapshot(threadId).pipe( - Effect.mapError( - (cause) => - new OrchestrationGetSnapshotError({ - message: `Failed to load thread ${threadId}`, - cause, - }), - ), - ); + const loadThreadSnapshot = Effect.fn("Ws.loadThreadSnapshot")(function* ( + threadId: ThreadId, + turnLimit?: number, + ) { + const snapshot = yield* projectionSnapshotQuery + .getThreadDetailSnapshot(threadId, turnLimit) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: `Failed to load thread ${threadId}`, + cause, + }), + ), + ); return Option.map(snapshot, projectThreadDetailSnapshot); }); @@ -1024,7 +1029,11 @@ const makeWsRpcLayer = ( const headSequence = yield* orchestrationEngine.latestSequence; const replayGap = headSequence - afterSequence; if (replayGap < 0 || replayGap > input.maxReplayGap) { - const snapshot = yield* loadThreadSnapshot(input.request.threadId); + // Keep the requested live-tail bound when replay falls back to a fresh snapshot. + const snapshot = yield* loadThreadSnapshot( + input.request.threadId, + input.request.turnLimit, + ); if (Option.isNone(snapshot)) { return Stream.concat( Stream.make({ kind: "not-found" as const }), @@ -1073,7 +1082,7 @@ const makeWsRpcLayer = ( return Stream.concat(catchUpStream, synchronizedThenLive); } - const snapshot = yield* loadThreadSnapshot(input.request.threadId); + const snapshot = yield* loadThreadSnapshot(input.request.threadId, input.request.turnLimit); if (Option.isNone(snapshot)) { return yield* new OrchestrationGetSnapshotError({ message: `Thread ${input.request.threadId} was not found`, @@ -1119,6 +1128,7 @@ const makeWsRpcLayer = ( settings, shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, + threadMessagePagination: true, }; }); diff --git a/apps/web/package.json b/apps/web/package.json index 4d49953431f..c32745b23c4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.3", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index d86fe39a77f..511ef6b3f93 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -10,6 +10,7 @@ import { WrapTextIcon, } from "lucide-react"; import type { ScopedThreadRef, ServerProviderSkill } from "@t3tools/contracts"; +import { LRUCache } from "@t3tools/shared/LRUCache"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -56,7 +57,6 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; -import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5543c5dadc8..f70c055c144 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -66,6 +66,7 @@ import { type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; @@ -212,7 +213,7 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { threadEnvironment } from "../state/threads"; +import { environmentThreads, threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; import { @@ -1152,6 +1153,21 @@ function ChatViewContent(props: ChatViewProps) { }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const requestThreadGoal = useAtomCommand(threadEnvironment.requestGoal, { reportFailure: false }); + const loadMessagesAround = useAtomCommand(environmentThreads.loadMessagesAround, { + reportFailure: false, + }); + const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { + reportFailure: false, + }); + const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + reportFailure: false, + }); + const cancelMessagesAround = useAtomCommand(environmentThreads.cancelMessagesAround, { + reportFailure: false, + }); + const showLatestMessages = useAtomCommand(environmentThreads.showLatestMessages, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1184,6 +1200,19 @@ function ChatViewContent(props: ChatViewProps) { ); const routeServerThreadShell = useThreadShell(routeKind === "server" ? routeThreadRef : null); const serverThread = useThread(routeThreadRef, { waitForShell: draftThread !== null }); + const subscribeToThreadHistory = routeKind === "server" || serverThread !== null; + const environmentThreadState = useEnvironmentThread( + subscribeToThreadHistory ? environmentId : null, + subscribeToThreadHistory ? threadId : null, + ); + const liveServerThread = Option.getOrNull(environmentThreadState.liveData); + const [historyTarget, setHistoryTarget] = useState<{ + readonly threadKey: string; + readonly messageId: MessageId | null; + }>({ threadKey: routeThreadKey, messageId: null }); + if (historyTarget.threadKey !== routeThreadKey) { + setHistoryTarget({ threadKey: routeThreadKey, messageId: null }); + } const loadingServerThread = useMemo( () => threadDetailLoading && routeServerThreadShell @@ -1251,6 +1280,7 @@ function ChatViewContent(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [latestMessagesRequest, setLatestMessagesRequest] = useState(0); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -1426,6 +1456,48 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = activeServerThread !== null; const activeThread = activeServerThread ?? localDraftThread; + const operationalThread = isServerThread ? liveServerThread : activeThread; + const handleSelectHistoryMessage = useCallback( + (messageId: MessageId) => { + if (environmentThreadState.history.kind !== "ready") { + return; + } + setHistoryTarget({ threadKey: routeThreadKey, messageId }); + void loadMessagesAround({ + environmentId, + input: { threadId, messageId }, + }).then((result) => { + if (result._tag === "Success" && result.value) { + return; + } + setHistoryTarget((current) => + current.threadKey === routeThreadKey && current.messageId === messageId + ? { ...current, messageId: null } + : current, + ); + }); + }, + [environmentId, environmentThreadState.history, loadMessagesAround, routeThreadKey, threadId], + ); + const handleHistoryTargetReady = useCallback(() => { + setHistoryTarget((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); + }, []); + const handleLoadPreviousMessages = useCallback(async () => { + const result = await loadPreviousMessages({ + environmentId, + input: { threadId }, + }); + return result._tag === "Success" && result.value; + }, [environmentId, loadPreviousMessages, threadId]); + const handleLoadNextMessages = useCallback(async () => { + const result = await loadNextMessages({ + environmentId, + input: { threadId }, + }); + return result._tag === "Success" && result.value; + }, [environmentId, loadNextMessages, threadId]); const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; @@ -1983,14 +2055,15 @@ function ChatViewContent(props: ChatViewProps) { const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; + const operationalActivities = operationalThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); const pendingApprovals = useMemo( - () => derivePendingApprovals(threadActivities), - [threadActivities], + () => derivePendingApprovals(operationalActivities), + [operationalActivities], ); const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), - [threadActivities], + () => derivePendingUserInputs(operationalActivities), + [operationalActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; const activePendingDraftAnswers = useMemo( @@ -2030,10 +2103,10 @@ function ChatViewContent(props: ChatViewProps) { return null; } return findLatestProposedPlan( - activeThread?.proposedPlans ?? [], + operationalThread?.proposedPlans ?? [], activeLatestTurn?.turnId ?? null, ); - }, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]); + }, [activeLatestTurn?.turnId, latestTurnSettled, operationalThread?.proposedPlans]); const sidebarProposedPlan = useMemo( () => findSidebarProposedPlan({ @@ -2045,8 +2118,8 @@ function ChatViewContent(props: ChatViewProps) { [activeLatestTurn, activeThread?.id, latestTurnSettled, threadPlanCatalog], ); const activePlan = useMemo( - () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), - [activeLatestTurn?.turnId, threadActivities], + () => deriveActivePlanState(operationalActivities, activeLatestTurn?.turnId ?? undefined), + [activeLatestTurn?.turnId, operationalActivities], ); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" @@ -3457,6 +3530,24 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } }, []); + const handleTimelineManualNavigation = useCallback( + (cancelHistoryLoad: boolean) => { + cancelTimelineLiveFollowForUserNavigation(); + if (cancelHistoryLoad && environmentThreadState.history.kind === "ready") { + void cancelMessagesAround({ + environmentId, + input: { threadId }, + }); + } + }, + [ + cancelMessagesAround, + cancelTimelineLiveFollowForUserNavigation, + environmentId, + environmentThreadState.history.kind, + threadId, + ], + ); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, ); @@ -3514,16 +3605,54 @@ function ChatViewContent(props: ChatViewProps) { // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. - const scrollToEnd = useCallback((animated = false) => { - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - void legendListRef.current?.scrollToEnd?.({ animated }); - }, []); + const scrollToEnd = useCallback( + (animated = false) => { + const userScrollGeneration = anchorUserScrollGenerationRef.current; + isAtEndRef.current = true; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = userScrollGeneration; + pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + pendingAnchorScrollRestoreRef.current = null; + if (anchorScrollRestoreFrameRef.current !== null) { + cancelAnimationFrame(anchorScrollRestoreFrameRef.current); + anchorScrollRestoreFrameRef.current = null; + } + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + + if (activeThread?.messageHistory === undefined) { + void legendListRef.current?.scrollToEnd?.({ animated }); + return; + } + + setLatestMessagesRequest((request) => request + 1); + if (!activeThread.messageHistory.hasMoreAfter) { + return; + } + void showLatestMessages({ + environmentId, + input: { threadId }, + }).then((result) => { + if ( + result._tag !== "Success" || + !result.value || + liveFollowUserScrollGenerationRef.current !== userScrollGeneration + ) { + if (liveFollowUserScrollGenerationRef.current === userScrollGeneration) { + isAtEndRef.current = false; + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + setShowScrollToBottom(true); + } + return; + } + }); + }, + [activeThread?.messageHistory, environmentId, showLatestMessages, threadId], + ); useEffect(() => { let removeListeners: (() => void) | null = null; const frame = requestAnimationFrame(() => { @@ -4786,6 +4915,12 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); + if (activeThread.messageHistory?.hasMoreAfter === true) { + await showLatestMessages({ + environmentId, + input: { threadId: threadIdForSend }, + }); + } // Sending always returns to the live edge. The new row becomes the // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. @@ -5936,9 +6071,31 @@ function ChatViewContent(props: ChatViewProps) { onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onManualNavigation={handleTimelineManualNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} + {...(activeThread.messageHistory === undefined + ? {} + : { messageHistory: activeThread.messageHistory })} + isLoadingPreviousMessages={ + environmentThreadState.history.kind === "ready" && + environmentThreadState.history.loading === "before" + } + isLoadingNextMessages={ + environmentThreadState.history.kind === "ready" && + environmentThreadState.history.loading === "after" + } + latestMessagesRequest={latestMessagesRequest} + historyOutline={ + environmentThreadState.history.kind === "ready" + ? environmentThreadState.history.outline + : null + } + historyTargetMessageId={historyTarget.messageId} + onSelectHistoryMessage={handleSelectHistoryMessage} + onHistoryTargetReady={handleHistoryTargetReady} + onLoadPreviousMessages={handleLoadPreviousMessages} + onLoadNextMessages={handleLoadNextMessages} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -5998,8 +6155,8 @@ function ChatViewContent(props: ChatViewProps) { ) : ( )} - {threadSyncPhase && !activeEnvironmentUnavailable ? ( - + {threadDetailLoading && !activeEnvironmentUnavailable ? ( + ) : null}
s.sidebarProjectSortOrder); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); + const progressiveThreadHistoryEnabled = useClientSettings( + (s) => s.progressiveThreadHistoryEnabled, + ); const updateSettings = useUpdateClientSettings(); const handleNewThread = useNewThreadHandler(); const { archiveThread, deleteThread } = useThreadActions(); @@ -3618,8 +3621,9 @@ export default function Sidebar() { : EMPTY_THREAD_JUMP_LABELS; const orderedSidebarThreadKeys = visibleSidebarThreadKeys; const prewarmedSidebarThreadKeys = useMemo( - () => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), - [visibleSidebarThreadKeys], + () => + progressiveThreadHistoryEnabled ? [] : getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys), + [progressiveThreadHistoryEnabled, visibleSidebarThreadKeys], ); const prewarmedSidebarThreadRefs = useMemo( () => diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 493522d4f49..1d30d452188 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,8 @@ import { type EnvironmentId, type MessageId, + type OrchestrationThreadHistoryOutline, + type OrchestrationThreadMessageHistory, type ScopedThreadRef, type ServerProviderSkill, type TurnId, @@ -14,11 +16,13 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, + type PointerEvent, type ReactNode, } from "react"; import { flushSync } from "react-dom"; @@ -59,6 +63,7 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; +import { Skeleton } from "../ui/skeleton"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { GoalTimelineRow } from "./GoalTimelineRow"; @@ -70,7 +75,6 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, - resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, @@ -108,6 +112,10 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { + useProgressiveTimelineHistory, + type TimelineHistoryNavigationTarget, +} from "./useProgressiveTimelineHistory"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -147,10 +155,28 @@ interface TimelineRowActivityState { const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); -const TIMELINE_LIST_HEADER =
; -const TIMELINE_LIST_FADE_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const TIMELINE_SCRUB_THROTTLE_MS = 180; +const TIMELINE_HISTORY_DRAW_DISTANCE = 1_200; +const TIMELINE_HISTORY_PAGE_THRESHOLD = 0.5; +const TIMELINE_MAINTAIN_VISIBLE_CONTENT_POSITION = { + data: true, + size: false, +}; +const TIMELINE_HISTORY_LOADING_BEFORE_ROW = { + id: "history-loading-before", + kind: "history-loading", +} as const; +const TIMELINE_HISTORY_LOADING_AFTER_ROW = { + id: "history-loading-after", + kind: "history-loading", +} as const; + +type MessagesTimelineListRow = + | MessagesTimelineRow + | typeof TIMELINE_HISTORY_LOADING_BEFORE_ROW + | typeof TIMELINE_HISTORY_LOADING_AFTER_ROW; // --------------------------------------------------------------------------- // Props (public API) @@ -182,9 +208,19 @@ interface MessagesTimelineProps { onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; - onManualNavigation: () => void; + onManualNavigation: (cancelHistoryLoad: boolean) => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + messageHistory?: OrchestrationThreadMessageHistory; + isLoadingPreviousMessages?: boolean; + isLoadingNextMessages?: boolean; + latestMessagesRequest?: number; + historyOutline?: OrchestrationThreadHistoryOutline | null; + historyTargetMessageId?: MessageId | null; + onSelectHistoryMessage?: (messageId: MessageId) => void; + onHistoryTargetReady?: () => void; + onLoadPreviousMessages?: () => Promise; + onLoadNextMessages?: () => Promise; } // --------------------------------------------------------------------------- @@ -220,10 +256,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + messageHistory, + isLoadingPreviousMessages = false, + isLoadingNextMessages = false, + latestMessagesRequest = 0, + historyOutline = null, + historyTargetMessageId = null, + onSelectHistoryMessage, + onHistoryTargetReady, + onLoadPreviousMessages, + onLoadNextMessages, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); + const isDraggingScrollbarRef = useRef(false); + const historyMaintainVisibleContentPosition = useMemo( + () => ({ + data: true, + size: true, + shouldRestorePosition: (item: MessagesTimelineListRow) => + !isDraggingScrollbarRef.current && item.kind !== "history-loading", + }), + [], + ); const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -325,12 +381,65 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); - const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const listRows = useMemo>( + () => [ + ...(isLoadingPreviousMessages ? [TIMELINE_HISTORY_LOADING_BEFORE_ROW] : []), + ...rows, + ...(isLoadingNextMessages ? [TIMELINE_HISTORY_LOADING_AFTER_ROW] : []), + ], + [isLoadingNextMessages, isLoadingPreviousMessages, rows], + ); + const minimapItems = useMemo( + () => deriveTimelineMinimapItems(rows, historyOutline), + [historyOutline, rows], + ); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const historyHeaderSize = topFadeEnabled ? 48 : 16; + const progressiveHistory = useProgressiveTimelineHistory({ + historyTargetMessageId, + isLoadingNextMessages, + isLoadingPreviousMessages, + latestMessagesRequest, + listRef, + messageHistory, + minimapStripMap, + onHistoryTargetReady, + onIsAtEndChange, + onLoadNextMessages, + onLoadPreviousMessages, + onManualNavigation, + onSelectHistoryMessage, + routeThreadKey, + rowIndexOffset: isLoadingPreviousMessages ? 1 : 0, + rows, + }); + const handlePointerDownCapture = useCallback( + (event: PointerEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if (scrollNode !== null && scrollNode !== undefined) { + const rect = scrollNode.getBoundingClientRect(); + isDraggingScrollbarRef.current = + event.clientX >= rect.right - (scrollNode.offsetWidth - scrollNode.clientWidth); + } + progressiveHistory.beginUserNavigation(); + }, + [listRef, progressiveHistory.beginUserNavigation], + ); + useEffect(() => { + const finishPointerNavigation = () => { + isDraggingScrollbarRef.current = false; + }; + window.addEventListener("pointerup", finishPointerNavigation, true); + window.addEventListener("pointercancel", finishPointerNavigation, true); + return () => { + window.removeEventListener("pointerup", finishPointerNavigation, true); + window.removeEventListener("pointercancel", finishPointerNavigation, true); + }; + }, []); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -356,42 +465,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); - const handleScroll = useCallback(() => { - const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); - if (isAtEnd !== undefined) { - onIsAtEndChange(isAtEnd); - } - if (!state || minimapItems.length === 0) { - return; - } - - const scrollTop = state.scroll ?? 0; - const scrollBottom = scrollTop + (state.scrollLength ?? 0); - - for (const item of minimapItems) { - const strip = minimapStripMap.get(item.id); - if (!strip) { - continue; - } - - const rowTop = resolveTimelineRowTop(state, item.rowIndex); - const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); - const inView = - rowTop !== null && - rowTop < scrollBottom && - rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; - - strip.dataset.inView = inView ? "true" : "false"; - } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); - - useEffect(() => { - const frame = requestAnimationFrame(handleScroll); - return () => cancelAnimationFrame(frame); - }, [handleScroll, rows.length]); - - useEffect(() => { + useLayoutEffect(() => { if (!timelineViewportElement) { return; } @@ -405,13 +479,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ setMinimapHitStripWidth(resolveTimelineMinimapHitStripWidth(viewportWidth)); }; - const frame = requestAnimationFrame(measure); + measure(); const observer = new ResizeObserver(measure); observer.observe(timelineViewportElement); return () => { - cancelAnimationFrame(frame); observer.disconnect(); }; }, [timelineViewportElement, rows.length]); @@ -460,11 +533,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // Stable renderItem — no closure deps. Row components read shared state // from TimelineRowCtx, which propagates through LegendList's memo. const renderItem = useCallback( - ({ item }: { item: MessagesTimelineRow }) => ( -
- -
- ), + ({ item }: { item: MessagesTimelineListRow }) => + item.kind === "history-loading" ? ( + + ) : ( +
+ +
+ ), [], ); @@ -484,19 +560,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
- +
+ ref={listRef} - data={rows} + data={listRows} + {...(messageHistory === undefined + ? {} + : { drawDistance: TIMELINE_HISTORY_DRAW_DISTANCE })} keyExtractor={keyExtractor} getItemType={getItemType} + getFixedItemSize={getFixedItemSize} renderItem={renderItem} estimatedItemSize={90} + estimatedHeaderSize={historyHeaderSize} initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || messageHistory !== undefined ? false : { animated: false, @@ -507,16 +593,25 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, } } - maintainVisibleContentPosition={{ - data: true, - size: false, - }} - onScroll={handleScroll} + maintainVisibleContentPosition={ + messageHistory === undefined + ? TIMELINE_MAINTAIN_VISIBLE_CONTENT_POSITION + : historyMaintainVisibleContentPosition + } + {...(messageHistory === undefined + ? {} + : { + onEndReached: progressiveHistory.loadNextPage, + onEndReachedThreshold: TIMELINE_HISTORY_PAGE_THRESHOLD, + onStartReached: progressiveHistory.loadPreviousPage, + onStartReachedThreshold: TIMELINE_HISTORY_PAGE_THRESHOLD, + })} + onScroll={progressiveHistory.handleScroll} className={cn( "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5", topFadeEnabled && "chat-timeline-scroll-fade", )} - ListHeaderComponent={topFadeEnabled ? TIMELINE_LIST_FADE_HEADER : TIMELINE_LIST_HEADER} + ListHeaderComponent={
} ListFooterComponent={TIMELINE_LIST_FOOTER} /> { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} + onSelect={progressiveHistory.selectHistoryTarget} />
@@ -540,32 +628,74 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); -function keyExtractor(item: MessagesTimelineRow) { +function TimelineHistorySkeletons() { + return ( + + ); +} + +function keyExtractor(item: MessagesTimelineListRow) { return item.id; } -function getItemType(item: MessagesTimelineRow) { +function getItemType(item: MessagesTimelineListRow) { + if (item.kind === "history-loading") { + return item.kind; + } return item.kind === "message" ? `message:${item.message.role}` : item.kind; } -interface TimelineMinimapItem { - readonly id: string; - readonly rowIndex: number; - readonly userText: string | null; - readonly assistantText: string | null; +function getFixedItemSize(item: MessagesTimelineListRow) { + return item.kind === "history-loading" ? 192 : undefined; } -interface TimelinePositionState { - readonly contentLength?: number; - readonly scroll?: number; - readonly scrollLength?: number; - readonly positionAtIndex?: (index: number) => number | undefined; - readonly sizeAtIndex?: (index: number) => number | undefined; +interface TimelineMinimapItem extends TimelineHistoryNavigationTarget { + readonly userText: string | null; + readonly assistantText: string | null; } function deriveTimelineMinimapItems( rows: ReadonlyArray, + outline: OrchestrationThreadHistoryOutline | null, ): TimelineMinimapItem[] { + if (outline !== null) { + const rowIndexByMessageId = new Map(); + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind === "message" && row.message.role === "user") { + rowIndexByMessageId.set(row.message.id, index); + } + } + return outline.landmarks.map((landmark) => { + const rowIndex = rowIndexByMessageId.get(landmark.messageId) ?? null; + return { + id: landmark.messageId, + messageIndex: landmark.messageIndex ?? null, + rowIndex, + userText: compactMinimapPreview(landmark.preview), + assistantText: + rowIndex === null + ? null + : compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, rowIndex)), + }; + }); + } const items: TimelineMinimapItem[] = []; for (let index = 0; index < rows.length; index += 1) { const row = rows[index]; @@ -574,7 +704,8 @@ function deriveTimelineMinimapItems( } items.push({ - id: row.id, + id: row.message.id, + messageIndex: null, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), @@ -608,16 +739,6 @@ function compactMinimapPreview(text: string | null | undefined) { return compact.length > 0 ? compact : null; } -function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { - const top = state.positionAtIndex?.(rowIndex); - return typeof top === "number" && Number.isFinite(top) ? top : null; -} - -function resolveTimelineRowHeight(state: TimelinePositionState, rowIndex: number) { - const height = state.sizeAtIndex?.(rowIndex); - return typeof height === "number" && Number.isFinite(height) ? height : null; -} - function timelineMinimapEventTargetsPreview(target: EventTarget): boolean { return target instanceof Element && target.closest("[data-minimap-preview]") !== null; } @@ -638,6 +759,11 @@ function TimelineMinimap({ onSelect: (item: TimelineMinimapItem) => void; }) { const [activeIndex, setActiveIndex] = useState(null); + const draggingRef = useRef(false); + const lastSelectionAtRef = useRef(0); + const lastRequestedItemIdRef = useRef(null); + const pendingSelectionRef = useRef(null); + const selectionTimeoutRef = useRef(null); const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; @@ -656,7 +782,7 @@ function TimelineMinimap({ : "-50%"; const resolveActiveIndexFromPointer = useCallback( - (event: MouseEvent) => { + (event: MouseEvent | PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); return resolveTimelineMinimapIndexFromPointer({ itemCount: items.length, @@ -676,6 +802,46 @@ function TimelineMinimap({ [resolveActiveIndexFromPointer], ); + const selectScrubItem = useCallback( + (item: TimelineMinimapItem, flush: boolean) => { + pendingSelectionRef.current = item; + if (flush && lastRequestedItemIdRef.current === item.id) { + if (selectionTimeoutRef.current !== null) { + window.clearTimeout(selectionTimeoutRef.current); + selectionTimeoutRef.current = null; + } + pendingSelectionRef.current = null; + return; + } + const remaining = lastSelectionAtRef.current + TIMELINE_SCRUB_THROTTLE_MS - performance.now(); + if (flush || remaining <= 0) { + if (selectionTimeoutRef.current !== null) { + window.clearTimeout(selectionTimeoutRef.current); + selectionTimeoutRef.current = null; + } + pendingSelectionRef.current = null; + lastRequestedItemIdRef.current = item.id; + lastSelectionAtRef.current = performance.now(); + onSelect(item); + return; + } + if (selectionTimeoutRef.current !== null) { + return; + } + selectionTimeoutRef.current = window.setTimeout(() => { + selectionTimeoutRef.current = null; + const pendingItem = pendingSelectionRef.current; + pendingSelectionRef.current = null; + if (pendingItem !== null) { + lastRequestedItemIdRef.current = pendingItem.id; + lastSelectionAtRef.current = performance.now(); + onSelect(pendingItem); + } + }, remaining); + }, + [onSelect], + ); + const moveActiveIndex = useCallback( (delta: number) => { setActiveIndex((current) => { @@ -686,6 +852,15 @@ function TimelineMinimap({ [items.length], ); + useEffect( + () => () => { + if (selectionTimeoutRef.current !== null) { + window.clearTimeout(selectionTimeoutRef.current); + } + }, + [], + ); + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { return null; } @@ -713,17 +888,10 @@ function TimelineMinimap({ // the centered content column; with no usable gutter it goes inert. hitStripWidth > 0 ? "pointer-events-auto" : "pointer-events-none", )} - onBlur={() => setActiveIndex(null)} - onClick={(event) => { - if (timelineMinimapEventTargetsPreview(event.target)) { - return; + onBlur={() => { + if (!draggingRef.current) { + setActiveIndex(null); } - const nextIndex = resolveActiveIndexFromPointer(event); - const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); - if (nextItem) { - onSelect(nextItem); - } - event.currentTarget.blur(); }} onFocus={() => setActiveIndex((current) => current ?? 0)} onKeyDown={(event) => { @@ -746,13 +914,60 @@ function TimelineMinimap({ } } }} - onMouseLeave={() => setActiveIndex(null)} + onMouseLeave={() => { + if (!draggingRef.current) { + setActiveIndex(null); + } + }} onMouseMove={updateActiveIndexFromPointer} - onMouseDown={(event) => { + onPointerCancel={(event) => { + draggingRef.current = false; + pendingSelectionRef.current = null; + setActiveIndex(null); + if (selectionTimeoutRef.current !== null) { + window.clearTimeout(selectionTimeoutRef.current); + selectionTimeoutRef.current = null; + } + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerDown={(event) => { if (timelineMinimapEventTargetsPreview(event.target)) { return; } event.preventDefault(); + draggingRef.current = true; + lastRequestedItemIdRef.current = null; + event.currentTarget.setPointerCapture(event.pointerId); + const nextIndex = resolveActiveIndexFromPointer(event); + setActiveIndex(nextIndex); + const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); + if (nextItem !== null) { + selectScrubItem(nextItem, true); + } + }} + onPointerMove={(event) => { + if (!draggingRef.current) { + return; + } + const nextIndex = resolveActiveIndexFromPointer(event); + setActiveIndex(nextIndex); + const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); + if (nextItem !== null) { + selectScrubItem(nextItem, false); + } + }} + onPointerUp={(event) => { + if (!draggingRef.current) { + return; + } + draggingRef.current = false; + const nextIndex = resolveActiveIndexFromPointer(event); + const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); + if (nextItem !== null) { + selectScrubItem(nextItem, true); + } + setActiveIndex(null); + event.currentTarget.releasePointerCapture(event.pointerId); }} style={{ height: resolveTimelineMinimapHeightStyle(items.length), diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts new file mode 100644 index 00000000000..3b508ca8f44 --- /dev/null +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -0,0 +1,316 @@ +import { type MessageId, type OrchestrationThreadMessageHistory } from "@t3tools/contracts"; +import { type LegendListRef } from "@legendapp/list/react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, type RefObject } from "react"; +import { resolveTimelineIsAtEnd, type MessagesTimelineRow } from "./MessagesTimeline.logic"; + +export interface TimelineHistoryNavigationTarget { + readonly id: MessageId; + readonly messageIndex: number | null; + readonly rowIndex: number | null; +} + +type HistoryPageDirection = "before" | "after"; + +interface HistoryPageRequest { + readonly direction: HistoryPageDirection; +} + +interface HistoryPositioningTarget { + readonly id: MessageId; +} + +interface UseProgressiveTimelineHistoryInput { + readonly historyTargetMessageId: MessageId | null; + readonly isLoadingNextMessages: boolean; + readonly isLoadingPreviousMessages: boolean; + readonly latestMessagesRequest: number; + readonly listRef: RefObject; + readonly messageHistory: OrchestrationThreadMessageHistory | undefined; + readonly minimapStripMap: Map; + readonly onHistoryTargetReady: (() => void) | undefined; + readonly onIsAtEndChange: (isAtEnd: boolean) => void; + readonly onLoadNextMessages: (() => Promise) | undefined; + readonly onLoadPreviousMessages: (() => Promise) | undefined; + readonly onManualNavigation: (cancelHistoryLoad: boolean) => void; + readonly onSelectHistoryMessage: ((messageId: MessageId) => void) | undefined; + readonly routeThreadKey: string; + readonly rowIndexOffset: number; + readonly rows: ReadonlyArray; +} + +export function useProgressiveTimelineHistory({ + historyTargetMessageId, + isLoadingNextMessages, + isLoadingPreviousMessages, + latestMessagesRequest, + listRef, + messageHistory, + minimapStripMap, + onHistoryTargetReady, + onIsAtEndChange, + onLoadNextMessages, + onLoadPreviousMessages, + onManualNavigation, + onSelectHistoryMessage, + routeThreadKey, + rowIndexOffset, + rows, +}: UseProgressiveTimelineHistoryInput) { + const pageRequestRef = useRef(null); + const initializedThreadRef = useRef(null); + const handledLatestMessagesRequestRef = useRef(latestMessagesRequest); + const latestPositionPendingRef = useRef(false); + const positioningTargetRef = useRef(null); + const minimapFrameRef = useRef(null); + const visibleMinimapIdsRef = useRef>(new Set()); + + const rowIndexByMessageId = useMemo(() => { + const indexes = new Map(); + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind === "message") { + indexes.set(row.message.id, index + rowIndexOffset); + } + } + return indexes; + }, [rowIndexOffset, rows]); + + const requestPage = useCallback( + (direction: HistoryPageDirection) => { + if (messageHistory === undefined || pageRequestRef.current !== null) { + return; + } + const isBefore = direction === "before"; + if ( + (isBefore && (!messageHistory.hasMoreBefore || isLoadingPreviousMessages)) || + (!isBefore && (!messageHistory.hasMoreAfter || isLoadingNextMessages)) + ) { + return; + } + const load = isBefore ? onLoadPreviousMessages : onLoadNextMessages; + if (load === undefined) { + return; + } + + const request = { direction }; + pageRequestRef.current = request; + void load().finally(() => { + if (pageRequestRef.current === request) { + pageRequestRef.current = null; + } + }); + }, + [ + isLoadingNextMessages, + isLoadingPreviousMessages, + messageHistory, + onLoadNextMessages, + onLoadPreviousMessages, + ], + ); + + const loadPreviousPage = useCallback(() => { + requestPage("before"); + }, [requestPage]); + + const loadNextPage = useCallback(() => { + requestPage("after"); + }, [requestPage]); + + const updateMinimap = useCallback(() => { + const scrollNode = listRef.current?.getScrollableNode(); + if (scrollNode === null || scrollNode === undefined) { + return; + } + const viewport = scrollNode.getBoundingClientRect(); + const visibleMessageIds = new Set(); + for (const element of scrollNode.querySelectorAll("[data-message-id]")) { + const rect = element.getBoundingClientRect(); + const messageId = element.dataset.messageId; + if (messageId !== undefined && rect.bottom > viewport.top && rect.top < viewport.bottom) { + visibleMessageIds.add(messageId); + } + } + for (const messageId of visibleMinimapIdsRef.current) { + if (!visibleMessageIds.has(messageId)) { + const strip = minimapStripMap.get(messageId); + if (strip !== undefined) { + strip.dataset.inView = "false"; + } + } + } + for (const messageId of visibleMessageIds) { + const strip = minimapStripMap.get(messageId); + if (strip !== undefined) { + strip.dataset.inView = "true"; + } + } + visibleMinimapIdsRef.current = visibleMessageIds; + }, [listRef, minimapStripMap]); + + const scheduleMinimapUpdate = useCallback(() => { + if (minimapFrameRef.current !== null) { + return; + } + minimapFrameRef.current = requestAnimationFrame(() => { + minimapFrameRef.current = null; + updateMinimap(); + }); + }, [updateMinimap]); + + const positionHistoryTarget = useCallback( + (id: MessageId, rowIndex: number, animated: boolean, onReady?: () => void) => { + const list = listRef.current; + if (list === null) { + return; + } + const target = { id }; + positioningTargetRef.current = target; + void list + .scrollToIndex({ + index: rowIndex, + animated, + viewPosition: 0.5, + }) + .then(() => { + if (positioningTargetRef.current !== target) { + return; + } + positioningTargetRef.current = null; + scheduleMinimapUpdate(); + onReady?.(); + }); + }, + [listRef, scheduleMinimapUpdate], + ); + + const beginUserNavigation = useCallback(() => { + scheduleMinimapUpdate(); + if (positioningTargetRef.current !== null) { + positioningTargetRef.current = null; + } + if (historyTargetMessageId !== null) { + onHistoryTargetReady?.(); + } + onManualNavigation(true); + }, [historyTargetMessageId, onHistoryTargetReady, onManualNavigation, scheduleMinimapUpdate]); + + const handleScroll = useCallback(() => { + const localIsAtEnd = resolveTimelineIsAtEnd(listRef.current?.getState()); + if (localIsAtEnd !== undefined) { + onIsAtEndChange( + messageHistory === undefined ? localIsAtEnd : localIsAtEnd && !messageHistory.hasMoreAfter, + ); + } + scheduleMinimapUpdate(); + }, [listRef, messageHistory, onIsAtEndChange, scheduleMinimapUpdate]); + + const selectHistoryTarget = useCallback( + (item: TimelineHistoryNavigationTarget) => { + onManualNavigation(false); + const rowIndex = rowIndexByMessageId.get(item.id); + if (rowIndex !== undefined) { + positionHistoryTarget(item.id, rowIndex, true); + return; + } + onSelectHistoryMessage?.(item.id); + }, + [onManualNavigation, onSelectHistoryMessage, positionHistoryTarget, rowIndexByMessageId], + ); + + useLayoutEffect(() => { + updateMinimap(); + }, [rows, updateMinimap]); + + useLayoutEffect(() => { + if ( + historyTargetMessageId === null || + positioningTargetRef.current?.id === historyTargetMessageId + ) { + return; + } + const rowIndex = rowIndexByMessageId.get(historyTargetMessageId); + if (rowIndex === undefined) { + return; + } + positionHistoryTarget(historyTargetMessageId, rowIndex, false, onHistoryTargetReady); + }, [historyTargetMessageId, onHistoryTargetReady, positionHistoryTarget, rowIndexByMessageId]); + + useLayoutEffect(() => { + if (initializedThreadRef.current === routeThreadKey) { + return; + } + initializedThreadRef.current = routeThreadKey; + for (const messageId of visibleMinimapIdsRef.current) { + const strip = minimapStripMap.get(messageId); + if (strip !== undefined) { + strip.dataset.inView = "false"; + } + } + visibleMinimapIdsRef.current = new Set(); + pageRequestRef.current = null; + positioningTargetRef.current = null; + handledLatestMessagesRequestRef.current = latestMessagesRequest; + latestPositionPendingRef.current = false; + }, [latestMessagesRequest, minimapStripMap, routeThreadKey]); + + useLayoutEffect(() => { + if (handledLatestMessagesRequestRef.current === latestMessagesRequest) { + return; + } + handledLatestMessagesRequestRef.current = latestMessagesRequest; + latestPositionPendingRef.current = true; + pageRequestRef.current = null; + positioningTargetRef.current = null; + }, [latestMessagesRequest]); + + useLayoutEffect(() => { + if ( + messageHistory === undefined || + messageHistory.hasMoreAfter || + !latestPositionPendingRef.current + ) { + return; + } + latestPositionPendingRef.current = false; + void listRef.current?.scrollToEnd({ animated: false }).then(scheduleMinimapUpdate); + }, [listRef, messageHistory, rows, scheduleMinimapUpdate]); + + useEffect(() => { + const handleKeyboardNavigation = (event: KeyboardEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if ( + !["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown", " "].includes(event.key) || + (document.activeElement !== document.body && !scrollNode?.contains(document.activeElement)) + ) { + return; + } + beginUserNavigation(); + }; + window.addEventListener("keydown", handleKeyboardNavigation, true); + return () => { + window.removeEventListener("keydown", handleKeyboardNavigation, true); + }; + }, [beginUserNavigation, listRef]); + + useEffect( + () => () => { + if (minimapFrameRef.current !== null) { + cancelAnimationFrame(minimapFrameRef.current); + minimapFrameRef.current = null; + } + }, + [], + ); + + return useMemo( + () => ({ + beginUserNavigation, + handleScroll, + loadNextPage, + loadPreviousPage, + selectHistoryTarget, + }), + [beginUserNavigation, handleScroll, loadNextPage, loadPreviousPage, selectHistoryTarget], + ); +} diff --git a/apps/web/src/components/settings/BetaSettingsPanel.tsx b/apps/web/src/components/settings/BetaSettingsPanel.tsx index 5a1ac036077..3634738b404 100644 --- a/apps/web/src/components/settings/BetaSettingsPanel.tsx +++ b/apps/web/src/components/settings/BetaSettingsPanel.tsx @@ -1,12 +1,21 @@ import { useEffect, useState } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useClientSettings, useSidebarV2Enabled, useUpdateClientSettings, } from "../../hooks/useSettings"; +import { clearLocalThreadHistoryCache } from "../../state/threadHistoryCache"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; +import { toastManager } from "../ui/toast"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; const AUTO_SETTLE_MIN_DAYS = 1; @@ -56,10 +65,17 @@ function AutoSettleDaysInput({ export function BetaSettingsPanel() { const sidebarV2Enabled = useSidebarV2Enabled(); + const progressiveThreadHistoryEnabled = useClientSettings( + (settings) => settings.progressiveThreadHistoryEnabled, + ); const sidebarAutoSettleAfterDays = useClientSettings( (settings) => settings.sidebarAutoSettleAfterDays, ); const updateSettings = useUpdateClientSettings(); + const clearLocalCache = useAtomCommand(clearLocalThreadHistoryCache, { + reportFailure: false, + }); + const [isClearingLocalCache, setIsClearingLocalCache] = useState(false); return ( @@ -113,6 +129,50 @@ export function BetaSettingsPanel() { ) : null} ) : null} + + + + updateSettings({ progressiveThreadHistoryEnabled: Boolean(checked) }) + } + aria-label="Enable progressive thread history" + /> + + } + /> ); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 5da93e3f5c5..4061da06cbc 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -10,6 +10,7 @@ import { removeCatalogValue, removeConnectionFromCatalog, replaceCatalogValue, + ThreadHistoryCacheStore, } from "@t3tools/client-runtime/platform"; import { TokenStore } from "@t3tools/client-runtime/authorization"; import { @@ -33,11 +34,14 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { makeWebThreadHistoryCacheStore } from "./threadHistoryCache.ts"; + const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; +const THREAD_CACHE_KEY_NAMESPACE = "tarik02:bounded-v1"; const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; const CATALOG_KEY = "document"; @@ -101,6 +105,8 @@ function persistenceError( | "load-thread" | "save-thread" | "remove-thread" + | "load-thread-history" + | "save-thread-history" | "load-server-config" | "save-server-config" | "load-vcs-refs" @@ -227,7 +233,7 @@ function removeDatabaseValuesInRange(database: IDBDatabase, storeName: string, r } function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { - return `${environmentId}:${threadId}`; + return `${THREAD_CACHE_KEY_NAMESPACE}:${environmentId}:${threadId}`; } function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { @@ -365,6 +371,7 @@ export const connectionStorageLayer = Layer.effectContext( const database = yield* Effect.acquireRelease(openDatabase(), (database) => Effect.sync(() => database.close()), ); + const threadHistoryCacheStore = yield* makeWebThreadHistoryCacheStore(); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const targetStore = ConnectionTargetStore.of({ @@ -648,23 +655,32 @@ export const connectionStorageLayer = Layer.effectContext( THREAD_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + removeDatabaseValuesInRange( + database, + THREAD_STORE_NAME, + IDBKeyRange.bound( + `${THREAD_CACHE_KEY_NAMESPACE}:${environmentId}:`, + `${THREAD_CACHE_KEY_NAMESPACE}:${environmentId}:\uffff`, + ), + ), removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId), removeDatabaseValuesInRange( database, VCS_REFS_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + threadHistoryCacheStore.clear(environmentId), ], { concurrency: "unbounded", discard: true }, ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), }); - return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), + Context.add(ThreadHistoryCacheStore, threadHistoryCacheStore), ); }), ); diff --git a/apps/web/src/connection/threadHistoryCache.ts b/apps/web/src/connection/threadHistoryCache.ts new file mode 100644 index 00000000000..ba4d56a6a90 --- /dev/null +++ b/apps/web/src/connection/threadHistoryCache.ts @@ -0,0 +1,710 @@ +import { + ConnectionPersistenceError, + ThreadHistoryCacheStore, +} from "@t3tools/client-runtime/platform"; +import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; +import { + EnvironmentId, + NonNegativeInt, + OrchestrationMessage, + OrchestrationProposedPlan, + OrchestrationThreadActivity, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +const DATABASE_NAME = "t3code:tarik02-thread-history-cache"; +const DATABASE_VERSION = 2; +const LEGACY_STORE_NAME = "thread-history"; +const THREAD_STORE_NAME = "thread"; +const MESSAGE_STORE_NAME = "message"; +const ACTIVITY_STORE_NAME = "activity"; +const PLAN_STORE_NAME = "plan"; +const MESSAGE_ID_INDEX_NAME = "by-message-id"; +const ACTIVITY_POSITION_INDEX_NAME = "by-position"; +const PLAN_POSITION_INDEX_NAME = "by-position"; +const CACHE_SCHEMA_VERSION = 1; + +const StoredThreadHistoryRange = Schema.Struct({ + startIndex: NonNegativeInt, + endIndex: NonNegativeInt, +}); +const StoredThreadHistoryThread = Schema.Struct({ + schemaVersion: Schema.Literal(CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + totalMessages: NonNegativeInt, + ranges: Schema.Array(StoredThreadHistoryRange), + turnStarts: Schema.Array(NonNegativeInt).pipe(Schema.withDecodingDefault(Effect.succeed([]))), +}); +const StoredThreadHistoryMessage = Schema.Struct({ + schemaVersion: Schema.Literal(CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + index: NonNegativeInt, + messageId: OrchestrationMessage.fields.id, + message: OrchestrationMessage, +}); +const StoredThreadHistoryActivity = Schema.Struct({ + schemaVersion: Schema.Literal(CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + position: NonNegativeInt, + activityId: OrchestrationThreadActivity.fields.id, + activity: OrchestrationThreadActivity, +}); +const StoredThreadHistoryPlan = Schema.Struct({ + schemaVersion: Schema.Literal(CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + position: NonNegativeInt, + planId: OrchestrationProposedPlan.fields.id, + plan: OrchestrationProposedPlan, +}); + +const decodeStoredThreadHistoryThread = Schema.decodeUnknownEffect(StoredThreadHistoryThread); +const decodeStoredThreadHistoryMessages = Schema.decodeUnknownEffect( + Schema.Array(StoredThreadHistoryMessage), +); +const decodeStoredThreadHistoryActivities = Schema.decodeUnknownEffect( + Schema.Array(StoredThreadHistoryActivity), +); +const decodeStoredThreadHistoryPlans = Schema.decodeUnknownEffect( + Schema.Array(StoredThreadHistoryPlan), +); + +function transientError(operation: string, cause: unknown) { + return new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not ${operation} the thread history cache: ${String(cause)}`, + }); +} + +function persistenceError( + operation: + | "load-thread-history" + | "save-thread-history" + | "remove-thread" + | "clear-thread-history" + | "clear-environment", + cause: unknown, +) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +const openDatabase = Effect.fn("web.threadHistoryCache.openDatabase")(function* () { + return yield* Effect.callback((resume) => { + if (globalThis.indexedDB === undefined) { + resume(Effect.fail(transientError("open", "IndexedDB is unavailable."))); + return; + } + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (request.result.objectStoreNames.contains(LEGACY_STORE_NAME)) { + request.result.deleteObjectStore(LEGACY_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_STORE_NAME, { + keyPath: "scope", + }); + } + if (!request.result.objectStoreNames.contains(MESSAGE_STORE_NAME)) { + const store = request.result.createObjectStore(MESSAGE_STORE_NAME, { + keyPath: ["scope", "index"], + }); + store.createIndex(MESSAGE_ID_INDEX_NAME, ["scope", "messageId"], { + unique: true, + }); + } + if (!request.result.objectStoreNames.contains(ACTIVITY_STORE_NAME)) { + const store = request.result.createObjectStore(ACTIVITY_STORE_NAME, { + keyPath: ["scope", "activityId"], + }); + store.createIndex( + ACTIVITY_POSITION_INDEX_NAME, + ["scope", "position", "activity.createdAt", "activityId"], + { unique: true }, + ); + } + if (!request.result.objectStoreNames.contains(PLAN_STORE_NAME)) { + const store = request.result.createObjectStore(PLAN_STORE_NAME, { + keyPath: ["scope", "planId"], + }); + store.createIndex( + PLAN_POSITION_INDEX_NAME, + ["scope", "position", "plan.createdAt", "planId"], + { unique: true }, + ); + } + }); + request.addEventListener("error", () => { + resume(Effect.fail(transientError("open", request.error ?? "Unknown IndexedDB error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }); +}); + +function readValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); + request.addEventListener("error", () => { + resume(Effect.fail(transientError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.threadHistoryCache.readValue")); +} + +function readValuesInRange( + database: IDBDatabase, + storeName: string, + range: IDBKeyRange, + indexName?: string, +) { + return Effect.callback, ConnectionTransientError>((resume) => { + const store = database.transaction(storeName, "readonly").objectStore(storeName); + const request = (indexName === undefined ? store : store.index(indexName)).getAll(range); + request.addEventListener("error", () => { + resume(Effect.fail(transientError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.threadHistoryCache.readValuesInRange")); +} + +function writePage( + database: IDBDatabase, + thread: object, + messages: ReadonlyArray, + activities: ReadonlyArray, + plans: ReadonlyArray, +) { + return Effect.callback((resume) => { + const transaction = database.transaction( + [THREAD_STORE_NAME, MESSAGE_STORE_NAME, ACTIVITY_STORE_NAME, PLAN_STORE_NAME], + "readwrite", + ); + transaction.addEventListener("error", () => { + resume( + Effect.fail(transientError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(THREAD_STORE_NAME).put(thread); + const messageStore = transaction.objectStore(MESSAGE_STORE_NAME); + for (const message of messages) { + messageStore.put(message); + } + const activityStore = transaction.objectStore(ACTIVITY_STORE_NAME); + for (const activity of activities) { + activityStore.put(activity); + } + const planStore = transaction.objectStore(PLAN_STORE_NAME); + for (const plan of plans) { + planStore.put(plan); + } + }).pipe(Effect.withSpan("web.threadHistoryCache.writePage")); +} + +function removeValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail( + transientError("remove", transaction.error ?? "Unknown IndexedDB remove error"), + ), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(storeName).delete(key); + }).pipe(Effect.withSpan("web.threadHistoryCache.removeValue")); +} + +function removeValuesInRange(database: IDBDatabase, storeName: string, range: IDBKeyRange) { + return Effect.callback((resume) => { + const transaction = database.transaction(storeName, "readwrite"); + transaction.addEventListener("error", () => { + resume( + Effect.fail( + transientError("remove", transaction.error ?? "Unknown IndexedDB cursor error"), + ), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + const request = transaction.objectStore(storeName).openCursor(range); + request.addEventListener("error", () => { + resume( + Effect.fail(transientError("remove", request.error ?? "Unknown IndexedDB cursor error")), + ); + }); + request.addEventListener("success", () => { + const cursor = request.result; + if (cursor === null) { + return; + } + cursor.delete(); + cursor.continue(); + }); + }).pipe(Effect.withSpan("web.threadHistoryCache.removeValuesInRange")); +} + +const clearDatabase = Effect.fn("web.threadHistoryCache.clearDatabase")(function* ( + database: IDBDatabase, +) { + yield* Effect.callback((resume) => { + const transaction = database.transaction( + [THREAD_STORE_NAME, MESSAGE_STORE_NAME, ACTIVITY_STORE_NAME, PLAN_STORE_NAME], + "readwrite", + ); + transaction.addEventListener("error", () => { + resume( + Effect.fail(transientError("clear", transaction.error ?? "Unknown IndexedDB clear error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(THREAD_STORE_NAME).clear(); + transaction.objectStore(MESSAGE_STORE_NAME).clear(); + transaction.objectStore(ACTIVITY_STORE_NAME).clear(); + transaction.objectStore(PLAN_STORE_NAME).clear(); + }); +}); + +function threadScope(environmentId: EnvironmentId, threadId: ThreadId) { + return `${environmentId}:${threadId}`; +} + +const loadThread = Effect.fn("web.threadHistoryCache.loadThread")(function* ( + database: IDBDatabase, + environmentId: EnvironmentId, + threadId: ThreadId, +) { + const scope = threadScope(environmentId, threadId); + const raw = yield* readValue(database, THREAD_STORE_NAME, scope); + if (raw === undefined) { + return Option.none(); + } + const thread = yield* decodeStoredThreadHistoryThread(raw); + return thread.scope === scope && + thread.environmentId === environmentId && + thread.threadId === threadId + ? Option.some(thread) + : Option.none(); +}); + +const loadPage = Effect.fn("web.threadHistoryCache.loadPage")(function* ( + database: IDBDatabase, + environmentId: EnvironmentId, + threadId: ThreadId, + startIndex: number, + endIndex: number, + totalMessages: number, +) { + if (endIndex <= startIndex) { + return Option.none(); + } + const scope = threadScope(environmentId, threadId); + const [rawMessages, rawActivities, rawPlans] = yield* Effect.all([ + readValuesInRange( + database, + MESSAGE_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex - 1]), + ), + readValuesInRange( + database, + ACTIVITY_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex, "\uffff", "\uffff"]), + ACTIVITY_POSITION_INDEX_NAME, + ), + readValuesInRange( + database, + PLAN_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex, "\uffff", "\uffff"]), + PLAN_POSITION_INDEX_NAME, + ), + ]); + const messageRecords = yield* decodeStoredThreadHistoryMessages(rawMessages); + const activityRecords = yield* decodeStoredThreadHistoryActivities(rawActivities); + const planRecords = yield* decodeStoredThreadHistoryPlans(rawPlans); + if ( + messageRecords.length !== endIndex - startIndex || + messageRecords.some( + (record, offset) => + record.scope !== scope || + record.environmentId !== environmentId || + record.threadId !== threadId || + record.index !== startIndex + offset, + ) + ) { + return Option.none(); + } + const messages = messageRecords.map((record) => record.message); + const firstMessage = messages[0]; + if (firstMessage === undefined) { + return Option.none(); + } + return Option.some({ + messages, + activities: activityRecords + .filter( + (record) => + record.scope === scope && + record.environmentId === environmentId && + record.threadId === threadId, + ) + .map((record) => record.activity) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ), + proposedPlans: planRecords + .filter( + (record) => + record.scope === scope && + record.environmentId === environmentId && + record.threadId === threadId, + ) + .map((record) => record.plan) + .toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ), + messageHistory: { + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessages, + startIndex, + endIndex, + totalMessages, + cursor: { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }); +}); + +export const makeWebThreadHistoryCacheStore = Effect.fn("web.threadHistoryCache.make")( + function* () { + const database = yield* Effect.acquireRelease(openDatabase(), (database) => + Effect.sync(() => database.close()), + ); + const writeLock = yield* Semaphore.make(1); + let activeWriteToken = 0; + + return ThreadHistoryCacheStore.of({ + loadPrevious: (environmentId, threadId, endIndex, limit) => + Effect.gen(function* () { + const thread = yield* loadThread(database, environmentId, threadId); + if (Option.isNone(thread)) { + return { page: Option.none(), requestLimit: limit }; + } + const range = thread.value.ranges.find( + (candidate) => candidate.startIndex < endIndex && candidate.endIndex >= endIndex, + ); + if (range !== undefined) { + const turnStarts = thread.value.turnStarts.filter( + (index) => index >= range.startIndex && index < endIndex, + ); + const startIndex = turnStarts.at(-limit); + return { + page: + startIndex === undefined + ? Option.none() + : yield* loadPage( + database, + environmentId, + threadId, + startIndex, + endIndex, + thread.value.totalMessages, + ), + requestLimit: limit, + }; + } + return { page: Option.none(), requestLimit: limit }; + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + loadNext: (environmentId, threadId, startIndex, limit) => + Effect.gen(function* () { + const thread = yield* loadThread(database, environmentId, threadId); + if (Option.isNone(thread)) { + return { page: Option.none(), requestLimit: limit }; + } + const range = thread.value.ranges.find( + (candidate) => candidate.startIndex <= startIndex && candidate.endIndex > startIndex, + ); + if (range !== undefined) { + const turnStarts = thread.value.turnStarts.filter( + (index) => index >= startIndex && index < range.endIndex, + ); + const pageStartIndex = turnStarts[0]; + return { + page: + pageStartIndex === undefined + ? Option.none() + : yield* loadPage( + database, + environmentId, + threadId, + pageStartIndex, + turnStarts[limit] ?? range.endIndex, + thread.value.totalMessages, + ), + requestLimit: limit, + }; + } + return { page: Option.none(), requestLimit: limit }; + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + loadAround: (environmentId, threadId, messageId, limit) => + Effect.gen(function* () { + const scope = threadScope(environmentId, threadId); + const [thread, rawTargets] = yield* Effect.all([ + loadThread(database, environmentId, threadId), + readValuesInRange( + database, + MESSAGE_STORE_NAME, + IDBKeyRange.only([scope, messageId]), + MESSAGE_ID_INDEX_NAME, + ), + ]); + const targets = yield* decodeStoredThreadHistoryMessages(rawTargets); + const target = targets[0]; + if ( + Option.isNone(thread) || + target === undefined || + target.scope !== scope || + target.environmentId !== environmentId || + target.threadId !== threadId + ) { + return Option.none(); + } + const range = thread.value.ranges.find( + (candidate) => + candidate.startIndex <= target.index && candidate.endIndex > target.index, + ); + if (range === undefined) { + return Option.none(); + } + const turnStarts = thread.value.turnStarts.filter( + (index) => index >= range.startIndex && index < range.endIndex, + ); + const targetTurn = turnStarts.findLastIndex((index) => index <= target.index); + if (targetTurn === -1) { + return Option.none(); + } + const startTurn = Math.max( + 0, + Math.min(targetTurn - Math.floor((limit - 1) / 2), turnStarts.length - limit), + ); + const startIndex = turnStarts[startTurn]!; + const endIndex = turnStarts[startTurn + limit] ?? range.endIndex; + return yield* loadPage( + database, + environmentId, + threadId, + startIndex, + endIndex, + thread.value.totalMessages, + ); + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + captureWriteToken: () => Effect.sync(() => activeWriteToken), + save: (environmentId, threadId, page, writeToken) => + writeLock + .withPermits(1)( + Effect.gen(function* () { + if (writeToken !== activeWriteToken || page.messages.length === 0) { + return; + } + const scope = threadScope(environmentId, threadId); + const current = yield* loadThread(database, environmentId, threadId); + const ranges = [ + ...(Option.isSome(current) ? current.value.ranges : []), + { + startIndex: page.messageHistory.startIndex, + endIndex: page.messageHistory.endIndex, + }, + ].toSorted((left, right) => left.startIndex - right.startIndex); + const turnStarts = [ + ...(Option.isSome(current) ? current.value.turnStarts : []), + ...page.messages.flatMap((message, offset) => + message.role === "user" ? [page.messageHistory.startIndex + offset] : [], + ), + ].toSorted((left, right) => left - right); + const mergedRanges: Array<{ readonly startIndex: number; endIndex: number }> = []; + for (const range of ranges) { + const previous = mergedRanges.at(-1); + if (previous === undefined || previous.endIndex < range.startIndex) { + mergedRanges.push({ ...range }); + } else { + previous.endIndex = Math.max(previous.endIndex, range.endIndex); + } + } + let activityMessageOffset = 0; + const activities = page.activities.map((activity) => { + while ( + activityMessageOffset < page.messages.length && + page.messages[activityMessageOffset]!.createdAt <= activity.createdAt + ) { + activityMessageOffset += 1; + } + return { + schemaVersion: CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + position: page.messageHistory.startIndex + activityMessageOffset, + activityId: activity.id, + activity, + }; + }); + let planMessageOffset = 0; + const plans = page.proposedPlans.map((plan) => { + while ( + planMessageOffset < page.messages.length && + page.messages[planMessageOffset]!.createdAt <= plan.createdAt + ) { + planMessageOffset += 1; + } + return { + schemaVersion: CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + position: page.messageHistory.startIndex + planMessageOffset, + planId: plan.id, + plan, + }; + }); + yield* writePage( + database, + { + schemaVersion: CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + totalMessages: Math.max( + Option.isSome(current) ? current.value.totalMessages : 0, + page.messageHistory.totalMessages, + ), + ranges: mergedRanges, + turnStarts: [...new Set(turnStarts)], + }, + page.messages.map((message, offset) => ({ + schemaVersion: CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + index: page.messageHistory.startIndex + offset, + messageId: message.id, + message, + })), + activities, + plans, + ); + }), + ) + .pipe(Effect.mapError((cause) => persistenceError("save-thread-history", cause))), + remove: (environmentId, threadId) => + writeLock + .withPermits(1)( + Effect.gen(function* () { + activeWriteToken += 1; + yield* Effect.all( + [ + removeValue(database, THREAD_STORE_NAME, threadScope(environmentId, threadId)), + removeValuesInRange( + database, + MESSAGE_STORE_NAME, + IDBKeyRange.bound( + [threadScope(environmentId, threadId), 0], + [threadScope(environmentId, threadId), Number.MAX_SAFE_INTEGER], + ), + ), + removeValuesInRange( + database, + ACTIVITY_STORE_NAME, + IDBKeyRange.bound( + [threadScope(environmentId, threadId)], + [threadScope(environmentId, threadId), "\uffff"], + ), + ), + removeValuesInRange( + database, + PLAN_STORE_NAME, + IDBKeyRange.bound( + [threadScope(environmentId, threadId)], + [threadScope(environmentId, threadId), "\uffff"], + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ); + }), + ) + .pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + clear: (environmentId) => + writeLock + .withPermits(1)( + Effect.gen(function* () { + activeWriteToken += 1; + yield* Effect.all( + [ + removeValuesInRange( + database, + THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + removeValuesInRange( + database, + MESSAGE_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeValuesInRange( + database, + ACTIVITY_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeValuesInRange( + database, + PLAN_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + ], + { concurrency: "unbounded", discard: true }, + ); + }), + ) + .pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + clearAll: () => + writeLock + .withPermits(1)( + Effect.gen(function* () { + activeWriteToken += 1; + yield* clearDatabase(database); + }), + ) + .pipe(Effect.mapError((cause) => persistenceError("clear-thread-history", cause))), + }); + }, +); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e58876b19f7..2caa6a34752 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -28,6 +28,8 @@ import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { APP_STAGE_LABEL } from "~/branding"; import { resolveSidebarV2Enabled } from "~/branding.logic"; import { ensureLocalApi } from "~/localApi"; +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { progressiveThreadHistoryEnabledAtom } from "~/state/clientSettings"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { usePrimaryEnvironment } from "~/state/environments"; @@ -62,6 +64,10 @@ function getClientSettingsSnapshot(): ClientSettings { function replaceClientSettingsSnapshot(settings: ClientSettings): void { clientSettingsSnapshot = settings; + appAtomRegistry.set( + progressiveThreadHistoryEnabledAtom, + settings.progressiveThreadHistoryEnabled, + ); emitClientSettingsChange(); } @@ -335,6 +341,10 @@ export function useUpdateClientSettings() { export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; + appAtomRegistry.set( + progressiveThreadHistoryEnabledAtom, + DEFAULT_CLIENT_SETTINGS.progressiveThreadHistoryEnabled, + ); clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); @@ -344,6 +354,10 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; + appAtomRegistry.set( + progressiveThreadHistoryEnabledAtom, + settings.progressiveThreadHistoryEnabled, + ); clientSettingsHydrated = true; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714..cc018a2b4b5 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,32 +9,26 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as SettingsRouteImport } from './routes/settings' -import { Route as PairRouteImport } from './routes/pair' -import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' +import { Route as ConnectRouteImport } from './routes/connect' +import { Route as PairRouteImport } from './routes/pair' +import { Route as SettingsRouteImport } from './routes/settings' import { Route as ChatIndexRouteImport } from './routes/_chat.index' -import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' -import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' -import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' -import { Route as SettingsGeneralRouteImport } from './routes/settings.general' -import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' -import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' -import { Route as SettingsBetaRouteImport } from './routes/settings.beta' -import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' -import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' -import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' +import { Route as SettingsAppearanceRouteImport } from './routes/settings.appearance' +import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' +import { Route as SettingsBetaRouteImport } from './routes/settings.beta' +import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' +import { Route as SettingsDiagnosticsRouteImport } from './routes/settings.diagnostics' +import { Route as SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' +import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' -const SettingsRoute = SettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => rootRouteImport, -} as any) -const PairRoute = PairRouteImport.update({ - id: '/pair', - path: '/pair', +const ChatRoute = ChatRouteImport.update({ + id: '/_chat', getParentRoute: () => rootRouteImport, } as any) const ConnectRoute = ConnectRouteImport.update({ @@ -42,8 +36,14 @@ const ConnectRoute = ConnectRouteImport.update({ path: '/connect', getParentRoute: () => rootRouteImport, } as any) -const ChatRoute = ChatRouteImport.update({ - id: '/_chat', +const PairRoute = PairRouteImport.update({ + id: '/pair', + path: '/pair', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsRoute = SettingsRouteImport.update({ + id: '/settings', + path: '/settings', getParentRoute: () => rootRouteImport, } as any) const ChatIndexRoute = ChatIndexRouteImport.update({ @@ -51,24 +51,29 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) -const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ - id: '/source-control', - path: '/source-control', +const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ + id: '/connect_/callback', + path: '/connect/callback', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ + id: '/appearance', + path: '/appearance', getParentRoute: () => SettingsRoute, } as any) -const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ - id: '/providers', - path: '/providers', +const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ + id: '/archived', + path: '/archived', getParentRoute: () => SettingsRoute, } as any) -const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ - id: '/keybindings', - path: '/keybindings', +const SettingsBetaRoute = SettingsBetaRouteImport.update({ + id: '/beta', + path: '/beta', getParentRoute: () => SettingsRoute, } as any) -const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ - id: '/general', - path: '/general', +const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ + id: '/connections', + path: '/connections', getParentRoute: () => SettingsRoute, } as any) const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ @@ -76,42 +81,37 @@ const SettingsDiagnosticsRoute = SettingsDiagnosticsRouteImport.update({ path: '/diagnostics', getParentRoute: () => SettingsRoute, } as any) -const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ - id: '/connections', - path: '/connections', +const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ + id: '/general', + path: '/general', getParentRoute: () => SettingsRoute, } as any) -const SettingsBetaRoute = SettingsBetaRouteImport.update({ - id: '/beta', - path: '/beta', +const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ + id: '/keybindings', + path: '/keybindings', getParentRoute: () => SettingsRoute, } as any) -const SettingsArchivedRoute = SettingsArchivedRouteImport.update({ - id: '/archived', - path: '/archived', +const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ + id: '/providers', + path: '/providers', getParentRoute: () => SettingsRoute, } as any) -const SettingsAppearanceRoute = SettingsAppearanceRouteImport.update({ - id: '/appearance', - path: '/appearance', +const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', getParentRoute: () => SettingsRoute, } as any) -const ConnectCallbackRoute = ConnectCallbackRouteImport.update({ - id: '/connect_/callback', - path: '/connect/callback', - getParentRoute: () => rootRouteImport, -} as any) -const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ - id: '/draft/$draftId', - path: '/draft/$draftId', - getParentRoute: () => ChatRoute, -} as any) const ChatEnvironmentIdThreadIdRoute = ChatEnvironmentIdThreadIdRouteImport.update({ id: '/$environmentId/$threadId', path: '/$environmentId/$threadId', getParentRoute: () => ChatRoute, } as any) +const ChatDraftDraftIdRoute = ChatDraftDraftIdRouteImport.update({ + id: '/draft/$draftId', + path: '/draft/$draftId', + getParentRoute: () => ChatRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute @@ -237,18 +237,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/settings': { - id: '/settings' - path: '/settings' - fullPath: '/settings' - preLoaderRoute: typeof SettingsRouteImport - parentRoute: typeof rootRouteImport - } - '/pair': { - id: '/pair' - path: '/pair' - fullPath: '/pair' - preLoaderRoute: typeof PairRouteImport + '/_chat': { + id: '/_chat' + path: '' + fullPath: '/' + preLoaderRoute: typeof ChatRouteImport parentRoute: typeof rootRouteImport } '/connect': { @@ -258,11 +251,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConnectRouteImport parentRoute: typeof rootRouteImport } - '/_chat': { - id: '/_chat' - path: '' - fullPath: '/' - preLoaderRoute: typeof ChatRouteImport + '/pair': { + id: '/pair' + path: '/pair' + fullPath: '/pair' + preLoaderRoute: typeof PairRouteImport + parentRoute: typeof rootRouteImport + } + '/settings': { + id: '/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof SettingsRouteImport parentRoute: typeof rootRouteImport } '/_chat/': { @@ -272,32 +272,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } - '/settings/source-control': { - id: '/settings/source-control' - path: '/source-control' - fullPath: '/settings/source-control' - preLoaderRoute: typeof SettingsSourceControlRouteImport + '/connect_/callback': { + id: '/connect_/callback' + path: '/connect/callback' + fullPath: '/connect/callback' + preLoaderRoute: typeof ConnectCallbackRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/appearance': { + id: '/settings/appearance' + path: '/appearance' + fullPath: '/settings/appearance' + preLoaderRoute: typeof SettingsAppearanceRouteImport parentRoute: typeof SettingsRoute } - '/settings/providers': { - id: '/settings/providers' - path: '/providers' - fullPath: '/settings/providers' - preLoaderRoute: typeof SettingsProvidersRouteImport + '/settings/archived': { + id: '/settings/archived' + path: '/archived' + fullPath: '/settings/archived' + preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/settings/keybindings': { - id: '/settings/keybindings' - path: '/keybindings' - fullPath: '/settings/keybindings' - preLoaderRoute: typeof SettingsKeybindingsRouteImport + '/settings/beta': { + id: '/settings/beta' + path: '/beta' + fullPath: '/settings/beta' + preLoaderRoute: typeof SettingsBetaRouteImport parentRoute: typeof SettingsRoute } - '/settings/general': { - id: '/settings/general' - path: '/general' - fullPath: '/settings/general' - preLoaderRoute: typeof SettingsGeneralRouteImport + '/settings/connections': { + id: '/settings/connections' + path: '/connections' + fullPath: '/settings/connections' + preLoaderRoute: typeof SettingsConnectionsRouteImport parentRoute: typeof SettingsRoute } '/settings/diagnostics': { @@ -307,40 +314,40 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsDiagnosticsRouteImport parentRoute: typeof SettingsRoute } - '/settings/connections': { - id: '/settings/connections' - path: '/connections' - fullPath: '/settings/connections' - preLoaderRoute: typeof SettingsConnectionsRouteImport + '/settings/general': { + id: '/settings/general' + path: '/general' + fullPath: '/settings/general' + preLoaderRoute: typeof SettingsGeneralRouteImport parentRoute: typeof SettingsRoute } - '/settings/beta': { - id: '/settings/beta' - path: '/beta' - fullPath: '/settings/beta' - preLoaderRoute: typeof SettingsBetaRouteImport + '/settings/keybindings': { + id: '/settings/keybindings' + path: '/keybindings' + fullPath: '/settings/keybindings' + preLoaderRoute: typeof SettingsKeybindingsRouteImport parentRoute: typeof SettingsRoute } - '/settings/archived': { - id: '/settings/archived' - path: '/archived' - fullPath: '/settings/archived' - preLoaderRoute: typeof SettingsArchivedRouteImport + '/settings/providers': { + id: '/settings/providers' + path: '/providers' + fullPath: '/settings/providers' + preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } - '/settings/appearance': { - id: '/settings/appearance' - path: '/appearance' - fullPath: '/settings/appearance' - preLoaderRoute: typeof SettingsAppearanceRouteImport + '/settings/source-control': { + id: '/settings/source-control' + path: '/source-control' + fullPath: '/settings/source-control' + preLoaderRoute: typeof SettingsSourceControlRouteImport parentRoute: typeof SettingsRoute } - '/connect_/callback': { - id: '/connect_/callback' - path: '/connect/callback' - fullPath: '/connect/callback' - preLoaderRoute: typeof ConnectCallbackRouteImport - parentRoute: typeof rootRouteImport + '/_chat/$environmentId/$threadId': { + id: '/_chat/$environmentId/$threadId' + path: '/$environmentId/$threadId' + fullPath: '/$environmentId/$threadId' + preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport + parentRoute: typeof ChatRoute } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' @@ -349,13 +356,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatDraftDraftIdRouteImport parentRoute: typeof ChatRoute } - '/_chat/$environmentId/$threadId': { - id: '/_chat/$environmentId/$threadId' - path: '/$environmentId/$threadId' - fullPath: '/$environmentId/$threadId' - preLoaderRoute: typeof ChatEnvironmentIdThreadIdRouteImport - parentRoute: typeof ChatRoute - } } } diff --git a/apps/web/src/state/clientSettings.ts b/apps/web/src/state/clientSettings.ts new file mode 100644 index 00000000000..24cd146e977 --- /dev/null +++ b/apps/web/src/state/clientSettings.ts @@ -0,0 +1,6 @@ +import { Atom } from "effect/unstable/reactivity"; + +export const progressiveThreadHistoryEnabledAtom = Atom.make(false).pipe( + Atom.keepAlive, + Atom.withLabel("web-progressive-thread-history-enabled"), +); diff --git a/apps/web/src/state/threadHistoryCache.ts b/apps/web/src/state/threadHistoryCache.ts new file mode 100644 index 00000000000..a3e5bb134f4 --- /dev/null +++ b/apps/web/src/state/threadHistoryCache.ts @@ -0,0 +1,12 @@ +import { ThreadHistoryCacheStore } from "@t3tools/client-runtime/platform"; +import { createRuntimeCommand } from "@t3tools/client-runtime/state/runtime"; +import * as Effect from "effect/Effect"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const clearLocalThreadHistoryCache = createRuntimeCommand(connectionAtomRuntime, { + label: "web:thread-history:clear-local-cache", + concurrency: { mode: "singleFlight", key: () => "global" }, + execute: () => + ThreadHistoryCacheStore.pipe(Effect.flatMap((historyCache) => historyCache.clearAll())), +}); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index fd936f99ff2..cbd17f08f77 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -9,14 +9,27 @@ import { } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { progressiveThreadHistoryEnabledAtom } from "./clientSettings"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); -export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime, { + messagePagination: { + enabled: () => appAtomRegistry.get(progressiveThreadHistoryEnabledAtom), + changes: Stream.suspend(() => + AtomRegistry.toStream(appAtomRegistry, progressiveThreadHistoryEnabledAtom).pipe( + Stream.changes, + Stream.drop(1), + ), + ), + }, +}); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); diff --git a/nix/package.nix b/nix/package.nix index 3ecb291c1c2..45a513a3f61 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -49,7 +49,7 @@ stdenv.mkDerivation (finalAttrs: { ; inherit pnpm; fetcherVersion = 4; - hash = "sha256-4pO+a4+kqvHGt4iRt7Cz+Pe1TvLcVAMchECw5uu7b80="; + hash = "sha256-Lnbxd6gQ9Seu6XUPXVmumf/i/phFEWhnZGjj4sCagmI="; }; postPatch = lib.optionalString (finalAttrs.version != sourceVersion) '' diff --git a/packages/client-runtime/src/platform/index.ts b/packages/client-runtime/src/platform/index.ts index 0c937549771..42f6a076550 100644 --- a/packages/client-runtime/src/platform/index.ts +++ b/packages/client-runtime/src/platform/index.ts @@ -2,3 +2,4 @@ export * from "./capabilities.ts"; export * from "./persistence.ts"; export * from "./source.ts"; export * from "./storageDocument.ts"; +export * from "./threadHistoryCache.ts"; diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 14a322bcd30..7d4286a2b13 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -26,6 +26,9 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass; + readonly requestLimit: number; +} + +export class ThreadHistoryCacheStore extends Context.Reference<{ + readonly loadPrevious: ( + environmentId: EnvironmentId, + threadId: ThreadId, + endIndex: number, + limit: number, + ) => Effect.Effect; + readonly loadNext: ( + environmentId: EnvironmentId, + threadId: ThreadId, + startIndex: number, + limit: number, + ) => Effect.Effect; + readonly loadAround: ( + environmentId: EnvironmentId, + threadId: ThreadId, + messageId: MessageId, + limit: number, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly captureWriteToken: () => Effect.Effect; + readonly save: ( + environmentId: EnvironmentId, + threadId: ThreadId, + page: OrchestrationThreadHistoryPage, + writeToken: number, + ) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect; + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; + readonly clearAll: () => Effect.Effect; +}>("@t3tools/client-runtime/platform/threadHistoryCache/ThreadHistoryCacheStore", { + defaultValue: () => ({ + loadPrevious: (_environmentId, _threadId, _endIndex, limit) => + Effect.succeed({ page: Option.none(), requestLimit: limit }), + loadNext: (_environmentId, _threadId, _startIndex, limit) => + Effect.succeed({ page: Option.none(), requestLimit: limit }), + loadAround: () => Effect.succeed(Option.none()), + captureWriteToken: () => Effect.succeed(0), + save: () => Effect.void, + remove: () => Effect.void, + clear: () => Effect.void, + clearAll: () => Effect.void, + }), +}) {} + +interface CachedHistoryPage { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly page: OrchestrationThreadHistoryPage; +} + +function sliceHistoryPage( + page: OrchestrationThreadHistoryPage, + startIndex: number, + endIndex: number, +): OrchestrationThreadHistoryPage { + if (startIndex === page.messageHistory.startIndex && endIndex === page.messageHistory.endIndex) { + return page; + } + + const messages = page.messages.slice( + startIndex - page.messageHistory.startIndex, + endIndex - page.messageHistory.startIndex, + ); + const firstMessage = messages[0]; + const activityPosition = (createdAt: string) => { + const offset = page.messages.findIndex((message) => message.createdAt > createdAt); + return page.messageHistory.startIndex + (offset === -1 ? page.messages.length : offset); + }; + + return { + messages, + activities: page.activities.filter((activity) => { + const position = activityPosition(activity.createdAt); + return position >= startIndex && position <= endIndex; + }), + proposedPlans: page.proposedPlans.filter((plan) => { + const position = activityPosition(plan.createdAt); + return position >= startIndex && position <= endIndex; + }), + messageHistory: { + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < page.messageHistory.totalMessages, + startIndex, + endIndex, + totalMessages: page.messageHistory.totalMessages, + cursor: + firstMessage === undefined + ? null + : { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +export function makeInMemoryThreadHistoryCacheStore(maxPages: number) { + const pages = new LRUCache(maxPages, maxPages); + let activeWriteToken = 0; + + return ThreadHistoryCacheStore.of({ + loadPrevious: (environmentId, threadId, endIndex, limit) => + Effect.sync(() => { + let match: readonly [string, CachedHistoryPage] | null = null; + for (const entry of pages.entries()) { + const cached = entry[1]; + if (cached.environmentId !== environmentId || cached.threadId !== threadId) { + continue; + } + const history = cached.page.messageHistory; + if (history.startIndex < endIndex && history.endIndex >= endIndex) { + match = entry; + } + } + if (match !== null) { + const [key, cached] = match; + void pages.get(key); + const endOffset = endIndex - cached.page.messageHistory.startIndex; + const turnStarts = cached.page.messages.flatMap((message, index) => + message.role === "user" && index < endOffset ? [index] : [], + ); + return { + page: Option.some( + sliceHistoryPage( + cached.page, + cached.page.messageHistory.startIndex + (turnStarts.at(-limit) ?? 0), + endIndex, + ), + ), + requestLimit: limit, + }; + } + return { page: Option.none(), requestLimit: limit }; + }), + loadNext: (environmentId, threadId, startIndex, limit) => + Effect.sync(() => { + let match: readonly [string, CachedHistoryPage] | null = null; + for (const entry of pages.entries()) { + const cached = entry[1]; + if (cached.environmentId !== environmentId || cached.threadId !== threadId) { + continue; + } + const history = cached.page.messageHistory; + if (history.startIndex <= startIndex && history.endIndex > startIndex) { + match = entry; + } + } + if (match !== null) { + const [key, cached] = match; + void pages.get(key); + const startOffset = startIndex - cached.page.messageHistory.startIndex; + const turnStarts = cached.page.messages.flatMap((message, index) => + message.role === "user" && index >= startOffset ? [index] : [], + ); + const sliceStart = turnStarts[0] ?? startOffset; + const sliceEnd = turnStarts[limit] ?? cached.page.messages.length; + return { + page: Option.some( + sliceHistoryPage( + cached.page, + cached.page.messageHistory.startIndex + sliceStart, + cached.page.messageHistory.startIndex + sliceEnd, + ), + ), + requestLimit: limit, + }; + } + return { page: Option.none(), requestLimit: limit }; + }), + loadAround: (environmentId, threadId, messageId, limit) => + Effect.sync(() => { + let match: readonly [string, CachedHistoryPage, number] | null = null; + for (const [key, cached] of pages.entries()) { + if (cached.environmentId !== environmentId || cached.threadId !== threadId) { + continue; + } + const offset = cached.page.messages.findIndex((message) => message.id === messageId); + if (offset !== -1) { + match = [key, cached, cached.page.messageHistory.startIndex + offset]; + } + } + if (match === null) { + return Option.none(); + } + const [key, cached, targetIndex] = match; + void pages.get(key); + const turnStarts = cached.page.messages.flatMap((message, index) => + message.role === "user" ? [index] : [], + ); + const targetOffset = targetIndex - cached.page.messageHistory.startIndex; + const targetTurn = Math.max( + 0, + turnStarts.findLastIndex((turnStart) => turnStart <= targetOffset), + ); + const startTurn = Math.max( + 0, + Math.min(targetTurn - Math.floor((limit - 1) / 2), turnStarts.length - limit), + ); + const startIndex = + cached.page.messageHistory.startIndex + (turnStarts[startTurn] ?? targetOffset); + const endIndex = + cached.page.messageHistory.startIndex + + (turnStarts[startTurn + limit] ?? cached.page.messages.length); + return Option.some(sliceHistoryPage(cached.page, startIndex, endIndex)); + }), + captureWriteToken: () => Effect.sync(() => activeWriteToken), + save: (environmentId, threadId, page, writeToken) => + Effect.sync(() => { + if (writeToken !== activeWriteToken || page.messages.length === 0) { + return; + } + const key = `${environmentId}:${threadId}:${page.messageHistory.startIndex}:${page.messageHistory.endIndex}`; + pages.set(key, { environmentId, threadId, page }, 1); + }), + remove: (environmentId, threadId) => + Effect.sync(() => { + activeWriteToken += 1; + for (const [key, cached] of pages.entries()) { + if (cached.environmentId === environmentId && cached.threadId === threadId) { + pages.delete(key); + } + } + }), + clear: (environmentId) => + Effect.sync(() => { + activeWriteToken += 1; + for (const [key, cached] of pages.entries()) { + if (cached.environmentId === environmentId) { + pages.delete(key); + } + } + }), + clearAll: () => + Effect.sync(() => { + activeWriteToken += 1; + pages.clear(); + }), + }); +} diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 42ba7fa0f3e..99bb276f18a 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -332,8 +332,10 @@ describe("environment entity projections", () => { harness.threadStateAtom(THREAD_ID), AsyncResult.success({ data: Option.some(detail), + liveData: Option.some(detail), status: "live", error: Option.none(), + history: { kind: "disabled" }, }), ); @@ -360,8 +362,10 @@ describe("environment entity projections", () => { updatedAt: "2026-06-01T00:01:00.000Z", }, }), + liveData: Option.some(detail), status: "live", error: Option.none(), + history: { kind: "disabled" }, }), ); diff --git a/packages/client-runtime/src/state/threadHistory.ts b/packages/client-runtime/src/state/threadHistory.ts new file mode 100644 index 00000000000..804e808c6f5 --- /dev/null +++ b/packages/client-runtime/src/state/threadHistory.ts @@ -0,0 +1,231 @@ +import type { OrchestrationThread, OrchestrationThreadHistoryPage } from "@t3tools/contracts"; + +import { THREAD_TURN_PAGE_SIZE } from "./threadSnapshotHttp.ts"; +import type { EnvironmentThreadHistoryState } from "./threadState.ts"; + +const THREAD_HISTORY_WINDOW_MAX_TURNS = THREAD_TURN_PAGE_SIZE * 5; + +export function boundLiveThread(thread: OrchestrationThread): OrchestrationThread { + if (thread.messageHistory === undefined) { + return thread; + } + const turnStarts = thread.messages.flatMap((message, index) => + message.role === "user" ? [index] : [], + ); + const startOffset = turnStarts.at(-THREAD_TURN_PAGE_SIZE); + const messages = startOffset === undefined ? thread.messages : thread.messages.slice(startOffset); + const firstMessage = messages[0]; + if (firstMessage === undefined) { + return thread; + } + const visibleTurnIds = new Set( + messages.flatMap((message) => (message.turnId === null ? [] : [message.turnId])), + ); + const activeTurnId = thread.session?.activeTurnId ?? null; + if (activeTurnId !== null) { + visibleTurnIds.add(activeTurnId); + } + return { + ...thread, + messages, + activities: thread.activities.filter( + (activity) => + activity.createdAt >= firstMessage.createdAt && + (activity.turnId === null || visibleTurnIds.has(activity.turnId)), + ), + proposedPlans: thread.proposedPlans.filter((plan) => plan.createdAt >= firstMessage.createdAt), + messageHistory: { + hasMoreBefore: thread.messageHistory.endIndex - messages.length > 0, + hasMoreAfter: thread.messageHistory.endIndex < thread.messageHistory.totalMessages, + startIndex: thread.messageHistory.endIndex - messages.length, + endIndex: thread.messageHistory.endIndex, + totalMessages: thread.messageHistory.totalMessages, + cursor: { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +export function mergeThreadHistoryPages(input: { + readonly older: OrchestrationThreadHistoryPage; + readonly newer: OrchestrationThreadHistoryPage; +}): OrchestrationThreadHistoryPage { + const messages = [ + ...new Map( + [...input.older.messages, ...input.newer.messages].map((message) => [message.id, message]), + ).values(), + ].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const activities = [ + ...new Map( + [...input.older.activities, ...input.newer.activities].map((activity) => [ + activity.id, + activity, + ]), + ).values(), + ].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const proposedPlans = [ + ...new Map( + [...input.older.proposedPlans, ...input.newer.proposedPlans].map((plan) => [plan.id, plan]), + ).values(), + ].toSorted( + (left, right) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + ); + const firstMessage = messages[0]; + const startIndex = Math.min( + input.older.messageHistory.startIndex, + input.newer.messageHistory.startIndex, + ); + const endIndex = Math.max( + input.older.messageHistory.endIndex, + input.newer.messageHistory.endIndex, + ); + const totalMessages = Math.max( + input.older.messageHistory.totalMessages, + input.newer.messageHistory.totalMessages, + ); + return { + messages, + activities, + proposedPlans, + messageHistory: { + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessages, + startIndex, + endIndex, + totalMessages, + cursor: + firstMessage === undefined + ? null + : { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +export function boundThreadHistoryPage( + page: OrchestrationThreadHistoryPage, + preserve: "older" | "newer", +): OrchestrationThreadHistoryPage { + const turnStarts = page.messages.flatMap((message, index) => + message.role === "user" ? [index] : [], + ); + if (turnStarts.length <= THREAD_HISTORY_WINDOW_MAX_TURNS) { + return page; + } + const sliceStart = + preserve === "older" ? 0 : turnStarts[turnStarts.length - THREAD_HISTORY_WINDOW_MAX_TURNS]!; + const sliceEnd = + preserve === "older" ? turnStarts[THREAD_HISTORY_WINDOW_MAX_TURNS]! : page.messages.length; + const messages = page.messages.slice(sliceStart, sliceEnd); + const firstMessage = messages[0]; + if (firstMessage === undefined) { + return page; + } + // Turn telemetry can land after the turn's final message, so timestamps alone + // cannot decide whether it belongs to the retained window. + const visibleTurnIds = new Set( + messages.flatMap((message) => (message.turnId === null ? [] : [message.turnId])), + ); + const endBoundary = preserve === "older" ? (page.messages[sliceEnd]?.createdAt ?? null) : null; + const startIndex = + preserve === "older" + ? page.messageHistory.startIndex + : page.messageHistory.startIndex + sliceStart; + const endIndex = + preserve === "older" ? page.messageHistory.startIndex + sliceEnd : page.messageHistory.endIndex; + return { + messages, + activities: page.activities.filter((activity) => + activity.turnId !== null + ? visibleTurnIds.has(activity.turnId) + : activity.createdAt >= firstMessage.createdAt && + (endBoundary === null || activity.createdAt < endBoundary), + ), + proposedPlans: page.proposedPlans.filter((plan) => + plan.turnId !== null + ? visibleTurnIds.has(plan.turnId) + : plan.createdAt >= firstMessage.createdAt && + (endBoundary === null || plan.createdAt < endBoundary), + ), + messageHistory: { + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < page.messageHistory.totalMessages, + startIndex, + endIndex, + totalMessages: page.messageHistory.totalMessages, + cursor: { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +export function displayThreadHistory( + liveThread: OrchestrationThread, + history: EnvironmentThreadHistoryState, +): OrchestrationThread { + if (history.kind === "disabled" || history.window === null) { + return liveThread; + } + const historyTurnIds = new Set( + history.window.messages.flatMap((message) => (message.turnId === null ? [] : [message.turnId])), + ); + const visibleHistoryWindow = { + ...history.window, + activities: history.window.activities.filter( + (activity) => activity.turnId === null || historyTurnIds.has(activity.turnId), + ), + }; + if (visibleHistoryWindow.messageHistory.hasMoreAfter) { + const totalMessages = Math.max( + visibleHistoryWindow.messageHistory.totalMessages, + liveThread.messageHistory?.totalMessages ?? liveThread.messages.length, + ); + return { + ...liveThread, + messages: visibleHistoryWindow.messages, + activities: visibleHistoryWindow.activities, + proposedPlans: visibleHistoryWindow.proposedPlans, + messageHistory: { + ...visibleHistoryWindow.messageHistory, + hasMoreAfter: visibleHistoryWindow.messageHistory.endIndex < totalMessages, + totalMessages, + }, + }; + } + const merged = mergeThreadHistoryPages({ + older: visibleHistoryWindow, + newer: { + messages: liveThread.messages, + activities: liveThread.activities, + proposedPlans: liveThread.proposedPlans, + messageHistory: liveThread.messageHistory ?? { + hasMoreBefore: false, + hasMoreAfter: false, + startIndex: 0, + endIndex: liveThread.messages.length, + totalMessages: liveThread.messages.length, + cursor: null, + }, + }, + }); + return { + ...liveThread, + messages: merged.messages, + activities: merged.activities, + proposedPlans: merged.proposedPlans, + messageHistory: merged.messageHistory, + }; +} diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index f684839d5cf..5d3604c7b30 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -320,6 +320,19 @@ export function applyThreadDetailEvent( thread: { ...thread, messages, + ...(thread.messageHistory !== undefined && existingMessage === undefined + ? { + messageHistory: { + ...thread.messageHistory, + endIndex: thread.messageHistory.endIndex + 1, + totalMessages: thread.messageHistory.totalMessages + 1, + cursor: thread.messageHistory.cursor ?? { + createdAt: message.createdAt, + messageId: message.id, + }, + }, + } + : {}), checkpoints, latestTurn, updatedAt: event.occurredAt, @@ -510,6 +523,7 @@ export function applyThreadDetailEvent( messages, proposedPlans, activities, + messageHistory: undefined, latestTurn: latestCheckpoint === null ? null diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 874bcc30ebd..1aa182e2301 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -1,4 +1,11 @@ -import type { OrchestrationThreadDetailSnapshot, ThreadId } from "@t3tools/contracts"; +import type { + MessageId, + OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, + OrchestrationThreadMessageCursor, + ThreadId, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -16,10 +23,12 @@ import { } from "../rpc/http.ts"; import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; -// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket -// fallback for long. The cached thread renders while this runs, so the wait only -// delays the transition to live data on the first open, not the initial paint. -const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; +// The timeout covers response decoding and schema validation as well as the +// request itself. Dense turns can contain thousands of tool activities, so a +// short timeout can discard an already-delivered page and repeat the same work +// through the socket fallback. +const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 30_000; +export const THREAD_TURN_PAGE_SIZE = 10; /** * Load a thread's detail snapshot over HTTP instead of embedding it in the @@ -28,6 +37,170 @@ const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; */ export const fetchEnvironmentThreadSnapshot = Effect.fn( "clientRuntime.state.fetchEnvironmentThreadSnapshot", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly signer: Option.Option; + readonly turnLimit?: number; + readonly timeoutMs?: number; +}) { + const requestUrl = new URL( + environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}`, + ), + ); + if (input.turnLimit !== undefined) { + requestUrl.searchParams.set("turnLimit", String(input.turnLimit)); + } + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl.toString(), + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl.toString(), + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadSnapshot({ + params: { threadId: input.threadId }, + query: input.turnLimit === undefined ? {} : { turnLimit: input.turnLimit }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadMessagesBefore = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessagesBefore", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + readonly turnLimit: number; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = new URL( + environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}/messages`, + ), + ); + requestUrl.searchParams.set("beforeCreatedAt", input.before.createdAt); + requestUrl.searchParams.set("beforeMessageId", input.before.messageId); + requestUrl.searchParams.set("turnLimit", String(input.turnLimit)); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl.toString(), + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl.toString(), + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadMessages({ + params: { threadId: input.threadId }, + query: { + beforeCreatedAt: input.before.createdAt, + beforeMessageId: input.before.messageId, + turnLimit: input.turnLimit, + }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadMessagesAfter = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessagesAfter", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + readonly turnLimit: number; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = new URL( + environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}/messages/after`, + ), + ); + requestUrl.searchParams.set("afterCreatedAt", input.after.createdAt); + requestUrl.searchParams.set("afterMessageId", input.after.messageId); + requestUrl.searchParams.set("turnLimit", String(input.turnLimit)); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl.toString(), + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl.toString(), + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadMessagesAfter({ + params: { threadId: input.threadId }, + query: { + afterCreatedAt: input.after.createdAt, + afterMessageId: input.after.messageId, + turnLimit: input.turnLimit, + }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadMessagesAround = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessagesAround", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = new URL( + environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}/messages/${input.messageId}/around`, + ), + ); + requestUrl.searchParams.set("turnLimit", String(THREAD_TURN_PAGE_SIZE)); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl.toString(), + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl.toString(), + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadMessagesAround({ + params: { threadId: input.threadId, messageId: input.messageId }, + query: { turnLimit: THREAD_TURN_PAGE_SIZE }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadHistoryOutline = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadHistoryOutline", )(function* (input: { readonly prepared: PreparedConnection; readonly threadId: ThreadId; @@ -36,7 +209,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( }) { const requestUrl = environmentEndpointUrl( input.prepared.httpBaseUrl, - `/api/orchestration/threads/${input.threadId}`, + `/api/orchestration/threads/${input.threadId}/history/outline`, ); const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); const headers = yield* buildEnvironmentAuthHeaders( @@ -50,7 +223,7 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, withEnvironmentCredentials( input.prepared.httpAuthorization, - client.orchestration.threadSnapshot({ + client.orchestration.threadHistoryOutline({ params: { threadId: input.threadId }, headers, }), @@ -72,7 +245,29 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + turnLimit?: number, ) => Effect.Effect>; + readonly loadPreviousMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + before: OrchestrationThreadMessageCursor, + turnLimit: number, + ) => Effect.Effect>; + readonly loadNextMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + after: OrchestrationThreadMessageCursor, + turnLimit: number, + ) => Effect.Effect>; + readonly loadMessagesAround: ( + prepared: PreparedConnection, + threadId: ThreadId, + messageId: MessageId, + ) => Effect.Effect>; + readonly loadHistoryOutline: ( + prepared: PreparedConnection, + threadId: ThreadId, + ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -89,8 +284,13 @@ export const threadSnapshotLoaderLayer: Layer.Layer< // connections work without one). const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner); return ThreadSnapshotLoader.of({ - load: (prepared: PreparedConnection, threadId: ThreadId) => - fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( + load: (prepared: PreparedConnection, threadId: ThreadId, turnLimit?: number) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(turnLimit === undefined ? {} : { turnLimit }), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket @@ -115,6 +315,104 @@ export const threadSnapshotLoaderLayer: Layer.Layer< ), ), ), + loadPreviousMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + before: OrchestrationThreadMessageCursor, + turnLimit: number, + ) => + fetchEnvironmentThreadMessagesBefore({ + prepared, + threadId, + before, + turnLimit, + signer, + }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catchTags({ + EnvironmentResourceNotFoundError: () => + Effect.logDebug("Thread message history was not found.").pipe( + Effect.annotateLogs({ threadId }), + Effect.as(Option.none()), + ), + }), + Effect.catchCause((cause) => + Effect.logWarning("Could not load previous thread messages.").pipe( + Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), + loadNextMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + after: OrchestrationThreadMessageCursor, + turnLimit: number, + ) => + fetchEnvironmentThreadMessagesAfter({ + prepared, + threadId, + after, + turnLimit, + signer, + }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catchTags({ + EnvironmentResourceNotFoundError: () => + Effect.logDebug("Thread message history was not found.").pipe( + Effect.annotateLogs({ threadId }), + Effect.as(Option.none()), + ), + }), + Effect.catchCause((cause) => + Effect.logWarning("Could not load next thread messages.").pipe( + Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), + loadMessagesAround: ( + prepared: PreparedConnection, + threadId: ThreadId, + messageId: MessageId, + ) => + fetchEnvironmentThreadMessagesAround({ prepared, threadId, messageId, signer }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catchTags({ + EnvironmentResourceNotFoundError: () => + Effect.logDebug("Thread message history target was not found.").pipe( + Effect.annotateLogs({ threadId, messageId }), + Effect.as(Option.none()), + ), + }), + Effect.catchCause((cause) => + Effect.logWarning("Could not load thread messages around the target.").pipe( + Effect.annotateLogs({ threadId, messageId, cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), + loadHistoryOutline: (prepared: PreparedConnection, threadId: ThreadId) => + fetchEnvironmentThreadHistoryOutline({ prepared, threadId, signer }).pipe( + Effect.map(Option.some), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.catchTags({ + EnvironmentResourceNotFoundError: () => + Effect.logDebug("Thread history outline was not found.").pipe( + Effect.annotateLogs({ threadId }), + Effect.as(Option.none()), + ), + }), + Effect.catchCause((cause) => + Effect.logWarning("Could not load the thread history outline.").pipe( + Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }), + Effect.as(Option.none()), + ), + ), + ), }); }), ); diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e925..6a88dbdba20 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -1,16 +1,33 @@ -import type { OrchestrationThread } from "@t3tools/contracts"; +import type { + OrchestrationThread, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; +export type EnvironmentThreadHistoryState = + | { readonly kind: "disabled" } + | { + readonly kind: "ready"; + readonly outline: OrchestrationThreadHistoryOutline | null; + readonly window: OrchestrationThreadHistoryPage | null; + readonly loading: "before" | "after" | "around" | null; + }; + export interface EnvironmentThreadState { readonly data: Option.Option; + readonly liveData: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly history: EnvironmentThreadHistoryState; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), + liveData: Option.none(), status: "empty", error: Option.none(), + history: { kind: "disabled" }, }; diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 9a227c3740b..a38365bfa52 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -1,6 +1,7 @@ import { EnvironmentId, EventId, + MessageId, ORCHESTRATION_WS_METHODS, ProjectId, ProviderInstanceId, @@ -11,6 +12,7 @@ import { type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; @@ -36,6 +38,7 @@ import { ThreadSnapshotLoader, type EnvironmentThreadState, } from "./threads.ts"; +import { THREAD_TURN_PAGE_SIZE } from "./threadSnapshotHttp.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -104,15 +107,17 @@ type TestThreadInput = OrchestrationThreadStreamItem | Error; function testSession( client: WsRpcProtocolClient, - options?: { readonly completionMarker?: boolean }, + options?: { + readonly completionMarker?: boolean; + readonly messagePagination?: boolean; + }, ): RpcSession.RpcSession { return { client, - initialConfig: Effect.succeed( - options?.completionMarker === true - ? ({ threadResumeCompletionMarker: true } as never) - : ({} as never), - ), + initialConfig: Effect.succeed({ + ...(options?.completionMarker === true ? { threadResumeCompletionMarker: true } : {}), + ...(options?.messagePagination === true ? { threadMessagePagination: true } : {}), + } as never), ready: Effect.void, probe: Effect.void, closed: Effect.never, @@ -133,7 +138,9 @@ function awaitThreadState( const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: { readonly cached?: OrchestrationThread; readonly httpSnapshot?: Option.Option; + readonly httpSnapshotEffect?: Effect.Effect>; readonly completionMarker?: boolean; + readonly messagePagination?: boolean; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -141,6 +148,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o const retryCount = yield* Ref.make(0); const subscriptionCount = yield* Ref.make(0); const loaderCalls = yield* Ref.make(0); + const lastLoaderTurnLimit = yield* Ref.make(undefined); const lastSubscribeAfterSequence = yield* Ref.make(undefined); const lastRequestCompletionMarker = yield* Ref.make(undefined); const savedThreads = yield* Ref.make>([]); @@ -172,7 +180,12 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Option.some( testSession( client, - options?.completionMarker === true ? { completionMarker: true } : undefined, + options?.completionMarker === true || options?.messagePagination === true + ? { + ...(options.completionMarker === true ? { completionMarker: true } : {}), + ...(options.messagePagination === true ? { messagePagination: true } : {}), + } + : undefined, ), ), ); @@ -180,14 +193,22 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Option.some(PREPARED), ); const snapshotLoader = ThreadSnapshotLoader.of({ - load: (_prepared, threadId) => + load: (_prepared, threadId, turnLimit) => Ref.update(loaderCalls, (count) => count + 1).pipe( - Effect.as( + Effect.andThen(Ref.set(lastLoaderTurnLimit, turnLimit)), + Effect.andThen( threadId === THREAD_ID - ? (options?.httpSnapshot ?? Option.none()) - : Option.none(), + ? (options?.httpSnapshotEffect ?? + Effect.succeed( + options?.httpSnapshot ?? Option.none(), + )) + : Effect.succeed(Option.none()), ), ), + loadPreviousMessages: () => Effect.succeed(Option.none()), + loadNextMessages: () => Effect.succeed(Option.none()), + loadMessagesAround: () => Effect.succeed(Option.none()), + loadHistoryOutline: () => Effect.succeed(Option.none()), }); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, @@ -245,6 +266,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o retryCount, subscriptionCount, loaderCalls, + lastLoaderTurnLimit, lastSubscribeAfterSequence, lastRequestCompletionMarker, supervisorState, @@ -257,7 +279,12 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Option.some( testSession( client, - options?.completionMarker === true ? { completionMarker: true } : undefined, + options?.completionMarker === true || options?.messagePagination === true + ? { + ...(options.completionMarker === true ? { completionMarker: true } : {}), + ...(options.messagePagination === true ? { messagePagination: true } : {}), + } + : undefined, ), ), ), @@ -326,6 +353,72 @@ describe("EnvironmentThreads", () => { }), ); + it.effect("does not publish an unbounded legacy cache before loading a paginated snapshot", () => + Effect.gen(function* () { + const messages = Array.from({ length: THREAD_TURN_PAGE_SIZE * 2 + 2 }, (_, index) => ({ + id: MessageId.make(`message-${index.toString().padStart(3, "0")}`), + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `message ${index}`, + turnId: null, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + })); + const snapshotReady = + yield* Deferred.make>(); + const boundedMessages = messages.slice(-THREAD_TURN_PAGE_SIZE * 2); + const boundedThread: OrchestrationThread = { + ...BASE_THREAD, + messages: boundedMessages, + messageHistory: { + hasMoreBefore: true, + hasMoreAfter: false, + startIndex: 2, + endIndex: THREAD_TURN_PAGE_SIZE * 2 + 2, + totalMessages: THREAD_TURN_PAGE_SIZE * 2 + 2, + cursor: { + createdAt: boundedMessages[0]!.createdAt, + messageId: boundedMessages[0]!.id, + }, + }, + }; + const harness = yield* makeHarness({ + cached: { ...BASE_THREAD, messages }, + httpSnapshotEffect: Deferred.await(snapshotReady), + messagePagination: true, + }); + + const provisional = yield* awaitThreadState( + harness.observed, + (value) => value.status === "synchronizing" && Option.isNone(value.data), + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.loaderCalls)) > 0) break; + yield* Effect.yieldNow; + } + + expect(Option.isNone(provisional.data)).toBe(true); + expect(yield* Ref.get(harness.lastLoaderTurnLimit)).toBe(THREAD_TURN_PAGE_SIZE); + + yield* Deferred.succeed( + snapshotReady, + Option.some({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + thread: boundedThread, + }), + ); + const loaded = yield* awaitThreadState( + harness.observed, + (value) => Option.isSome(value.data) && value.data.value.messageHistory !== undefined, + ); + + expect(Option.getOrThrow(loaded.data).messages).toHaveLength(THREAD_TURN_PAGE_SIZE * 2); + expect(Option.getOrThrow(loaded.data).messageHistory?.totalMessages).toBe( + THREAD_TURN_PAGE_SIZE * 2 + 2, + ); + }), + ); + it.effect("resumes a warm cache via afterSequence without an HTTP fetch", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b993c8b8946..b4a48bb67b4 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1,7 +1,9 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, + type MessageId, type OrchestrationThread, + type OrchestrationThreadHistoryPage, type OrchestrationThreadDeltaStreamItem, type OrchestrationThreadDetailSnapshot, type ThreadId as ThreadIdType, @@ -11,6 +13,8 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; @@ -20,18 +24,38 @@ import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { ThreadHistoryCacheStore } from "../platform/threadHistoryCache.ts"; import { subscribeDynamicRequest } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { THREAD_TURN_PAGE_SIZE, ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { + boundLiveThread, + boundThreadHistoryPage, + displayThreadHistory, + mergeThreadHistoryPages, +} from "./threadHistory.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; -import { followStreamInEnvironment } from "./runtime.ts"; +import { + createAtomCommandScheduler, + createEnvironmentCommand, + followStreamInEnvironment, +} from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadHistoryState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; +export interface EnvironmentThreadStateOptions { + readonly loadHistoryOutline?: boolean; + readonly messagePagination?: { + readonly enabled: () => boolean; + readonly changes?: Stream.Stream; + }; +} + function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; } @@ -50,12 +74,46 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, + options?: EnvironmentThreadStateOptions, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; + const historyCache = yield* ThreadHistoryCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); + const scope = yield* Scope.Scope; const environmentId = supervisor.target.environmentId; + const cacheHistoryPage = Effect.fn("EnvironmentThreadState.cacheHistoryPage")(function* ( + page: OrchestrationThreadHistoryPage, + writeToken: number, + ) { + yield* historyCache.save(environmentId, threadId, page, writeToken).pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not persist cached thread history.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + }), + ); + }); + const clearHistoryCache = Effect.fn("EnvironmentThreadState.clearHistoryCache")(function* () { + yield* historyCache.remove(environmentId, threadId).pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not clear cached thread history.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + ), + }), + ); + }); const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( @@ -68,23 +126,55 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ), ); - const cachedThread = Option.map(cached, (snapshot) => snapshot.thread); + const cachedThread = Option.map(cached, (snapshot) => boundLiveThread(snapshot.thread)); + const initiallyMessagePaginationEnabled = options?.messagePagination?.enabled() ?? true; + const initiallyVisibleCachedThread = Option.filter(cachedThread, (thread) => + initiallyMessagePaginationEnabled + ? thread.messageHistory !== undefined + : thread.messageHistory === undefined, + ); const state = yield* SubscriptionRef.make({ - data: cachedThread, - status: statusWithoutLiveData(cachedThread), + data: initiallyVisibleCachedThread, + liveData: initiallyVisibleCachedThread, + status: statusWithoutLiveData(initiallyVisibleCachedThread), error: Option.none(), + history: { kind: "disabled" }, }); + const liveThread = yield* Ref.make(cachedThread); + const historyOutlineRefreshes = yield* SubscriptionRef.make(0); + const threadSnapshotRefreshes = yield* SubscriptionRef.make(0); + const messagePaginationSupported = yield* SubscriptionRef.make(false); + const aroundLoadSemaphore = yield* Semaphore.make(1); // Seed the resume cursor from the cached snapshot so a warm cache can catch up // via `afterSequence` instead of re-downloading the full thread body. const lastSequence = yield* SubscriptionRef.make( Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }), ); const awaitingCompletion = yield* Ref.make(false); - const persistence = yield* Queue.sliding(1); + const persistence = yield* Queue.sliding<{ + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly historyCacheWriteToken: number; + }>(1); + const preparedConnection = SubscriptionRef.get(supervisor.prepared).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + SubscriptionRef.changes(supervisor.prepared).pipe( + Stream.filter(Option.isSome), + Stream.map((value) => value.value), + Stream.runHead, + Effect.map(Option.getOrThrow), + ), + }), + ), + ); - const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( - snapshot: OrchestrationThreadDetailSnapshot, - ) { + const persist = Effect.fn("EnvironmentThreadState.persist")(function* (input: { + readonly snapshot: OrchestrationThreadDetailSnapshot; + readonly historyCacheWriteToken: number; + }) { + const { snapshot } = input; yield* cache.saveThread(environmentId, snapshot).pipe( Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( @@ -96,6 +186,22 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ), ); + const firstMessage = snapshot.thread.messages[0]; + if (snapshot.thread.messageHistory !== undefined && firstMessage !== undefined) { + yield* cacheHistoryPage( + { + messages: snapshot.thread.messages, + activities: snapshot.thread.activities.filter( + (activity) => activity.createdAt >= firstMessage.createdAt, + ), + proposedPlans: snapshot.thread.proposedPlans.filter( + (plan) => plan.createdAt >= firstMessage.createdAt, + ), + messageHistory: snapshot.thread.messageHistory, + }, + input.historyCacheWriteToken, + ); + } }); yield* Stream.fromQueue(persistence).pipe( @@ -144,27 +250,573 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, ) { + const boundedThread = boundLiveThread(thread); + yield* Ref.set(liveThread, Option.some(boundedThread)); const waiting = yield* Ref.get(awaitingCompletion); - yield* SubscriptionRef.set(state, { - data: Option.some(thread), - status: waiting ? "synchronizing" : "live", + const status: EnvironmentThreadStatus = waiting ? "synchronizing" : "live"; + yield* SubscriptionRef.update(state, (current) => ({ + ...current, + data: Option.some(displayThreadHistory(boundedThread, current.history)), + liveData: Option.some(boundedThread), + status, error: Option.none(), - }); + })); // Active threads can update many times per second and retain large tool // payloads. The server remains the source of truth while a turn is active; // persist once it settles so cache encoding stays off the streaming path. - if (shouldPersistThread(thread)) { + if (shouldPersistThread(boundedThread)) { const snapshotSequence = yield* SubscriptionRef.get(lastSequence); - yield* Queue.offer(persistence, { snapshotSequence, thread }); + const historyCacheWriteToken = yield* historyCache.captureWriteToken(); + yield* Queue.offer(persistence, { + snapshot: { snapshotSequence, thread: boundedThread }, + historyCacheWriteToken, + }); + } + }); + + let latestHistoryOutlineRequestId = 0; + let historyOutlineLoading = false; + const loadHistoryOutline = Effect.gen(function* () { + if (options?.loadHistoryOutline === false) { + return; + } + const current = yield* SubscriptionRef.get(state); + if (current.history.kind === "disabled" || current.history.outline !== null) { + return; + } + if (historyOutlineLoading) { + return; + } + historyOutlineLoading = true; + const requestId = ++latestHistoryOutlineRequestId; + yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const outline = yield* snapshotLoader.loadHistoryOutline(prepared, threadId); + if (Option.isNone(outline) || requestId !== latestHistoryOutlineRequestId) { + return; + } + yield* SubscriptionRef.update(state, (latest) => + latest.history.kind === "disabled" + ? latest + : { + ...latest, + history: { ...latest.history, outline: outline.value }, + }, + ); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (requestId === latestHistoryOutlineRequestId) { + historyOutlineLoading = false; + } + }), + ), + ); + }); + + const invalidateHistoryOutline = Effect.sync(() => { + latestHistoryOutlineRequestId += 1; + historyOutlineLoading = false; + }); + + let latestHistoryWindowRequestId = 0; + const loadPreviousMessages = Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + const currentLiveThread = yield* Ref.get(liveThread); + if ( + current.history.kind === "disabled" || + current.history.loading !== null || + Option.isNone(currentLiveThread) + ) { + return false; + } + const sourceHistory = + current.history.window?.messageHistory ?? currentLiveThread.value.messageHistory; + const cursor = sourceHistory?.cursor; + if ( + sourceHistory === undefined || + !sourceHistory.hasMoreBefore || + cursor === null || + cursor === undefined + ) { + return false; + } + const requestId = ++latestHistoryWindowRequestId; + const historyCacheWriteToken = yield* historyCache.captureWriteToken(); + + const historyLookup = yield* historyCache + .loadPrevious(environmentId, threadId, sourceHistory.startIndex, THREAD_TURN_PAGE_SIZE) + .pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not load cached previous thread messages.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as({ + page: Option.none(), + requestLimit: THREAD_TURN_PAGE_SIZE, + }), + ), + }), + ); + if (Option.isSome(historyLookup.page)) { + const cachedPage = historyLookup.page.value; + return yield* SubscriptionRef.modify(state, (latest) => { + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== null || + Option.isNone(latest.liveData) + ) { + return [false, latest]; + } + const latestLiveThread = latest.liveData.value; + const liveMessages = latestLiveThread.messages; + const firstLiveMessage = liveMessages[0]; + const currentWindow = latest.history.window ?? { + messages: liveMessages, + activities: + firstLiveMessage === undefined + ? [] + : latestLiveThread.activities.filter( + (activity) => activity.createdAt >= firstLiveMessage.createdAt, + ), + proposedPlans: + firstLiveMessage === undefined + ? [] + : latestLiveThread.proposedPlans.filter( + (plan) => plan.createdAt >= firstLiveMessage.createdAt, + ), + messageHistory: latestLiveThread.messageHistory ?? { + hasMoreBefore: false, + hasMoreAfter: false, + startIndex: 0, + endIndex: liveMessages.length, + totalMessages: liveMessages.length, + cursor: null, + }, + }; + const window = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: cachedPage, + newer: currentWindow, + }), + "older", + ); + const history: EnvironmentThreadHistoryState = { ...latest.history, window }; + return [ + true, + { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread, history)), + history, + }, + ]; + }); + } + + const startedLoading = yield* SubscriptionRef.modify(state, (latest) => { + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== null + ) { + return [false, latest]; + } + const history: EnvironmentThreadHistoryState = { + ...latest.history, + loading: "before", + }; + return [ + true, + { + ...latest, + history, + }, + ]; + }); + if (!startedLoading) { + return false; + } + return yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const page = yield* snapshotLoader.loadPreviousMessages( + prepared, + threadId, + cursor, + historyLookup.requestLimit, + ); + if (Option.isNone(page)) { + return false; + } + + const latest = yield* SubscriptionRef.get(state); + const latestLiveThread = yield* Ref.get(liveThread); + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== "before" || + Option.isNone(latestLiveThread) + ) { + return false; + } + const liveMessages = latestLiveThread.value.messages; + const firstLiveMessage = liveMessages[0]; + const currentWindow = latest.history.window ?? { + messages: liveMessages, + activities: + firstLiveMessage === undefined + ? [] + : latestLiveThread.value.activities.filter( + (activity) => activity.createdAt >= firstLiveMessage.createdAt, + ), + proposedPlans: + firstLiveMessage === undefined + ? [] + : latestLiveThread.value.proposedPlans.filter( + (plan) => plan.createdAt >= firstLiveMessage.createdAt, + ), + messageHistory: latestLiveThread.value.messageHistory ?? { + hasMoreBefore: false, + hasMoreAfter: false, + startIndex: 0, + endIndex: liveMessages.length, + totalMessages: liveMessages.length, + cursor: null, + }, + }; + const window = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: page.value, + newer: currentWindow, + }), + "older", + ); + const history: EnvironmentThreadHistoryState = { + ...latest.history, + loading: null, + window, + }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, + }); + yield* Effect.forkIn(cacheHistoryPage(page.value, historyCacheWriteToken), scope); + return true; + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + latest.history.kind === "ready" && latest.history.loading === "before" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), + ), + ); + }); + + const loadNextMessages = Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + const currentLiveThread = yield* Ref.get(liveThread); + const window = current.history.kind === "ready" ? current.history.window : null; + const lastMessage = window?.messages.at(-1); + if ( + current.history.kind === "disabled" || + current.history.loading !== null || + window === null || + !window.messageHistory.hasMoreAfter || + lastMessage === undefined || + Option.isNone(currentLiveThread) + ) { + return false; + } + const requestId = ++latestHistoryWindowRequestId; + const historyCacheWriteToken = yield* historyCache.captureWriteToken(); + + const historyLookup = yield* historyCache + .loadNext(environmentId, threadId, window.messageHistory.endIndex, THREAD_TURN_PAGE_SIZE) + .pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not load cached next thread messages.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as({ + page: Option.none(), + requestLimit: THREAD_TURN_PAGE_SIZE, + }), + ), + }), + ); + if (Option.isSome(historyLookup.page)) { + const cachedPage = historyLookup.page.value; + return yield* SubscriptionRef.modify(state, (latest) => { + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== null || + latest.history.window === null || + Option.isNone(latest.liveData) + ) { + return [false, latest]; + } + const latestLiveThread = latest.liveData.value; + const nextWindow = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: latest.history.window, + newer: cachedPage, + }), + "newer", + ); + const history: EnvironmentThreadHistoryState = { + ...latest.history, + window: nextWindow, + }; + return [ + true, + { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread, history)), + history, + }, + ]; + }); } + + const startedLoading = yield* SubscriptionRef.modify(state, (latest) => { + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== null || + latest.history.window === null + ) { + return [false, latest]; + } + const history: EnvironmentThreadHistoryState = { + ...latest.history, + loading: "after", + }; + return [ + true, + { + ...latest, + history, + }, + ]; + }); + if (!startedLoading) { + return false; + } + return yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const page = yield* snapshotLoader.loadNextMessages( + prepared, + threadId, + { + createdAt: lastMessage.createdAt, + messageId: lastMessage.id, + }, + historyLookup.requestLimit, + ); + if (Option.isNone(page)) { + return false; + } + + const latest = yield* SubscriptionRef.get(state); + const latestLiveThread = yield* Ref.get(liveThread); + if ( + requestId !== latestHistoryWindowRequestId || + latest.history.kind === "disabled" || + latest.history.loading !== "after" || + latest.history.window === null || + Option.isNone(latestLiveThread) + ) { + return false; + } + const nextWindow = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: latest.history.window, + newer: page.value, + }), + "newer", + ); + const history: EnvironmentThreadHistoryState = { + ...latest.history, + loading: null, + window: nextWindow, + }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, + }); + yield* Effect.forkIn(cacheHistoryPage(page.value, historyCacheWriteToken), scope); + return true; + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + latest.history.kind === "ready" && latest.history.loading === "after" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), + ), + ); + }); + + let latestAroundRequestId = 0; + const loadMessagesAround = Effect.fn("EnvironmentThreadState.loadMessagesAround")(function* ( + messageId: MessageId, + ) { + latestHistoryWindowRequestId += 1; + const requestId = ++latestAroundRequestId; + return yield* Effect.gen(function* () { + if (requestId !== latestAroundRequestId) { + return false; + } + const historyCacheWriteToken = yield* historyCache.captureWriteToken(); + + const current = yield* SubscriptionRef.get(state); + if (current.history.kind === "disabled") { + return false; + } + yield* SubscriptionRef.set(state, { + ...current, + history: { ...current.history, loading: "around" }, + }); + const cachedPage = yield* historyCache + .loadAround(environmentId, threadId, messageId, THREAD_TURN_PAGE_SIZE) + .pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not load cached thread messages around the target.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as(Option.none()), + ), + }), + ); + const page = Option.isSome(cachedPage) + ? cachedPage + : yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + return yield* snapshotLoader.loadMessagesAround(prepared, threadId, messageId); + }); + if (Option.isNone(page) || requestId !== latestAroundRequestId) { + return false; + } + + const latest = yield* SubscriptionRef.get(state); + const latestLiveThread = yield* Ref.get(liveThread); + if ( + latest.history.kind === "disabled" || + latest.history.loading !== "around" || + Option.isNone(latestLiveThread) + ) { + return false; + } + const totalMessages = Math.max( + page.value.messageHistory.totalMessages, + latestLiveThread.value.messageHistory?.totalMessages ?? + latestLiveThread.value.messages.length, + ); + const window = Option.isSome(cachedPage) + ? { + ...page.value, + messageHistory: { + ...page.value.messageHistory, + hasMoreAfter: page.value.messageHistory.endIndex < totalMessages, + totalMessages, + }, + } + : page.value; + const history: EnvironmentThreadHistoryState = { + ...latest.history, + window, + }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, + }); + if (Option.isNone(cachedPage)) { + yield* Effect.forkIn(cacheHistoryPage(page.value, historyCacheWriteToken), scope); + } + return true; + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + requestId === latestAroundRequestId && + latest.history.kind === "ready" && + latest.history.loading === "around" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), + ), + aroundLoadSemaphore.withPermit, + ); + }); + + const cancelMessagesAround = Effect.gen(function* () { + latestAroundRequestId += 1; + yield* SubscriptionRef.update(state, (current) => + current.history.kind === "ready" && current.history.loading === "around" + ? { + ...current, + history: { ...current.history, loading: null }, + } + : current, + ); + }); + + const showLatestMessages = Effect.gen(function* () { + latestAroundRequestId += 1; + latestHistoryWindowRequestId += 1; + const current = yield* SubscriptionRef.get(state); + const currentLiveThread = yield* Ref.get(liveThread); + if (current.history.kind === "disabled" || Option.isNone(currentLiveThread)) { + return false; + } + const history: EnvironmentThreadHistoryState = { + ...current.history, + window: null, + loading: null, + }; + yield* SubscriptionRef.set(state, { + ...current, + data: Option.some(displayThreadHistory(currentLiveThread.value, history)), + history, + }); + return true; }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.set(liveThread, Option.none()); + yield* clearHistoryCache(); yield* SubscriptionRef.set(state, { data: Option.none(), + liveData: Option.none(), status: "deleted", error: Option.none(), + history: { kind: "disabled" }, }); yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => @@ -198,6 +850,31 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (item.kind === "snapshot") { + if (item.snapshot.thread.messageHistory !== undefined) { + // A bounded snapshot may replace an event replay, so cached segments + // cannot be trusted to have survived an unseen revert. + latestHistoryWindowRequestId += 1; + latestAroundRequestId += 1; + yield* clearHistoryCache(); + const current = yield* SubscriptionRef.get(state); + const refreshOutline = current.history.kind === "ready"; + yield* invalidateHistoryOutline; + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = + current.history.kind === "disabled" + ? current.history + : { + kind: "ready", + outline: null, + window: null, + loading: null, + }; + return { ...current, history }; + }); + if (refreshOutline) { + yield* SubscriptionRef.update(historyOutlineRefreshes, (revision) => revision + 1); + } + } yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); return; @@ -209,16 +886,66 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } yield* SubscriptionRef.set(lastSequence, item.event.sequence); - const current = yield* SubscriptionRef.get(state); - if (Option.isNone(current.data)) { + const currentLiveThread = yield* Ref.get(liveThread); + if (Option.isNone(currentLiveThread)) { if (item.event.type === "thread.deleted") { yield* setDeleted(); } return; } - const result = applyThreadDetailEvent(current.data.value, item.event); + const result = applyThreadDetailEvent(currentLiveThread.value, item.event); if (result.kind === "updated") { + const sentUserMessage = + item.event.type === "thread.message-sent" && item.event.payload.role === "user"; + if (item.event.type === "thread.reverted") { + latestHistoryWindowRequestId += 1; + latestAroundRequestId += 1; + yield* clearHistoryCache(); + const current = yield* SubscriptionRef.get(state); + const refreshOutline = current.history.kind === "ready"; + yield* invalidateHistoryOutline; + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = + current.history.kind === "disabled" + ? current.history + : { + kind: "ready", + outline: null, + window: null, + loading: null, + }; + return { ...current, history }; + }); + if (refreshOutline) { + yield* SubscriptionRef.update(historyOutlineRefreshes, (revision) => revision + 1); + } + } else if (sentUserMessage) { + // The event may come from another paired client. Local send handlers + // own navigation to the live tail, so preserve this client's window. + const current = yield* SubscriptionRef.get(state); + const refreshOutline = current.history.kind === "ready"; + yield* invalidateHistoryOutline; + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = + current.history.kind === "disabled" + ? current.history + : { + ...current.history, + outline: null, + }; + return { ...current, history }; + }); + if (refreshOutline) { + yield* SubscriptionRef.update(historyOutlineRefreshes, (revision) => revision + 1); + } + } yield* setThread(result.thread); + if ( + item.event.type === "thread.reverted" && + (yield* SubscriptionRef.get(messagePaginationSupported)) + ) { + yield* SubscriptionRef.update(threadSnapshotRefreshes, (revision) => revision + 1); + } } else if (result.kind === "deleted") { yield* setDeleted(); } @@ -243,51 +970,92 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onSome: (service) => service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); + const snapshotResubscriptions = SubscriptionRef.changes(threadSnapshotRefreshes).pipe( + Stream.filter((revision) => revision > 0), + ); + const messagePaginationResubscriptions = options?.messagePagination?.changes ?? Stream.never; + yield* SubscriptionRef.changes(historyOutlineRefreshes).pipe( + Stream.filter((revision) => revision > 0), + Stream.runForEach(() => loadHistoryOutline), + Effect.forkScoped, + ); yield* setSynchronizing; yield* Effect.forkScoped( subscribeDynamicRequest( Effect.fn("EnvironmentThreadState.makeSubscriptionRequest")(function* (session) { + const messagePaginationEnabled = options?.messagePagination?.enabled() ?? true; const subscriptionCapabilities = yield* session.initialConfig.pipe( Effect.map((config) => ({ completionMarker: config.threadResumeCompletionMarker === true, + messagePagination: messagePaginationEnabled && config.threadMessagePagination === true, threadDeltaSubscription: config.environment?.capabilities.threadDeltaSubscription === true, })), Effect.orElseSucceed(() => ({ completionMarker: false, + messagePagination: false, threadDeltaSubscription: false, })), ); const supportsCompletionMarker = subscriptionCapabilities.completionMarker; + const supportsMessagePagination = subscriptionCapabilities.messagePagination; yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; + const currentLiveThread = yield* Ref.get(liveThread); + const visibleLiveThread = Option.filter(currentLiveThread, (thread) => + supportsMessagePagination + ? thread.messageHistory !== undefined + : thread.messageHistory === undefined, + ); + if (Option.isSome(currentLiveThread) && Option.isNone(visibleLiveThread)) { + yield* Ref.set(liveThread, Option.none()); + } + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = supportsMessagePagination + ? current.history.kind === "ready" + ? current.history + : { + kind: "ready", + outline: null, + window: null, + loading: null, + } + : { kind: "disabled" }; + return { + ...current, + data: Option.map(visibleLiveThread, (thread) => displayThreadHistory(thread, history)), + liveData: visibleLiveThread, + history, + }; + }); let current = yield* SubscriptionRef.get(state); - if (Option.isNone(current.data) && current.status !== "deleted") { - const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - SubscriptionRef.changes(supervisor.prepared).pipe( - Stream.filter(Option.isSome), - Stream.map((value) => value.value), - Stream.runHead, - Effect.map(Option.getOrThrow), - ), - }), - ), + if ( + current.status !== "deleted" && + (Option.isNone(current.data) || + (supportsMessagePagination && current.data.value.messageHistory === undefined)) + ) { + const prepared = yield* preparedConnection; + const httpSnapshot = yield* snapshotLoader.load( + prepared, + threadId, + supportsMessagePagination ? THREAD_TURN_PAGE_SIZE : undefined, ); - const httpSnapshot = yield* snapshotLoader.load(prepared, threadId); if (Option.isSome(httpSnapshot)) { yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); current = yield* SubscriptionRef.get(state); } } + yield* SubscriptionRef.set(messagePaginationSupported, supportsMessagePagination); + if (supportsMessagePagination) { + yield* Effect.forkIn(loadHistoryOutline, scope); + } const sequence = yield* SubscriptionRef.get(lastSequence); - const canResume = Option.isSome(current.data); + const canResume = + Option.isSome(current.data) && + (!supportsMessagePagination || current.data.value.messageHistory !== undefined); if (!supportsCompletionMarker && canResume) { yield* SubscriptionRef.update(state, (value) => ({ ...value, @@ -302,6 +1070,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : ORCHESTRATION_WS_METHODS.subscribeThread, input: { threadId, + ...(supportsMessagePagination ? { turnLimit: THREAD_TURN_PAGE_SIZE } : {}), ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }, @@ -310,30 +1079,82 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make { onExpectedFailure: setStreamError, retryExpectedFailureAfter: "250 millis", - resubscribe: foregroundResubscriptions, + resubscribe: Stream.merge( + foregroundResubscriptions, + Stream.merge(snapshotResubscriptions, messagePaginationResubscriptions), + ), }, ).pipe(Stream.runForEach(applyItem)), ); yield* Effect.addFinalizer(() => - Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe( - Effect.flatMap(([current, snapshotSequence]) => - Option.match(current.data, { + Effect.all([Ref.get(liveThread), SubscriptionRef.get(lastSequence)]).pipe( + Effect.flatMap(([currentLiveThread, snapshotSequence]) => + Option.match(currentLiveThread, { onNone: () => Effect.void, onSome: (thread) => - shouldPersistThread(thread) ? persist({ snapshotSequence, thread }) : Effect.void, + shouldPersistThread(thread) + ? historyCache.captureWriteToken().pipe( + Effect.flatMap((historyCacheWriteToken) => + persist({ + snapshot: { snapshotSequence, thread }, + historyCacheWriteToken, + }), + ), + ) + : Effect.void, }), ), ), ); - return state; + return Object.assign(state, { + loadPreviousMessages, + loadNextMessages, + loadMessagesAround, + cancelMessagesAround, + showLatestMessages, + }); }); -export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { +type EnvironmentThreadStateSubscription = + SubscriptionRef.SubscriptionRef & { + readonly loadPreviousMessages: Effect.Effect; + readonly loadNextMessages: Effect.Effect; + readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; + readonly cancelMessagesAround: Effect.Effect; + readonly showLatestMessages: Effect.Effect; + }; + +export function threadStateChanges( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, + subscriptions?: Map, + options?: EnvironmentThreadStateOptions, +) { + const key = threadKey({ environmentId, threadId }); return followStreamInEnvironment( environmentId, - Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), + Stream.unwrap( + makeEnvironmentThreadState(threadId, options).pipe( + Effect.flatMap((subscription) => + subscriptions === undefined + ? Effect.succeed(SubscriptionRef.changes(subscription)) + : Effect.acquireRelease( + Effect.sync(() => { + subscriptions.set(key, subscription); + return subscription; + }), + (registered) => + Effect.sync(() => { + if (subscriptions.get(key) === registered) { + subscriptions.delete(key); + } + }), + ).pipe(Effect.map(SubscriptionRef.changes)), + ), + ), + ), ); } @@ -342,11 +1163,14 @@ export function createEnvironmentThreadStateAtoms( EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader | R, E >, + options?: EnvironmentThreadStateOptions, ) { + const subscriptions = new Map(); + const scheduler = createAtomCommandScheduler(); const family = Atom.family((key: string) => { const { environmentId, threadId } = parseThreadKey(key); return runtime - .atom(threadStateChanges(environmentId, threadId), { + .atom(threadStateChanges(environmentId, threadId, subscriptions, options), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, }) .pipe( @@ -358,6 +1182,99 @@ export function createEnvironmentThreadStateAtoms( return { stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => family(threadKey({ environmentId, threadId })), + loadPreviousMessages: createEnvironmentCommand(runtime, { + label: "environment-data:thread:load-previous-messages", + execute: (input: { readonly threadId: ThreadIdType }) => + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => { + const subscription = subscriptions.get( + threadKey({ + environmentId: supervisor.target.environmentId, + threadId: input.threadId, + }), + ); + return subscription?.loadPreviousMessages ?? Effect.succeed(false); + }), + ), + scheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + }, + }), + loadNextMessages: createEnvironmentCommand(runtime, { + label: "environment-data:thread:load-next-messages", + execute: (input: { readonly threadId: ThreadIdType }) => + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => { + const subscription = subscriptions.get( + threadKey({ + environmentId: supervisor.target.environmentId, + threadId: input.threadId, + }), + ); + return subscription?.loadNextMessages ?? Effect.succeed(false); + }), + ), + scheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + }, + }), + showLatestMessages: createEnvironmentCommand(runtime, { + label: "environment-data:thread:show-latest-messages", + execute: (input: { readonly threadId: ThreadIdType }) => + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => { + const subscription = subscriptions.get( + threadKey({ + environmentId: supervisor.target.environmentId, + threadId: input.threadId, + }), + ); + return subscription?.showLatestMessages ?? Effect.succeed(false); + }), + ), + scheduler, + concurrency: { mode: "parallel" }, + }), + cancelMessagesAround: createEnvironmentCommand(runtime, { + label: "environment-data:thread:cancel-messages-around", + execute: (input: { readonly threadId: ThreadIdType }) => + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => { + const subscription = subscriptions.get( + threadKey({ + environmentId: supervisor.target.environmentId, + threadId: input.threadId, + }), + ); + return subscription?.cancelMessagesAround ?? Effect.void; + }), + ), + scheduler, + concurrency: { mode: "parallel" }, + }), + loadMessagesAround: createEnvironmentCommand(runtime, { + label: "environment-data:thread:load-messages-around", + execute: (input: { readonly threadId: ThreadIdType; readonly messageId: MessageId }) => + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => { + const subscription = subscriptions.get( + threadKey({ + environmentId: supervisor.target.environmentId, + threadId: input.threadId, + }), + ); + return subscription?.loadMessagesAround(input.messageId) ?? Effect.void; + }), + ), + scheduler, + concurrency: { + mode: "parallel", + }, + }), }; } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..1a13ff7f921 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -24,7 +24,14 @@ import { AuthWebSocketTicketResult, ServerAuthSessionMethod, } from "./auth.ts"; -import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + AuthSessionId, + IsoDateTime, + MessageId, + PositiveInt, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; import { ClientOrchestrationCommand, @@ -32,6 +39,9 @@ import { OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, + ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT, } from "./orchestration.ts"; import { RelayCloudEnvironmentHealthRequest, @@ -457,6 +467,39 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +const EnvironmentOrchestrationThreadSnapshotQuery = Schema.Struct({ + turnLimit: Schema.optional( + PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT)), + ), +}); + +const EnvironmentOrchestrationThreadMessagesQuery = Schema.Struct({ + beforeCreatedAt: IsoDateTime, + beforeMessageId: MessageId, + turnLimit: PositiveInt.check( + Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT), + ), +}); + +const EnvironmentOrchestrationThreadMessagesAfterQuery = Schema.Struct({ + afterCreatedAt: IsoDateTime, + afterMessageId: MessageId, + turnLimit: PositiveInt.check( + Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT), + ), +}); + +const EnvironmentOrchestrationThreadMessageAroundParams = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); + +const EnvironmentOrchestrationThreadMessageAroundQuery = Schema.Struct({ + turnLimit: PositiveInt.check( + Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT), + ), +}); + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -476,10 +519,58 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", { headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadSnapshotQuery, success: OrchestrationThreadDetailSnapshot, error: EnvironmentOrchestrationThreadSnapshotErrors, }).middleware(EnvironmentAuthenticatedAuth), ) + .add( + HttpApiEndpoint.get("threadMessages", "/api/orchestration/threads/:threadId/messages", { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadMessagesQuery, + success: OrchestrationThreadHistoryPage, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get( + "threadMessagesAfter", + "/api/orchestration/threads/:threadId/messages/after", + { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadSnapshotParams, + query: EnvironmentOrchestrationThreadMessagesAfterQuery, + success: OrchestrationThreadHistoryPage, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get( + "threadMessagesAround", + "/api/orchestration/threads/:threadId/messages/:messageId/around", + { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadMessageAroundParams, + query: EnvironmentOrchestrationThreadMessageAroundQuery, + success: OrchestrationThreadHistoryPage, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) + .add( + HttpApiEndpoint.get( + "threadHistoryOutline", + "/api/orchestration/threads/:threadId/history/outline", + { + headers: OptionalBearerHeaders, + params: EnvironmentOrchestrationThreadSnapshotParams, + success: OrchestrationThreadHistoryOutline, + error: EnvironmentOrchestrationThreadSnapshotErrors, + }, + ).middleware(EnvironmentAuthenticatedAuth), + ) .add( HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", { headers: OptionalBearerHeaders, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 71108ed2e34..88ea506060f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -14,6 +14,7 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, ProjectId, ProviderItemId, ThreadId, @@ -365,6 +366,39 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT = 20; + +export const OrchestrationThreadMessageCursor = Schema.Struct({ + createdAt: IsoDateTime, + messageId: MessageId, +}); +export type OrchestrationThreadMessageCursor = typeof OrchestrationThreadMessageCursor.Type; + +export const OrchestrationThreadMessageHistory = Schema.Struct({ + hasMoreBefore: Schema.Boolean, + hasMoreAfter: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + startIndex: NonNegativeInt, + endIndex: NonNegativeInt, + totalMessages: NonNegativeInt, + cursor: Schema.NullOr(OrchestrationThreadMessageCursor), +}); +export type OrchestrationThreadMessageHistory = typeof OrchestrationThreadMessageHistory.Type; + +export const OrchestrationThreadHistoryLandmark = Schema.Struct({ + messageId: MessageId, + ordinal: NonNegativeInt, + messageIndex: Schema.optional(NonNegativeInt), + createdAt: IsoDateTime, + preview: Schema.String, +}); +export type OrchestrationThreadHistoryLandmark = typeof OrchestrationThreadHistoryLandmark.Type; + +export const OrchestrationThreadHistoryOutline = Schema.Struct({ + totalUserMessages: NonNegativeInt, + landmarks: Schema.Array(OrchestrationThreadHistoryLandmark), +}); +export type OrchestrationThreadHistoryOutline = typeof OrchestrationThreadHistoryOutline.Type; + export const ThreadTitleRegeneration = Schema.Struct({ requestId: CommandId, startedAt: IsoDateTime, @@ -403,6 +437,7 @@ export const OrchestrationThread = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(null)), ), messages: Schema.Array(OrchestrationMessage), + messageHistory: Schema.optional(OrchestrationThreadMessageHistory), proposedPlans: Schema.Array(OrchestrationProposedPlan).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -529,6 +564,9 @@ export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShel export const OrchestrationSubscribeThreadInput = Schema.Struct({ threadId: ThreadId, + turnLimit: Schema.optionalKey( + PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_TURN_PAGE_MAX_LIMIT)), + ), /** * When provided, the server skips the initial snapshot frame and instead * replays events after this sequence before streaming live events. Clients @@ -551,6 +589,14 @@ export const OrchestrationThreadDetailSnapshot = Schema.Struct({ }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; +export const OrchestrationThreadHistoryPage = Schema.Struct({ + messages: Schema.Array(OrchestrationMessage), + proposedPlans: Schema.Array(OrchestrationProposedPlan), + activities: Schema.Array(OrchestrationThreadActivity), + messageHistory: OrchestrationThreadMessageHistory, +}); +export type OrchestrationThreadHistoryPage = typeof OrchestrationThreadHistoryPage.Type; + export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index e083523bbdf..b106471c2ac 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -424,6 +424,8 @@ export const ServerConfig = Schema.Struct({ shellResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), /** Whether thread subscriptions can emit an opt-in catch-up completion marker. */ threadResumeCompletionMarker: Schema.optionalKey(Schema.Boolean), + /** Whether thread snapshots and message history support bounded keyset pages. */ + threadMessagePagination: Schema.optionalKey(Schema.Boolean), }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index aa21265a9fc..7d45a8c3c5c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -108,6 +108,9 @@ export const ClientSettingsSchema = Schema.Struct({ modelOrder: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), }), ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + progressiveThreadHistoryEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -709,6 +712,7 @@ export const ClientSettingsPatch = Schema.Struct({ }), ), ), + progressiveThreadHistoryEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( diff --git a/packages/shared/package.json b/packages/shared/package.json index 9cafac6ec28..2eb928abbd2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -63,6 +63,10 @@ "types": "./src/KeyedCoalescingWorker.ts", "import": "./src/KeyedCoalescingWorker.ts" }, + "./LRUCache": { + "types": "./src/LRUCache.ts", + "import": "./src/LRUCache.ts" + }, "./schemaJson": { "types": "./src/schemaJson.ts", "import": "./src/schemaJson.ts" diff --git a/apps/web/src/lib/lruCache.test.ts b/packages/shared/src/LRUCache.test.ts similarity index 97% rename from apps/web/src/lib/lruCache.test.ts rename to packages/shared/src/LRUCache.test.ts index 449c2081e5d..dd260e8063c 100644 --- a/apps/web/src/lib/lruCache.test.ts +++ b/packages/shared/src/LRUCache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { LRUCache } from "./lruCache"; +import { LRUCache } from "./LRUCache.ts"; describe("LRUCache", () => { it("returns null for missing keys", () => { diff --git a/apps/web/src/lib/lruCache.ts b/packages/shared/src/LRUCache.ts similarity index 67% rename from apps/web/src/lib/lruCache.ts rename to packages/shared/src/LRUCache.ts index 65925734300..c5ba02a44e2 100644 --- a/apps/web/src/lib/lruCache.ts +++ b/packages/shared/src/LRUCache.ts @@ -6,15 +6,17 @@ interface CacheEntry { export class LRUCache { private cache = new Map>(); private totalSize = 0; + private readonly maxEntries: number; + private readonly maxMemoryBytes: number; - constructor( - private readonly maxEntries: number, - private readonly maxMemoryBytes: number, - ) {} + constructor(maxEntries: number, maxMemoryBytes: number) { + this.maxEntries = maxEntries; + this.maxMemoryBytes = maxMemoryBytes; + } get(key: string): T | null { const entry = this.cache.get(key); - if (!entry) { + if (entry === undefined) { return null; } @@ -22,13 +24,19 @@ export class LRUCache { return entry.value; } + *entries(): IterableIterator<[string, T]> { + for (const [key, entry] of this.cache) { + yield [key, entry.value]; + } + } + set(key: string, value: T, approximateSize: number): void { if (approximateSize > this.maxMemoryBytes) { return; } const existing = this.cache.get(key); - if (existing) { + if (existing !== undefined) { this.totalSize -= existing.approximateSize; this.cache.delete(key); } @@ -38,6 +46,16 @@ export class LRUCache { this.totalSize += approximateSize; } + delete(key: string): boolean { + const entry = this.cache.get(key); + if (entry === undefined) { + return false; + } + + this.totalSize -= entry.approximateSize; + return this.cache.delete(key); + } + clear(): void { this.cache.clear(); this.totalSize = 0; @@ -59,7 +77,7 @@ export class LRUCache { } const oldestEntry = this.cache.get(oldestKey); - if (oldestEntry) { + if (oldestEntry !== undefined) { this.totalSize -= oldestEntry.approximateSize; } this.cache.delete(oldestKey); diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.3.patch index 4fa135d5aa0..aacf6144b17 100644 --- a/patches/@legendapp__list@3.3.3.patch +++ b/patches/@legendapp__list@3.3.3.patch @@ -925,6 +925,100 @@ index c2e0f38..5313086 100644 onScroll: onScrollHandler, recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { +diff --git a/react.js b/react.js +--- a/react.js ++++ b/react.js +@@ -1624,7 +1624,10 @@ function prepareMVCP(ctx, dataChanged) { + } else if (scrollTarget !== void 0) { + targetId = getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = idsInView.find((id) => { ++ const index = indexByKey.get(id); ++ return index !== void 0 && (shouldRestorePosition === void 0 || shouldRestorePosition(state.props.data[index], index, state.props.data)); ++ }); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -1638,6 +1641,22 @@ function prepareMVCP(ctx, dataChanged) { + } + } + } ++ if (dataChanged && state.didContainersLayout && scrollTarget === void 0) { ++ const cachedViewportStartIndex = findPositionIndexAtOrBeforeOffset(ctx, prevScroll); ++ if (cachedViewportStartIndex !== void 0) { ++ const viewportEnd = prevScroll + state.scrollLength; ++ for (let index = cachedViewportStartIndex; index < positions.length; index++) { ++ const position = positions[index]; ++ if (position === void 0 || position > viewportEnd) { ++ break; ++ } ++ const id = state.idCache[index]; ++ if (id !== void 0 && !idsInViewWithPositions.some((candidate) => candidate.id === id)) { ++ idsInViewWithPositions.push({ id, position }); ++ } ++ } ++ } ++ } + if (targetId !== void 0 && prevPosition === void 0) { + const targetIndex = indexByKey.get(targetId); + if (targetIndex !== void 0) { +@@ -1650,7 +1669,7 @@ function prepareMVCP(ctx, dataChanged) { + let anchorPositionForLock; + let skipTargetAnchor = false; + const data = state.props.data; +- const shouldValidateLockedAnchor = dataChanged && mvcpData && scrollTarget === void 0 && targetId !== void 0 && (anchorLock == null ? void 0 : anchorLock.id) === targetId && shouldRestorePosition !== void 0; ++ const shouldValidateLockedAnchor = mvcpData && scrollTarget === void 0 && targetId !== void 0 && (anchorLock == null ? void 0 : anchorLock.id) === targetId && shouldRestorePosition !== void 0; + if (shouldValidateLockedAnchor && targetId !== void 0) { + const index = indexByKey.get(targetId); + if (index !== void 0) { +diff --git a/react.mjs b/react.mjs +--- a/react.mjs ++++ b/react.mjs +@@ -1603,7 +1603,10 @@ function prepareMVCP(ctx, dataChanged) { + } else if (scrollTarget !== void 0) { + targetId = getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = idsInView.find((id) => { ++ const index = indexByKey.get(id); ++ return index !== void 0 && (shouldRestorePosition === void 0 || shouldRestorePosition(state.props.data[index], index, state.props.data)); ++ }); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -1617,6 +1620,22 @@ function prepareMVCP(ctx, dataChanged) { + } + } + } ++ if (dataChanged && state.didContainersLayout && scrollTarget === void 0) { ++ const cachedViewportStartIndex = findPositionIndexAtOrBeforeOffset(ctx, prevScroll); ++ if (cachedViewportStartIndex !== void 0) { ++ const viewportEnd = prevScroll + state.scrollLength; ++ for (let index = cachedViewportStartIndex; index < positions.length; index++) { ++ const position = positions[index]; ++ if (position === void 0 || position > viewportEnd) { ++ break; ++ } ++ const id = state.idCache[index]; ++ if (id !== void 0 && !idsInViewWithPositions.some((candidate) => candidate.id === id)) { ++ idsInViewWithPositions.push({ id, position }); ++ } ++ } ++ } ++ } + if (targetId !== void 0 && prevPosition === void 0) { + const targetIndex = indexByKey.get(targetId); + if (targetIndex !== void 0) { +@@ -1629,7 +1648,7 @@ function prepareMVCP(ctx, dataChanged) { + let anchorPositionForLock; + let skipTargetAnchor = false; + const data = state.props.data; +- const shouldValidateLockedAnchor = dataChanged && mvcpData && scrollTarget === void 0 && targetId !== void 0 && (anchorLock == null ? void 0 : anchorLock.id) === targetId && shouldRestorePosition !== void 0; ++ const shouldValidateLockedAnchor = mvcpData && scrollTarget === void 0 && targetId !== void 0 && (anchorLock == null ? void 0 : anchorLock.id) === targetId && shouldRestorePosition !== void 0; + if (shouldValidateLockedAnchor && targetId !== void 0) { + const index = indexByKey.get(targetId); + if (index !== void 0) { diff --git a/reanimated.d.ts b/reanimated.d.ts index 940da28..28dccbe 100644 --- a/reanimated.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d0b4351c48..658017400c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,7 +74,7 @@ patchedDependencies: '@effect/vitest@4.0.0-beta.102': a607339aab944136a084a05f159aa0f5a69776934d4b835dab8f9992f1c13425 '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 + '@legendapp/list@3.3.3': df4e6c4e7647e1096f4a3c17d1756fd6411077a473e8668baa7e59d78aed54ba '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 @@ -213,7 +213,7 @@ importers: version: 56.0.23(@babel/core@7.29.7)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(expo@56.0.17)(react-dom@19.2.3(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@legendapp/list': specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) + version: 3.3.3(patch_hash=df4e6c4e7647e1096f4a3c17d1756fd6411077a473e8668baa7e59d78aed54ba)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -544,8 +544,8 @@ importers: specifier: ^0.9.0 version: 0.9.0 '@legendapp/list': - specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 3.3.3 + version: 3.3.3(patch_hash=df4e6c4e7647e1096f4a3c17d1756fd6411077a473e8668baa7e59d78aed54ba)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -2953,18 +2953,6 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@legendapp/list@3.2.0': - resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} - peerDependencies: - react: '*' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - '@legendapp/list@3.3.3': resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} peerDependencies: @@ -12540,14 +12528,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': + '@legendapp/list@3.3.3(patch_hash=df4e6c4e7647e1096f4a3c17d1756fd6411077a473e8668baa7e59d78aed54ba)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12555,6 +12536,13 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3) + '@legendapp/list@3.3.3(patch_hash=df4e6c4e7647e1096f4a3c17d1756fd6411077a473e8668baa7e59d78aed54ba)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0