From 19c01279a93360fd8d30204abfdc411396f54fbb Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 12:09:56 +0300 Subject: [PATCH 01/31] load thread messages progressively --- FORK.md | 1 + .../features/threads/ThreadDetailScreen.tsx | 6 + .../src/features/threads/ThreadFeed.tsx | 25 ++- .../features/threads/ThreadRouteScreen.tsx | 17 +- .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 193 ++++++++++++++++-- .../Services/ProjectionSnapshotQuery.ts | 9 + apps/server/src/orchestration/http.ts | 27 ++- .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/serverRuntimeStartup.test.ts | 4 + apps/server/src/ws.ts | 3 +- apps/web/src/components/ChatView.tsx | 15 +- .../src/components/chat/MessagesTimeline.tsx | 32 ++- .../src/state/threadSnapshotHttp.ts | 105 +++++++++- .../client-runtime/src/state/threadState.ts | 1 + .../src/state/threads-sync.test.ts | 1 + packages/client-runtime/src/state/threads.ts | 175 ++++++++++++++-- packages/contracts/src/environmentHttp.ts | 33 ++- packages/contracts/src/orchestration.ts | 25 +++ packages/contracts/src/server.ts | 2 + 22 files changed, 625 insertions(+), 57 deletions(-) diff --git a/FORK.md b/FORK.md index d258322a529..f6b0dc7ede1 100644 --- a/FORK.md +++ b/FORK.md @@ -51,6 +51,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### UX Changes +- Thread detail snapshots load a bounded message tail and older messages use keyset pagination. - Desktop context-menu style is configurable. - 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/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..694ae4dd4bd 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -61,6 +61,8 @@ 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 isLoadingPreviousMessages?: boolean; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; readonly projectWorkspaceRoot: string | null; @@ -78,6 +80,7 @@ export interface ThreadDetailScreenProps { readonly onStopThread: () => void; readonly onSendMessage: () => Promise; readonly onReconnectEnvironment: () => void; + readonly onLoadPreviousMessages?: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateThreadInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -370,6 +373,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + hasPreviousMessages={props.hasPreviousMessages} + isLoadingPreviousMessages={props.isLoadingPreviousMessages} + onLoadPreviousMessages={props.onLoadPreviousMessages} skills={selectedProviderSkills} /> diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 37a8639fdbd..d32f99235bf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -140,6 +140,9 @@ export interface ThreadFeedProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly hasPreviousMessages?: boolean; + readonly isLoadingPreviousMessages?: boolean; + readonly onLoadPreviousMessages?: () => void; readonly skills?: ReadonlyArray; } @@ -1792,7 +1795,27 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ - usesNativeAutomaticInsets ? null : + props.hasPreviousMessages ? ( + + + + {props.isLoadingPreviousMessages + ? "Loading earlier messages..." + : "Load earlier messages"} + + + + ) : usesNativeAutomaticInsets ? null : ( + + ) } contentContainerStyle={{ paddingTop: 12, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 23d38c0d56d..6ed08971cf6 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, @@ -196,6 +196,9 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { + reportFailure: false, + }); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -314,6 +317,15 @@ function ThreadRouteContent( } onReconnectEnvironment(environmentId); }, [environmentId, onReconnectEnvironment]); + const handleLoadPreviousMessages = useCallback(() => { + if (selectedThread === null) { + return; + } + void loadPreviousMessages({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [loadPreviousMessages, selectedThread]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -761,6 +773,8 @@ function ThreadRouteContent( draftAttachments={composer.draftAttachments} connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} + hasPreviousMessages={selectedThreadDetail?.messageHistory?.hasMoreBefore === true} + isLoadingPreviousMessages={selectedThreadDetailState.loadingPreviousMessages === true} activeThreadBusy={composer.activeThreadBusy} environmentId={selectedThread.environmentId} projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null} @@ -777,6 +791,7 @@ function ThreadRouteContent( onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} + onLoadPreviousMessages={handleLoadPreviousMessages} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 8e0e5fb74d5..ef8aa5a5591 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -108,6 +108,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), ); @@ -201,6 +202,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), ); @@ -284,6 +286,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), ); @@ -352,6 +355,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), ); @@ -405,6 +409,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index c42fa107f5d..ea5e5a306c7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -204,6 +204,7 @@ describe("OrchestrationEngine", () => { getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadMessagePage: () => Effect.die("unused"), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 6d5a1ed0b17..c708221a396 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -4,17 +4,20 @@ import { IsoDateTime, MessageId, NonNegativeInt, + PositiveInt, OrchestrationCheckpointFile, OrchestrationProposedPlanId, OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadMessagePage, ProjectScript, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, type OrchestrationMessage, + type OrchestrationThreadMessageCursor, type OrchestrationProjectShell, type OrchestrationProposedPlan, type OrchestrationProject, @@ -120,6 +123,16 @@ const ProjectIdLookupInput = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const ThreadMessageLimitLookupInput = Schema.Struct({ + threadId: ThreadId, + fetchLimit: PositiveInt, +}); +const ThreadMessagePageLookupInput = Schema.Struct({ + threadId: ThreadId, + beforeCreatedAt: IsoDateTime, + beforeMessageId: MessageId, + fetchLimit: PositiveInt, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -264,8 +277,109 @@ 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; +} + const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const getActiveThreadForMessagePage = 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 listThreadMessageRowsBefore = SqlSchema.findAll({ + Request: ThreadMessagePageLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, beforeCreatedAt, beforeMessageId, fetchLimit }) => + sql` + SELECT * + FROM ( + 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) < (${beforeCreatedAt}, ${beforeMessageId}) + ORDER BY created_at DESC, message_id DESC + LIMIT ${fetchLimit} + ) + ORDER BY "createdAt" ASC, "messageId" ASC + `, + }); + const getProjectionThreadMessagePage = Effect.fn( + "ProjectionSnapshotQuery.getProjectionThreadMessagePage", + )(function* (input: { + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + readonly limit: number; + }) { + const [thread, rows] = yield* Effect.all([ + getActiveThreadForMessagePage({ threadId: input.threadId }), + listThreadMessageRowsBefore({ + threadId: input.threadId, + beforeCreatedAt: input.before.createdAt, + beforeMessageId: input.before.messageId, + fetchLimit: input.limit + 1, + }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectionThreadMessagePage:query", + "ProjectionSnapshotQuery.getProjectionThreadMessagePage:decodeRows", + ), + ), + ); + if (Option.isNone(thread)) { + return Option.none(); + } + + const hasMoreBefore = rows.length > input.limit; + const pageRows = hasMoreBefore ? rows.slice(1) : rows; + const firstRow = pageRows[0]; + return Option.some({ + messages: pageRows.map(mapThreadMessageRow), + messageHistory: { + hasMoreBefore, + cursor: + firstRow === undefined + ? null + : { + createdAt: firstRow.createdAt, + messageId: firstRow.messageId, + }, + }, + }); + }); const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -827,6 +941,32 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listLatestThreadMessageRowsByThread = SqlSchema.findAll({ + Request: ThreadMessageLimitLookupInput, + Result: ProjectionThreadMessageDbRowSchema, + execute: ({ threadId, fetchLimit }) => + sql` + SELECT * + FROM ( + 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} + ORDER BY created_at DESC, message_id DESC + LIMIT ${fetchLimit} + ) + ORDER BY "createdAt" ASC, "messageId" ASC + `, + }); + const listThreadProposedPlanRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadProposedPlanDbRowSchema, @@ -1968,7 +2108,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } satisfies OrchestrationThreadShell); }); - const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + const getThreadDetail = (threadId: ThreadId, messageLimit?: number) => Effect.gen(function* () { const [ threadRow, @@ -1992,7 +2132,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), - listThreadMessageRowsByThread({ threadId }).pipe( + (messageLimit === undefined + ? listThreadMessageRowsByThread({ threadId }) + : listLatestThreadMessageRowsByThread({ + threadId, + fetchLimit: messageLimit + 1, + }) + ).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProjectionSnapshotQuery.getThreadDetailById:listMessages:query", @@ -2046,6 +2192,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } + const hasMoreMessagesBefore = messageLimit !== undefined && messageRows.length > messageLimit; + const visibleMessageRows = hasMoreMessagesBefore ? messageRows.slice(1) : messageRows; + const firstMessageRow = visibleMessageRows[0]; const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2065,21 +2214,21 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedAt: threadRow.value.snoozedAt, 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; - }), + messages: visibleMessageRows.map(mapThreadMessageRow), + ...(messageLimit === undefined + ? {} + : { + messageHistory: { + hasMoreBefore: hasMoreMessagesBefore, + cursor: + firstMessageRow === undefined + ? null + : { + createdAt: firstMessageRow.createdAt, + messageId: firstMessageRow.messageId, + }, + }, + }), proposedPlans: proposedPlanRows.map(mapProposedPlanRow), activities: activityRows.map((row) => { const activity = { @@ -2117,8 +2266,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ); }); + const getThreadDetailById: ProjectionSnapshotQueryShape["getThreadDetailById"] = (threadId) => + getThreadDetail(threadId); + const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = ( threadId, + messageLimit, ) => // Read the thread detail and the snapshot sequence within a single // transaction so the sequence is consistent with the returned state; a @@ -2128,7 +2281,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { sql .withTransaction( Effect.gen(function* () { - const thread = yield* getThreadDetailById(threadId); + const thread = yield* getThreadDetail(threadId, messageLimit); if (Option.isNone(thread)) { return Option.none(); } @@ -2146,6 +2299,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); + const getThreadMessagePage: ProjectionSnapshotQueryShape["getThreadMessagePage"] = (input) => + getProjectionThreadMessagePage(input); + return { getCommandReadModel, getSnapshot, @@ -2161,6 +2317,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadShellById, getThreadDetailById, getThreadDetailSnapshot, + getThreadMessagePage, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 23b291d8778..2a967784190 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -15,6 +15,8 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadMessageCursor, + OrchestrationThreadMessagePage, OrchestrationThreadShell, ProjectId, ThreadId, @@ -167,7 +169,14 @@ export interface ProjectionSnapshotQueryShape { */ readonly getThreadDetailSnapshot: ( threadId: ThreadId, + messageLimit?: number, ) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePage: (input: { + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + readonly limit: number; + }) => Effect.Effect, ProjectionRepositoryError>; } /** diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 659665e47b5..225d40d19dd 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -61,7 +61,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.messageLimit) .pipe( Effect.catch((cause) => failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), @@ -73,6 +73,31 @@ 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, + }, + limit: args.query.limit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return page.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 f02a21b8bfe..07b1a85f66f 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -44,6 +44,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 6a68d685f03..311c70f59d4 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -213,6 +213,7 @@ describe("ProviderSessionReaper", () => { ), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9ad..f2313d347ac 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -97,6 +97,7 @@ 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"), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -160,6 +161,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -204,6 +206,7 @@ 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"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -254,6 +257,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), + getThreadMessagePage: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b8f4b07124d..1cd653e8977 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1101,6 +1101,7 @@ const makeWsRpcLayer = ( settings, shellResumeCompletionMarker: true, threadResumeCompletionMarker: true, + threadMessagePagination: true, }; }); @@ -1425,7 +1426,7 @@ const makeWsRpcLayer = ( } const snapshot = yield* projectionSnapshotQuery - .getThreadDetailSnapshot(input.threadId) + .getThreadDetailSnapshot(input.threadId, input.messageLimit) .pipe( Effect.mapError( (cause) => diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 10c1ceac86e..74b6a31a294 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -206,7 +206,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 { @@ -1136,6 +1136,9 @@ function ChatViewContent(props: ChatViewProps) { }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const requestThreadGoal = useAtomCommand(threadEnvironment.requestGoal, { reportFailure: false }); + const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1160,6 +1163,7 @@ function ChatViewContent(props: ChatViewProps) { const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); + const environmentThreadState = useEnvironmentThread(environmentId, threadId); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1401,6 +1405,12 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const handleLoadPreviousMessages = useCallback(() => { + void loadPreviousMessages({ + environmentId, + input: { threadId }, + }); + }, [environmentId, loadPreviousMessages, threadId]); const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -5865,6 +5875,9 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} + hasPreviousMessages={activeThread.messageHistory?.hasMoreBefore === true} + isLoadingPreviousMessages={environmentThreadState.loadingPreviousMessages === true} + onLoadPreviousMessages={handleLoadPreviousMessages} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 493522d4f49..09b0a4e1738 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -147,8 +147,6 @@ 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> = []; @@ -185,6 +183,9 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + hasPreviousMessages?: boolean; + isLoadingPreviousMessages?: boolean; + onLoadPreviousMessages?: () => void; } // --------------------------------------------------------------------------- @@ -220,6 +221,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + hasPreviousMessages = false, + isLoadingPreviousMessages = false, + onLoadPreviousMessages, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -516,7 +520,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ "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={ + hasPreviousMessages ? ( +
+ +
+ ) : ( +
+ ) + } ListFooterComponent={TIMELINE_LIST_FOOTER} /> ; + readonly messageLimit?: number; readonly timeoutMs?: number; }) { - const requestUrl = environmentEndpointUrl( - input.prepared.httpBaseUrl, - `/api/orchestration/threads/${input.threadId}`, + const requestUrl = new URL( + environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}`, + ), ); + if (input.messageLimit !== undefined) { + requestUrl.searchParams.set("messageLimit", String(input.messageLimit)); + } const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); const headers = yield* buildEnvironmentAuthHeaders( input.prepared.httpAuthorization, "GET", - requestUrl, + requestUrl.toString(), input.signer, ); return yield* executeEnvironmentHttpRequest( - requestUrl, + requestUrl.toString(), input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, withEnvironmentCredentials( input.prepared.httpAuthorization, client.orchestration.threadSnapshot({ params: { threadId: input.threadId }, + query: input.messageLimit === undefined ? {} : { messageLimit: input.messageLimit }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadMessages = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessages", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly before: OrchestrationThreadMessageCursor; + 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("limit", String(THREAD_MESSAGE_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.threadMessages({ + params: { threadId: input.threadId }, + query: { + beforeCreatedAt: input.before.createdAt, + beforeMessageId: input.before.messageId, + limit: THREAD_MESSAGE_PAGE_SIZE, + }, headers, }), ), @@ -72,7 +128,13 @@ export class ThreadSnapshotLoader extends Context.Service< readonly load: ( prepared: PreparedConnection, threadId: ThreadId, + messageLimit?: number, ) => Effect.Effect>; + readonly loadPreviousMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + before: OrchestrationThreadMessageCursor, + ) => Effect.Effect>; } >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} @@ -89,8 +151,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, messageLimit?: number) => + fetchEnvironmentThreadSnapshot({ + prepared, + threadId, + signer, + ...(messageLimit === undefined ? {} : { messageLimit }), + }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), // A genuinely missing thread (404) is expected — the socket @@ -115,6 +182,28 @@ export const threadSnapshotLoaderLayer: Layer.Layer< ), ), ), + loadPreviousMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + before: OrchestrationThreadMessageCursor, + ) => + fetchEnvironmentThreadMessages({ prepared, threadId, before, 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()), + ), + ), + ), }); }), ); diff --git a/packages/client-runtime/src/state/threadState.ts b/packages/client-runtime/src/state/threadState.ts index 89be139e925..8b9928473b4 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -7,6 +7,7 @@ export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; + readonly loadingPreviousMessages?: boolean; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 949cc83d326..900b751b11d 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -188,6 +188,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o : Option.none(), ), ), + loadPreviousMessages: () => Effect.succeed(Option.none()), }); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: TARGET, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 196229cc8b1..8202e988c45 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -21,11 +21,15 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { THREAD_MESSAGE_PAGE_SIZE, ThreadSnapshotLoader } from "./threadSnapshotHttp.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 EnvironmentThreadState, @@ -81,11 +85,43 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); const awaitingCompletion = yield* Ref.make(false); const persistence = yield* Queue.sliding(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, ) { - yield* cache.saveThread(environmentId, snapshot).pipe( + const persistedSnapshot = + snapshot.thread.messageHistory !== undefined && + snapshot.thread.messages.length > THREAD_MESSAGE_PAGE_SIZE + ? { + ...snapshot, + thread: { + ...snapshot.thread, + messages: snapshot.thread.messages.slice(-THREAD_MESSAGE_PAGE_SIZE), + messageHistory: { + hasMoreBefore: true, + cursor: { + createdAt: snapshot.thread.messages.at(-THREAD_MESSAGE_PAGE_SIZE)!.createdAt, + messageId: snapshot.thread.messages.at(-THREAD_MESSAGE_PAGE_SIZE)!.id, + }, + }, + }, + } + : snapshot; + yield* cache.saveThread(environmentId, persistedSnapshot).pipe( Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ @@ -159,6 +195,50 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } }); + const loadPreviousMessages = Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if (Option.isNone(current.data)) { + return; + } + const messageHistory = current.data.value.messageHistory; + const cursor = messageHistory?.cursor; + if ( + messageHistory === undefined || + !messageHistory.hasMoreBefore || + cursor === null || + cursor === undefined || + current.loadingPreviousMessages === true + ) { + return; + } + + yield* SubscriptionRef.set(state, { ...current, loadingPreviousMessages: true }); + yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const page = yield* snapshotLoader.loadPreviousMessages(prepared, threadId, cursor); + if (Option.isNone(page)) { + return; + } + + const latest = yield* SubscriptionRef.get(state); + if (Option.isNone(latest.data)) { + return; + } + yield* setThread({ + ...latest.data.value, + messages: [...page.value.messages, ...latest.data.value.messages], + messageHistory: page.value.messageHistory, + }); + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => ({ + ...latest, + loadingPreviousMessages: false, + })), + ), + ); + }); + const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.set(state, { @@ -248,26 +328,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.map((config) => config.threadResumeCompletionMarker === true), Effect.orElseSucceed(() => false), ); + const supportsMessagePagination = yield* session.initialConfig.pipe( + Effect.map((config) => config.threadMessagePagination === true), + Effect.orElseSucceed(() => false), + ); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; 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_MESSAGE_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); @@ -286,6 +365,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return { threadId, + ...(supportsMessagePagination ? { messageLimit: THREAD_MESSAGE_PAGE_SIZE } : {}), ...(canResume ? { afterSequence: sequence } : {}), ...(supportsCompletionMarker ? { requestCompletionMarker: true as const } : {}), }; @@ -310,13 +390,42 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); - return state; + return Object.assign(state, { loadPreviousMessages }); }); -export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { +type EnvironmentThreadStateSubscription = + SubscriptionRef.SubscriptionRef & { + readonly loadPreviousMessages: Effect.Effect; + }; + +export function threadStateChanges( + environmentId: EnvironmentIdType, + threadId: ThreadIdType, + subscriptions?: Map, +) { + const key = threadKey({ environmentId, threadId }); return followStreamInEnvironment( environmentId, - Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), + Stream.unwrap( + makeEnvironmentThreadState(threadId).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)), + ), + ), + ), ); } @@ -326,10 +435,12 @@ export function createEnvironmentThreadStateAtoms( E >, ) { + 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), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, }) .pipe( @@ -341,6 +452,26 @@ 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.void; + }), + ), + scheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + }, + }), }; } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..561aa9b71c6 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,8 @@ import { OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, + OrchestrationThreadMessagePage, + ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT, } from "./orchestration.ts"; import { RelayCloudEnvironmentHealthRequest, @@ -457,6 +466,18 @@ const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({ threadId: ThreadId, }); +const EnvironmentOrchestrationThreadSnapshotQuery = Schema.Struct({ + messageLimit: Schema.optional( + PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), + ), +}); + +const EnvironmentOrchestrationThreadMessagesQuery = Schema.Struct({ + beforeCreatedAt: IsoDateTime, + beforeMessageId: MessageId, + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), +}); + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -476,10 +497,20 @@ 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: OrchestrationThreadMessagePage, + 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 a733db2269c..79ce51d97b4 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, @@ -363,6 +364,20 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT = 200; + +export const OrchestrationThreadMessageCursor = Schema.Struct({ + createdAt: IsoDateTime, + messageId: MessageId, +}); +export type OrchestrationThreadMessageCursor = typeof OrchestrationThreadMessageCursor.Type; + +export const OrchestrationThreadMessageHistory = Schema.Struct({ + hasMoreBefore: Schema.Boolean, + cursor: Schema.NullOr(OrchestrationThreadMessageCursor), +}); +export type OrchestrationThreadMessageHistory = typeof OrchestrationThreadMessageHistory.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -393,6 +408,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([])), ), @@ -518,6 +534,9 @@ export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShel export const OrchestrationSubscribeThreadInput = Schema.Struct({ threadId: ThreadId, + messageLimit: Schema.optionalKey( + PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), + ), /** * When provided, the server skips the initial snapshot frame and instead * replays events after this sequence before streaming live events. Clients @@ -540,6 +559,12 @@ export const OrchestrationThreadDetailSnapshot = Schema.Struct({ }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; +export const OrchestrationThreadMessagePage = Schema.Struct({ + messages: Schema.Array(OrchestrationMessage), + messageHistory: OrchestrationThreadMessageHistory, +}); +export type OrchestrationThreadMessagePage = typeof OrchestrationThreadMessagePage.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 69699c7a839..51d3dc959f9 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -422,6 +422,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; From ac58678b5ef9eba0ebc8358dc3aef17b71fabd85 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 12:59:31 +0300 Subject: [PATCH 02/31] load thread history in bounded segments --- FORK.md | 5 +- .../features/threads/ThreadDetailScreen.tsx | 6 + .../src/features/threads/ThreadFeed.tsx | 33 +- .../features/threads/ThreadRouteScreen.tsx | 23 +- .../src/state/use-selected-thread-requests.ts | 5 +- .../checkpointing/CheckpointDiffQuery.test.ts | 15 + .../Layers/OrchestrationEngine.test.ts | 3 + .../Layers/ProjectionSnapshotQuery.ts | 428 ++++++++++++- .../Services/ProjectionSnapshotQuery.ts | 22 +- apps/server/src/orchestration/http.ts | 65 ++ .../project/ProjectSetupScriptRunner.test.ts | 3 + .../Layers/ProviderSessionReaper.test.ts | 3 + apps/server/src/serverRuntimeStartup.test.ts | 12 + apps/web/src/components/ChatView.tsx | 78 ++- .../src/components/chat/MessagesTimeline.tsx | 121 +++- .../client-runtime/src/state/entities.test.ts | 4 + .../src/state/threadSnapshotHttp.ts | 207 ++++++- .../client-runtime/src/state/threadState.ts | 20 +- .../src/state/threads-sync.test.ts | 3 + packages/client-runtime/src/state/threads.ts | 580 ++++++++++++++++-- packages/contracts/src/environmentHttp.ts | 58 +- packages/contracts/src/orchestration.ts | 22 +- 22 files changed, 1571 insertions(+), 145 deletions(-) diff --git a/FORK.md b/FORK.md index f6b0dc7ede1..0b4257fe00b 100644 --- a/FORK.md +++ b/FORK.md @@ -51,7 +51,10 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork ### UX Changes -- Thread detail snapshots load a bounded message tail and older messages use keyset pagination. +- Thread detail snapshots keep a bounded live tail. Historical browsing uses bounded, + bidirectional keyset windows, while the web minimap samples landmarks across the full thread and + loads the selected segment on demand. Historical windows retain every message and cap work + telemetry per segment. - Desktop context-menu style is configurable. - 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/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 694ae4dd4bd..796c0392451 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -62,7 +62,9 @@ export interface ThreadDetailScreenProps { /** 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; @@ -81,6 +83,7 @@ export interface ThreadDetailScreenProps { 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; @@ -374,8 +377,11 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread 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 d32f99235bf..f90a6163b4e 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -141,8 +141,11 @@ export interface ThreadFeedProps { 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; } @@ -1753,7 +1756,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, @@ -1792,31 +1795,37 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { scrollToOverflowEnabled estimatedItemSize={180} initialScrollAtEnd + onStartReached={props.hasPreviousMessages ? props.onLoadPreviousMessages : undefined} + onStartReachedThreshold={0.8} + onEndReached={props.hasNextMessages ? props.onLoadNextMessages : undefined} + onEndReachedThreshold={0.8} onScroll={handleScroll} scrollEventThrottle={16} ListHeaderComponent={ - props.hasPreviousMessages ? ( + props.isLoadingPreviousMessages ? ( - + + - {props.isLoadingPreviousMessages - ? "Loading earlier messages..." - : "Load earlier messages"} + Loading earlier messages... - + ) : usesNativeAutomaticInsets ? null : ( ) } + ListFooterComponent={ + props.isLoadingNextMessages ? ( + + + Loading newer messages... + + ) : null + } contentContainerStyle={{ paddingTop: 12, paddingHorizontal: contentHorizontalPadding, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6ed08971cf6..aeb6fea3181 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -199,6 +199,9 @@ function ThreadRouteContent( const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { reportFailure: false, }); + const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + reportFailure: false, + }); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -326,6 +329,15 @@ function ThreadRouteContent( input: { threadId: selectedThread.id }, }); }, [loadPreviousMessages, selectedThread]); + const handleLoadNextMessages = useCallback(() => { + if (selectedThread === null) { + return; + } + void loadNextMessages({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id }, + }); + }, [loadNextMessages, selectedThread]); /* ─── Git action progress (for overlay banner) ──────────────────── */ const gitActionProgressTarget = useMemo( @@ -774,7 +786,15 @@ function ThreadRouteContent( connectionStateLabel={routeConnectionState} threadSyncStatus={selectedThreadDetailState.status} hasPreviousMessages={selectedThreadDetail?.messageHistory?.hasMoreBefore === true} - isLoadingPreviousMessages={selectedThreadDetailState.loadingPreviousMessages === true} + hasNextMessages={selectedThreadDetail?.messageHistory?.hasMoreAfter === true} + 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} @@ -792,6 +812,7 @@ function ThreadRouteContent( onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} onLoadPreviousMessages={handleLoadPreviousMessages} + onLoadNextMessages={handleLoadNextMessages} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} onUpdateThreadInteractionMode={composer.onUpdateInteractionMode} diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index c9e9db12530..f70953a85ba 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"; @@ -14,7 +15,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"; @@ -63,7 +64,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 ef8aa5a5591..8209640953c 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -109,6 +109,9 @@ describe("CheckpointDiffQuery.layer", () => { 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"), }), ), ); @@ -203,6 +206,9 @@ describe("CheckpointDiffQuery.layer", () => { 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"), }), ), ); @@ -287,6 +293,9 @@ describe("CheckpointDiffQuery.layer", () => { 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"), }), ), ); @@ -356,6 +365,9 @@ describe("CheckpointDiffQuery.layer", () => { 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"), }), ), ); @@ -410,6 +422,9 @@ describe("CheckpointDiffQuery.layer", () => { 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"), }), ), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index ea5e5a306c7..996d05be592 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -205,6 +205,9 @@ describe("OrchestrationEngine", () => { 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"), }), ), Layer.provide( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c708221a396..7e0820eb03b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -11,10 +11,12 @@ import { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, - OrchestrationThreadMessagePage, + OrchestrationThreadHistoryPage, + ORCHESTRATION_THREAD_HISTORY_OUTLINE_MAX_LANDMARKS, ProjectScript, TurnId, type OrchestrationCheckpointSummary, + type OrchestrationThreadHistoryOutline, type OrchestrationLatestTurn, type OrchestrationMessage, type OrchestrationThreadMessageCursor, @@ -129,10 +131,31 @@ const ThreadMessageLimitLookupInput = Schema.Struct({ }); const ThreadMessagePageLookupInput = Schema.Struct({ threadId: ThreadId, - beforeCreatedAt: IsoDateTime, - beforeMessageId: MessageId, + cursorCreatedAt: IsoDateTime, + cursorMessageId: MessageId, fetchLimit: PositiveInt, }); +const ThreadMessageIdLookupInput = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); +const ThreadHistoryRangeLookupInput = Schema.Struct({ + threadId: ThreadId, + startCreatedAt: IsoDateTime, + endCreatedAt: Schema.NullOr(IsoDateTime), +}); +const ThreadHistoryOutlineLookupInput = Schema.Struct({ + threadId: ThreadId, + maxLandmarks: PositiveInt, +}); +const THREAD_HISTORY_PAGE_ACTIVITY_LIMIT = 500; +const ProjectionThreadHistoryLandmarkRowSchema = Schema.Struct({ + messageId: MessageId, + ordinal: NonNegativeInt, + createdAt: IsoDateTime, + preview: Schema.String, + totalUserMessages: NonNegativeInt, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -295,9 +318,27 @@ function mapThreadMessageRow( 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 getActiveThreadForMessagePage = SqlSchema.findOneOption({ + const getActiveThreadForHistory = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadIdLookupRowSchema, execute: ({ threadId }) => @@ -313,7 +354,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const listThreadMessageRowsBefore = SqlSchema.findAll({ Request: ThreadMessagePageLookupInput, Result: ProjectionThreadMessageDbRowSchema, - execute: ({ threadId, beforeCreatedAt, beforeMessageId, fetchLimit }) => + execute: ({ threadId, cursorCreatedAt, cursorMessageId, fetchLimit }) => sql` SELECT * FROM ( @@ -329,13 +370,178 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { updated_at AS "updatedAt" FROM projection_thread_messages WHERE thread_id = ${threadId} - AND (created_at, message_id) < (${beforeCreatedAt}, ${beforeMessageId}) + AND (created_at, message_id) < (${cursorCreatedAt}, ${cursorMessageId}) ORDER BY created_at DESC, message_id DESC LIMIT ${fetchLimit} ) ORDER BY "createdAt" ASC, "messageId" ASC `, }); + const listThreadMessageRowsAfter = 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 getThreadMessageRowById = 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 listThreadActivityRowsInHistoryRange = 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, + payload_json 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}) + AND activity_id IN ( + SELECT activity_id + FROM projection_thread_activities + WHERE thread_id = ${threadId} + AND created_at >= ${startCreatedAt} + AND (${endCreatedAt} IS NULL OR created_at <= ${endCreatedAt}) + ORDER BY created_at DESC, activity_id DESC + LIMIT ${THREAD_HISTORY_PAGE_ACTIVITY_LIMIT} + ) + ORDER BY sequence ASC, created_at ASC, activity_id ASC + `, + }); + const listThreadProposedPlanRowsInHistoryRange = SqlSchema.findAll({ + Request: ThreadHistoryRangeLookupInput, + Result: ProjectionThreadProposedPlanDbRowSchema, + 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 listThreadHistoryLandmarkRows = SqlSchema.findAll({ + Request: ThreadHistoryOutlineLookupInput, + Result: ProjectionThreadHistoryLandmarkRowSchema, + execute: ({ threadId, maxLandmarks }) => + sql` + WITH ranked AS ( + SELECT + message_id, + created_at, + ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS ordinal, + COUNT(*) OVER () AS total_user_messages + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND role = 'user' + ), + bucketed AS ( + SELECT + *, + CASE + WHEN total_user_messages <= ${maxLandmarks} THEN ordinal + ELSE CAST( + ordinal * (${maxLandmarks} - 1) / (total_user_messages - 1) + AS INTEGER + ) + END AS bucket + FROM ranked + ), + sampled AS ( + SELECT bucket, MIN(ordinal) AS ordinal + FROM bucketed + GROUP BY bucket + ) + SELECT + bucketed.message_id AS "messageId", + bucketed.ordinal, + bucketed.created_at AS "createdAt", + substr(messages.text, 1, 240) AS preview, + bucketed.total_user_messages AS "totalUserMessages" + FROM bucketed + INNER JOIN sampled + ON sampled.bucket = bucketed.bucket + AND sampled.ordinal = bucketed.ordinal + INNER JOIN projection_thread_messages AS messages + ON messages.message_id = bucketed.message_id + ORDER BY bucketed.ordinal ASC + `, + }); + const loadThreadHistoryRange = Effect.fn("ProjectionSnapshotQuery.loadThreadHistoryRange")( + function* (input: { + readonly threadId: ThreadId; + readonly startCreatedAt: string; + readonly endCreatedAt: string | null; + }) { + const [activityRows, proposedPlanRows] = yield* Effect.all([ + listThreadActivityRowsInHistoryRange(input), + listThreadProposedPlanRowsInHistoryRange(input), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.loadThreadHistoryRange:query", + "ProjectionSnapshotQuery.loadThreadHistoryRange:decodeRows", + ), + ), + ); + return { + activities: activityRows.map(mapThreadActivityRow), + proposedPlans: proposedPlanRows.map(mapProposedPlanRow), + }; + }, + ); const getProjectionThreadMessagePage = Effect.fn( "ProjectionSnapshotQuery.getProjectionThreadMessagePage", )(function* (input: { @@ -344,11 +550,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly limit: number; }) { const [thread, rows] = yield* Effect.all([ - getActiveThreadForMessagePage({ threadId: input.threadId }), + getActiveThreadForHistory({ threadId: input.threadId }), listThreadMessageRowsBefore({ threadId: input.threadId, - beforeCreatedAt: input.before.createdAt, - beforeMessageId: input.before.messageId, + cursorCreatedAt: input.before.createdAt, + cursorMessageId: input.before.messageId, fetchLimit: input.limit + 1, }), ]).pipe( @@ -360,16 +566,83 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ); if (Option.isNone(thread)) { - return Option.none(); + return Option.none(); } const hasMoreBefore = rows.length > input.limit; const pageRows = hasMoreBefore ? rows.slice(1) : rows; const firstRow = pageRows[0]; + const historyRange = + firstRow === undefined + ? { activities: [], proposedPlans: [] } + : yield* loadThreadHistoryRange({ + threadId: input.threadId, + startCreatedAt: firstRow.createdAt, + endCreatedAt: input.before.createdAt, + }); return Option.some({ messages: pageRows.map(mapThreadMessageRow), + activities: historyRange.activities, + proposedPlans: historyRange.proposedPlans, messageHistory: { hasMoreBefore, + hasMoreAfter: true, + cursor: + firstRow === undefined + ? null + : { + createdAt: firstRow.createdAt, + messageId: firstRow.messageId, + }, + }, + }); + }); + const getProjectionThreadMessagePageAfter = Effect.fn( + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAfter", + )(function* (input: { + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + readonly limit: number; + }) { + const [thread, rows] = yield* Effect.all([ + getActiveThreadForHistory({ threadId: input.threadId }), + listThreadMessageRowsAfter({ + threadId: input.threadId, + cursorCreatedAt: input.after.createdAt, + cursorMessageId: input.after.messageId, + fetchLimit: input.limit + 1, + }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAfter:query", + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAfter:decodeRows", + ), + ), + ); + if (Option.isNone(thread)) { + return Option.none(); + } + + const hasMoreAfter = rows.length > input.limit; + const pageRows = hasMoreAfter ? rows.slice(0, input.limit) : rows; + const firstRow = pageRows[0]; + const nextRow = rows[input.limit]; + const historyRange = + firstRow === undefined + ? { activities: [], proposedPlans: [] } + : yield* loadThreadHistoryRange({ + threadId: input.threadId, + startCreatedAt: input.after.createdAt, + endCreatedAt: nextRow?.createdAt ?? null, + }); + return Option.some({ + messages: pageRows.map(mapThreadMessageRow), + activities: historyRange.activities, + proposedPlans: historyRange.proposedPlans, + messageHistory: { + hasMoreBefore: true, + hasMoreAfter, cursor: firstRow === undefined ? null @@ -380,6 +653,112 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }, }); }); + const getProjectionThreadMessagePageAround = Effect.fn( + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAround", + )(function* (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly limit: number; + }) { + const [thread, target] = yield* Effect.all([ + getActiveThreadForHistory({ threadId: input.threadId }), + getThreadMessageRowById({ threadId: input.threadId, messageId: input.messageId }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAround:targetQuery", + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAround:decodeTarget", + ), + ), + ); + if (Option.isNone(thread) || Option.isNone(target)) { + return Option.none(); + } + + const [beforeRows, afterRows] = yield* Effect.all([ + listThreadMessageRowsBefore({ + threadId: input.threadId, + cursorCreatedAt: target.value.createdAt, + cursorMessageId: target.value.messageId, + fetchLimit: input.limit + 1, + }), + listThreadMessageRowsAfter({ + threadId: input.threadId, + cursorCreatedAt: target.value.createdAt, + cursorMessageId: target.value.messageId, + fetchLimit: input.limit + 1, + }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAround:pageQuery", + "ProjectionSnapshotQuery.getProjectionThreadMessagePageAround:decodePage", + ), + ), + ); + let beforeCount = Math.min(Math.floor((input.limit - 1) / 2), beforeRows.length); + const afterCount = Math.min(input.limit - 1 - beforeCount, afterRows.length); + beforeCount = Math.min(input.limit - 1 - afterCount, beforeRows.length); + const visibleRows = [ + ...beforeRows.slice(-beforeCount), + target.value, + ...afterRows.slice(0, afterCount), + ]; + const firstRow = visibleRows[0]; + if (firstRow === undefined) { + return Option.none(); + } + const nextRow = afterRows[afterCount]; + const historyRange = yield* loadThreadHistoryRange({ + threadId: input.threadId, + startCreatedAt: firstRow.createdAt, + endCreatedAt: nextRow?.createdAt ?? null, + }); + return Option.some({ + messages: visibleRows.map(mapThreadMessageRow), + activities: historyRange.activities, + proposedPlans: historyRange.proposedPlans, + messageHistory: { + hasMoreBefore: beforeRows.length > beforeCount, + hasMoreAfter: afterRows.length > afterCount, + cursor: { + createdAt: firstRow.createdAt, + messageId: firstRow.messageId, + }, + }, + }); + }); + const getProjectionThreadHistoryOutline = Effect.fn( + "ProjectionSnapshotQuery.getProjectionThreadHistoryOutline", + )(function* (threadId: ThreadId) { + const [thread, rows] = yield* Effect.all([ + getActiveThreadForHistory({ threadId }), + listThreadHistoryLandmarkRows({ + threadId, + maxLandmarks: ORCHESTRATION_THREAD_HISTORY_OUTLINE_MAX_LANDMARKS, + }), + ]).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getProjectionThreadHistoryOutline:query", + "ProjectionSnapshotQuery.getProjectionThreadHistoryOutline: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, + createdAt: row.createdAt, + preview: row.preview, + })), + }); + }); const projectionThreadGoalRepository = yield* ProjectionThreadGoalRepository; const repositoryIdentityResolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const repositoryIdentityResolutionConcurrency = 4; @@ -2220,6 +2599,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { : { messageHistory: { hasMoreBefore: hasMoreMessagesBefore, + hasMoreAfter: false, cursor: firstMessageRow === undefined ? null @@ -2230,21 +2610,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }, }), 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; - }), + activities: activityRows.map(mapThreadActivityRow), checkpoints: checkpointRows.map((row) => ({ turnId: row.turnId, checkpointTurnCount: row.checkpointTurnCount, @@ -2301,6 +2667,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const getThreadMessagePage: ProjectionSnapshotQueryShape["getThreadMessagePage"] = (input) => getProjectionThreadMessagePage(input); + const getThreadMessagePageAfter: ProjectionSnapshotQueryShape["getThreadMessagePageAfter"] = ( + input, + ) => getProjectionThreadMessagePageAfter(input); + const getThreadMessagePageAround: ProjectionSnapshotQueryShape["getThreadMessagePageAround"] = ( + input, + ) => getProjectionThreadMessagePageAround(input); + const getThreadHistoryOutline: ProjectionSnapshotQueryShape["getThreadHistoryOutline"] = ( + threadId, + ) => getProjectionThreadHistoryOutline(threadId); return { getCommandReadModel, @@ -2318,6 +2693,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { getThreadDetailById, getThreadDetailSnapshot, getThreadMessagePage, + getThreadMessagePageAfter, + getThreadMessagePageAround, + getThreadHistoryOutline, } satisfies ProjectionSnapshotQueryShape; }); diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 2a967784190..0ba3a4e1ba1 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -15,9 +15,11 @@ import type { OrchestrationShellSnapshot, OrchestrationThread, OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, OrchestrationThreadMessageCursor, - OrchestrationThreadMessagePage, OrchestrationThreadShell, + MessageId, ProjectId, ThreadId, } from "@t3tools/contracts"; @@ -176,7 +178,23 @@ export interface ProjectionSnapshotQueryShape { readonly threadId: ThreadId; readonly before: OrchestrationThreadMessageCursor; readonly limit: number; - }) => Effect.Effect, ProjectionRepositoryError>; + }) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePageAfter: (input: { + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + readonly limit: number; + }) => Effect.Effect, ProjectionRepositoryError>; + + readonly getThreadMessagePageAround: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly limit: 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 225d40d19dd..2ec5c6e0606 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -98,6 +98,71 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( return 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, + }, + limit: args.query.limit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return 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, + limit: args.query.limit, + }) + .pipe( + Effect.catch((cause) => + failEnvironmentInternal("orchestration_thread_snapshot_failed", cause), + ), + ); + if (Option.isNone(page)) { + return yield* failEnvironmentNotFound("thread_not_found"); + } + return 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 07b1a85f66f..4a6ebff4771 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -45,6 +45,9 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), }); const makeTerminalManagerLayer = ( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 311c70f59d4..7b4f5664427 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -214,6 +214,9 @@ 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"), }), ), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index f2313d347ac..0d50f7cb9c2 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -98,6 +98,9 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa 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"), }), Effect.provideService(AnalyticsService.AnalyticsService, { record: () => Effect.void, @@ -162,6 +165,9 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -207,6 +213,9 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, @@ -258,6 +267,9 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), getThreadMessagePage: () => Effect.die("unused"), + getThreadMessagePageAfter: () => Effect.die("unused"), + getThreadMessagePageAround: () => Effect.die("unused"), + getThreadHistoryOutline: () => Effect.die("unused"), }), Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 74b6a31a294..63dfbc72fac 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"; @@ -1139,6 +1140,12 @@ function ChatViewContent(props: ChatViewProps) { const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { reportFailure: false, }); + const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + reportFailure: false, + }); + const loadMessagesAround = useAtomCommand(environmentThreads.loadMessagesAround, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1164,6 +1171,14 @@ function ChatViewContent(props: ChatViewProps) { routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); const environmentThreadState = useEnvironmentThread(environmentId, threadId); + 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 markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const activeThreadLastVisitedAt = useUiStateStore( (store) => store.threadLastVisitedAtById[routeThreadKey], @@ -1405,12 +1420,38 @@ function ChatViewContent(props: ChatViewProps) { // depend on which route is mounted. const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; + const operationalThread = isServerThread ? liveServerThread : activeThread; const handleLoadPreviousMessages = useCallback(() => { void loadPreviousMessages({ environmentId, input: { threadId }, }); }, [environmentId, loadPreviousMessages, threadId]); + const handleLoadNextMessages = useCallback(() => { + void loadNextMessages({ + environmentId, + input: { threadId }, + }); + }, [environmentId, loadNextMessages, threadId]); + const handleSelectHistoryMessage = useCallback( + (messageId: MessageId) => { + if ( + environmentThreadState.history.kind !== "ready" || + environmentThreadState.history.loading !== null + ) { + return; + } + setHistoryTarget({ threadKey: routeThreadKey, messageId }); + void loadMessagesAround({ + environmentId, + input: { threadId, messageId }, + }); + }, + [environmentId, environmentThreadState.history, loadMessagesAround, routeThreadKey, threadId], + ); + const handleHistoryTargetReady = useCallback(() => { + setHistoryTarget((current) => ({ ...current, messageId: null })); + }, []); const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -1921,14 +1962,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( @@ -1968,10 +2010,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({ @@ -1983,8 +2025,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" @@ -5876,8 +5918,26 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} hasPreviousMessages={activeThread.messageHistory?.hasMoreBefore === true} - isLoadingPreviousMessages={environmentThreadState.loadingPreviousMessages === true} + hasNextMessages={activeThread.messageHistory?.hasMoreAfter === true} + isLoadingPreviousMessages={ + environmentThreadState.history.kind === "ready" && + (environmentThreadState.history.loading === "before" || + environmentThreadState.history.loading === "around") + } + isLoadingNextMessages={ + environmentThreadState.history.kind === "ready" && + environmentThreadState.history.loading === "after" + } onLoadPreviousMessages={handleLoadPreviousMessages} + onLoadNextMessages={handleLoadNextMessages} + historyOutline={ + environmentThreadState.history.kind === "ready" + ? environmentThreadState.history.outline + : null + } + historyTargetMessageId={historyTarget.messageId} + onSelectHistoryMessage={handleSelectHistoryMessage} + onHistoryTargetReady={handleHistoryTargetReady} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 09b0a4e1738..2d94c0c068d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,6 +1,7 @@ import { type EnvironmentId, type MessageId, + type OrchestrationThreadHistoryOutline, type ScopedThreadRef, type ServerProviderSkill, type TurnId, @@ -184,8 +185,15 @@ interface MessagesTimelineProps { hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; hasPreviousMessages?: boolean; + hasNextMessages?: boolean; isLoadingPreviousMessages?: boolean; + isLoadingNextMessages?: boolean; onLoadPreviousMessages?: () => void; + onLoadNextMessages?: () => void; + historyOutline?: OrchestrationThreadHistoryOutline | null; + historyTargetMessageId?: MessageId | null; + onSelectHistoryMessage?: (messageId: MessageId) => void; + onHistoryTargetReady?: () => void; } // --------------------------------------------------------------------------- @@ -222,8 +230,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hideEmptyPlaceholder = false, topFadeEnabled = false, hasPreviousMessages = false, + hasNextMessages = false, isLoadingPreviousMessages = false, + isLoadingNextMessages = false, onLoadPreviousMessages, + onLoadNextMessages, + historyOutline = null, + historyTargetMessageId = null, + onSelectHistoryMessage, + onHistoryTargetReady, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -329,7 +344,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ], ); const rows = useStableRows(rawRows); - const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const minimapItems = useMemo( + () => deriveTimelineMinimapItems(rows, historyOutline), + [historyOutline, rows], + ); const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); @@ -375,7 +393,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); - if (!strip) { + if (!strip || item.rowIndex === null) { continue; } @@ -395,6 +413,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); + useEffect(() => { + if (historyTargetMessageId === null) { + return; + } + const rowIndex = rows.findIndex( + (row) => row.kind === "message" && row.message.id === historyTargetMessageId, + ); + if (rowIndex < 0) { + return; + } + const frame = requestAnimationFrame(() => { + void listRef.current?.scrollToIndex({ + index: rowIndex, + animated: false, + viewOffset: 24, + }); + onHistoryTargetReady?.(); + }); + return () => cancelAnimationFrame(frame); + }, [historyTargetMessageId, listRef, onHistoryTargetReady, rows]); + useEffect(() => { if (!timelineViewportElement) { return; @@ -500,7 +539,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || hasNextMessages ? false : { animated: false, @@ -515,35 +554,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ data: true, size: false, }} + onStartReached={hasPreviousMessages ? onLoadPreviousMessages : undefined} + onStartReachedThreshold={0.8} + onEndReached={hasNextMessages ? onLoadNextMessages : undefined} + onEndReachedThreshold={0.8} onScroll={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={ - hasPreviousMessages ? ( -
- + isLoadingPreviousMessages ? ( +
+ Loading earlier messages...
) : (
) } - ListFooterComponent={TIMELINE_LIST_FOOTER} + ListFooterComponent={ + isLoadingNextMessages ? ( +
+ Loading newer messages... +
+ ) : ( + TIMELINE_LIST_FOOTER + ) + } /> { onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); + if (item.rowIndex === null) { + onSelectHistoryMessage?.(item.id); + } else { + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + } }} />
@@ -575,8 +616,8 @@ function getItemType(item: MessagesTimelineRow) { } interface TimelineMinimapItem { - readonly id: string; - readonly rowIndex: number; + readonly id: MessageId; + readonly rowIndex: number | null; readonly userText: string | null; readonly assistantText: string | null; } @@ -591,7 +632,29 @@ interface TimelinePositionState { 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, + 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]; @@ -600,7 +663,7 @@ function deriveTimelineMinimapItems( } items.push({ - id: row.id, + id: row.message.id, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), 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/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index 85ea17ece13..e11a7d9d24f 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -1,7 +1,9 @@ import type { + MessageId, OrchestrationThreadDetailSnapshot, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, OrchestrationThreadMessageCursor, - OrchestrationThreadMessagePage, ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -26,6 +28,7 @@ import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./envir // delays the transition to live data on the first open, not the initial paint. const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; export const THREAD_MESSAGE_PAGE_SIZE = 50; +export const THREAD_HISTORY_AROUND_PAGE_SIZE = 100; /** * Load a thread's detail snapshot over HTTP instead of embedding it in the @@ -71,8 +74,8 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( ); }); -export const fetchEnvironmentThreadMessages = Effect.fn( - "clientRuntime.state.fetchEnvironmentThreadMessages", +export const fetchEnvironmentThreadMessagesBefore = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessagesBefore", )(function* (input: { readonly prepared: PreparedConnection; readonly threadId: ThreadId; @@ -114,6 +117,118 @@ export const fetchEnvironmentThreadMessages = Effect.fn( ); }); +export const fetchEnvironmentThreadMessagesAfter = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadMessagesAfter", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly after: OrchestrationThreadMessageCursor; + 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("limit", String(THREAD_MESSAGE_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.threadMessagesAfter({ + params: { threadId: input.threadId }, + query: { + afterCreatedAt: input.after.createdAt, + afterMessageId: input.after.messageId, + limit: THREAD_MESSAGE_PAGE_SIZE, + }, + 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("limit", String(THREAD_HISTORY_AROUND_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: { limit: THREAD_HISTORY_AROUND_PAGE_SIZE }, + headers, + }), + ), + ); +}); + +export const fetchEnvironmentThreadHistoryOutline = Effect.fn( + "clientRuntime.state.fetchEnvironmentThreadHistoryOutline", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly threadId: ThreadId; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl( + input.prepared.httpBaseUrl, + `/api/orchestration/threads/${input.threadId}/history/outline`, + ); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.orchestration.threadHistoryOutline({ + params: { threadId: input.threadId }, + headers, + }), + ), + ); +}); + export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; /** @@ -134,7 +249,21 @@ export class ThreadSnapshotLoader extends Context.Service< prepared: PreparedConnection, threadId: ThreadId, before: OrchestrationThreadMessageCursor, - ) => Effect.Effect>; + ) => Effect.Effect>; + readonly loadNextMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + after: OrchestrationThreadMessageCursor, + ) => 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") {} @@ -187,20 +316,82 @@ export const threadSnapshotLoaderLayer: Layer.Layer< threadId: ThreadId, before: OrchestrationThreadMessageCursor, ) => - fetchEnvironmentThreadMessages({ prepared, threadId, before, signer }).pipe( - Effect.map(Option.some), + fetchEnvironmentThreadMessagesBefore({ prepared, threadId, before, 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.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()), + Effect.as(Option.none()), + ), + ), + ), + loadNextMessages: ( + prepared: PreparedConnection, + threadId: ThreadId, + after: OrchestrationThreadMessageCursor, + ) => + fetchEnvironmentThreadMessagesAfter({ prepared, threadId, after, 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 8b9928473b4..2b5b8f41889 100644 --- a/packages/client-runtime/src/state/threadState.ts +++ b/packages/client-runtime/src/state/threadState.ts @@ -1,17 +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: "outline" | "before" | "after" | "around" | null; + }; + export interface EnvironmentThreadState { readonly data: Option.Option; + readonly liveData: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; - readonly loadingPreviousMessages?: boolean; + 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 900b751b11d..8323a61e163 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -189,6 +189,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), ), 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, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 8202e988c45..751acf5286a 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1,6 +1,8 @@ import { ORCHESTRATION_WS_METHODS, type EnvironmentId as EnvironmentIdType, + type MessageId, + type OrchestrationThreadHistoryPage, type OrchestrationThread, type OrchestrationThreadDetailSnapshot, type OrchestrationThreadStreamItem, @@ -32,10 +34,13 @@ import { } from "./runtime.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadHistoryState, type EnvironmentThreadState, type EnvironmentThreadStatus, } from "./threadState.ts"; +const THREAD_HISTORY_WINDOW_MAX_MESSAGES = 250; + function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; } @@ -52,6 +57,157 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { return status !== "starting" && status !== "running"; } +function boundLiveThread(thread: OrchestrationThread): OrchestrationThread { + if (thread.messageHistory === undefined || thread.messages.length <= THREAD_MESSAGE_PAGE_SIZE) { + return thread; + } + const messages = thread.messages.slice(-THREAD_MESSAGE_PAGE_SIZE); + const firstMessage = messages[0]; + if (firstMessage === undefined) { + return thread; + } + return { + ...thread, + messages, + messageHistory: { + hasMoreBefore: true, + hasMoreAfter: false, + cursor: { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +function mergeThreadHistoryPages(input: { + readonly older: OrchestrationThreadHistoryPage; + readonly newer: OrchestrationThreadHistoryPage; + readonly hasMoreBefore: boolean; + readonly hasMoreAfter: boolean; +}): 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]; + return { + messages, + activities, + proposedPlans, + messageHistory: { + hasMoreBefore: input.hasMoreBefore, + hasMoreAfter: input.hasMoreAfter, + cursor: + firstMessage === undefined + ? null + : { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +function boundThreadHistoryPage( + page: OrchestrationThreadHistoryPage, + preserve: "older" | "newer", +): OrchestrationThreadHistoryPage { + if (page.messages.length <= THREAD_HISTORY_WINDOW_MAX_MESSAGES) { + return page; + } + const messages = + preserve === "older" + ? page.messages.slice(0, THREAD_HISTORY_WINDOW_MAX_MESSAGES) + : page.messages.slice(-THREAD_HISTORY_WINDOW_MAX_MESSAGES); + const firstMessage = messages[0]; + const lastMessage = messages.at(-1); + if (firstMessage === undefined || lastMessage === undefined) { + return page; + } + return { + messages, + activities: page.activities.filter( + (activity) => + activity.createdAt >= firstMessage.createdAt && activity.createdAt <= lastMessage.createdAt, + ), + proposedPlans: page.proposedPlans.filter( + (plan) => plan.createdAt >= firstMessage.createdAt && plan.createdAt <= lastMessage.createdAt, + ), + messageHistory: { + hasMoreBefore: preserve === "newer" ? true : page.messageHistory.hasMoreBefore, + hasMoreAfter: preserve === "older" ? true : page.messageHistory.hasMoreAfter, + cursor: { + createdAt: firstMessage.createdAt, + messageId: firstMessage.id, + }, + }, + }; +} + +function displayThreadHistory( + liveThread: OrchestrationThread, + history: EnvironmentThreadHistoryState, +): OrchestrationThread { + if (history.kind === "disabled" || history.window === null) { + return liveThread; + } + if (history.window.messageHistory.hasMoreAfter) { + return { + ...liveThread, + messages: history.window.messages, + activities: history.window.activities, + proposedPlans: history.window.proposedPlans, + messageHistory: history.window.messageHistory, + }; + } + const merged = mergeThreadHistoryPages({ + older: history.window, + newer: { + messages: liveThread.messages, + activities: liveThread.activities, + proposedPlans: liveThread.proposedPlans, + messageHistory: liveThread.messageHistory ?? { + hasMoreBefore: false, + hasMoreAfter: false, + cursor: null, + }, + }, + hasMoreBefore: history.window.messageHistory.hasMoreBefore, + hasMoreAfter: false, + }); + return { + ...liveThread, + messages: merged.messages, + activities: merged.activities, + proposedPlans: merged.proposedPlans, + messageHistory: merged.messageHistory, + }; +} + export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, ) { @@ -72,12 +228,16 @@ 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 state = yield* SubscriptionRef.make({ data: cachedThread, + liveData: cachedThread, status: statusWithoutLiveData(cachedThread), error: Option.none(), + history: { kind: "disabled" }, }); + const liveThread = yield* Ref.make(cachedThread); + const historyOutlineRefreshes = yield* SubscriptionRef.make(0); // 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( @@ -103,25 +263,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( snapshot: OrchestrationThreadDetailSnapshot, ) { - const persistedSnapshot = - snapshot.thread.messageHistory !== undefined && - snapshot.thread.messages.length > THREAD_MESSAGE_PAGE_SIZE - ? { - ...snapshot, - thread: { - ...snapshot.thread, - messages: snapshot.thread.messages.slice(-THREAD_MESSAGE_PAGE_SIZE), - messageHistory: { - hasMoreBefore: true, - cursor: { - createdAt: snapshot.thread.messages.at(-THREAD_MESSAGE_PAGE_SIZE)!.createdAt, - messageId: snapshot.thread.messages.at(-THREAD_MESSAGE_PAGE_SIZE)!.id, - }, - }, - }, - } - : snapshot; - yield* cache.saveThread(environmentId, persistedSnapshot).pipe( + yield* cache.saveThread(environmentId, snapshot).pipe( Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ @@ -180,39 +322,93 @@ 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 }); + yield* Queue.offer(persistence, { snapshotSequence, thread: boundedThread }); + } + }); + + const loadHistoryOutline = Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if ( + current.history.kind === "disabled" || + current.history.outline !== null || + current.history.loading !== null + ) { + return; } + yield* SubscriptionRef.set(state, { + ...current, + history: { ...current.history, loading: "outline" }, + }); + yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const outline = yield* snapshotLoader.loadHistoryOutline(prepared, threadId); + if (Option.isNone(outline)) { + return; + } + yield* SubscriptionRef.update(state, (latest) => + latest.history.kind === "disabled" || latest.history.loading !== "outline" + ? latest + : { + ...latest, + history: { ...latest.history, outline: outline.value }, + }, + ); + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + latest.history.kind === "ready" && latest.history.loading === "outline" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), + ), + ); }); const loadPreviousMessages = Effect.gen(function* () { const current = yield* SubscriptionRef.get(state); - if (Option.isNone(current.data)) { + const currentLiveThread = yield* Ref.get(liveThread); + if ( + current.history.kind === "disabled" || + current.history.loading !== null || + Option.isNone(currentLiveThread) + ) { return; } - const messageHistory = current.data.value.messageHistory; - const cursor = messageHistory?.cursor; + const sourceHistory = + current.history.window?.messageHistory ?? currentLiveThread.value.messageHistory; + const cursor = sourceHistory?.cursor; if ( - messageHistory === undefined || - !messageHistory.hasMoreBefore || + sourceHistory === undefined || + !sourceHistory.hasMoreBefore || cursor === null || - cursor === undefined || - current.loadingPreviousMessages === true + cursor === undefined ) { return; } - yield* SubscriptionRef.set(state, { ...current, loadingPreviousMessages: true }); + yield* SubscriptionRef.set(state, { + ...current, + history: { ...current.history, loading: "before" }, + }); yield* Effect.gen(function* () { const prepared = yield* preparedConnection; const page = yield* snapshotLoader.loadPreviousMessages(prepared, threadId, cursor); @@ -221,30 +417,196 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } const latest = yield* SubscriptionRef.get(state); - if (Option.isNone(latest.data)) { + const latestLiveThread = yield* Ref.get(liveThread); + if ( + latest.history.kind === "disabled" || + latest.history.loading !== "before" || + Option.isNone(latestLiveThread) + ) { return; } - yield* setThread({ - ...latest.data.value, - messages: [...page.value.messages, ...latest.data.value.messages], - messageHistory: page.value.messageHistory, + 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, + cursor: null, + }, + }; + const window = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: page.value, + newer: currentWindow, + hasMoreBefore: page.value.messageHistory.hasMoreBefore, + hasMoreAfter: currentWindow.messageHistory.hasMoreAfter, + }), + "older", + ); + const history: EnvironmentThreadHistoryState = { ...latest.history, window }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, }); }).pipe( Effect.ensuring( - SubscriptionRef.update(state, (latest) => ({ - ...latest, - loadingPreviousMessages: false, - })), + 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; + } + + yield* SubscriptionRef.set(state, { + ...current, + history: { ...current.history, loading: "after" }, + }); + yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const page = yield* snapshotLoader.loadNextMessages(prepared, threadId, { + createdAt: lastMessage.createdAt, + messageId: lastMessage.id, + }); + if (Option.isNone(page)) { + return; + } + + const latest = yield* SubscriptionRef.get(state); + const latestLiveThread = yield* Ref.get(liveThread); + if ( + latest.history.kind === "disabled" || + latest.history.loading !== "after" || + latest.history.window === null || + Option.isNone(latestLiveThread) + ) { + return; + } + const nextWindow = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: latest.history.window, + newer: page.value, + hasMoreBefore: latest.history.window.messageHistory.hasMoreBefore, + hasMoreAfter: page.value.messageHistory.hasMoreAfter, + }), + "newer", + ); + const history: EnvironmentThreadHistoryState = { + ...latest.history, + window: nextWindow, + }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, + }); + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + latest.history.kind === "ready" && latest.history.loading === "after" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), + ), + ); + }); + + const loadMessagesAround = Effect.fn("EnvironmentThreadState.loadMessagesAround")(function* ( + messageId: MessageId, + ) { + const current = yield* SubscriptionRef.get(state); + if (current.history.kind === "disabled" || current.history.loading !== null) { + return; + } + yield* SubscriptionRef.set(state, { + ...current, + history: { ...current.history, loading: "around" }, + }); + yield* Effect.gen(function* () { + const prepared = yield* preparedConnection; + const page = yield* snapshotLoader.loadMessagesAround(prepared, threadId, messageId); + if (Option.isNone(page)) { + return; + } + + 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; + } + const history: EnvironmentThreadHistoryState = { + ...latest.history, + window: page.value, + }; + yield* SubscriptionRef.set(state, { + ...latest, + data: Option.some(displayThreadHistory(latestLiveThread.value, history)), + history, + }); + }).pipe( + Effect.ensuring( + SubscriptionRef.update(state, (latest) => + latest.history.kind === "ready" && latest.history.loading === "around" + ? { + ...latest, + history: { ...latest.history, loading: null }, + } + : latest, + ), ), ); }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* Ref.set(awaitingCompletion, false); + yield* Ref.set(liveThread, Option.none()); 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) => @@ -284,15 +646,56 @@ 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") { + const current = yield* SubscriptionRef.get(state); + const refreshOutline = + current.history.kind === "ready" && current.history.loading !== "outline"; + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = + current.history.kind === "disabled" + ? current.history + : { + kind: "ready", + outline: null, + window: null, + loading: current.history.loading === "outline" ? "outline" : null, + }; + return { ...current, history }; + }); + if (refreshOutline) { + yield* SubscriptionRef.update(historyOutlineRefreshes, (revision) => revision + 1); + } + } else if (sentUserMessage) { + const current = yield* SubscriptionRef.get(state); + const refreshOutline = + current.history.kind === "ready" && current.history.loading !== "outline"; + yield* SubscriptionRef.update(state, (current) => { + const history: EnvironmentThreadHistoryState = + current.history.kind === "disabled" + ? current.history + : { + ...current.history, + outline: null, + window: null, + loading: current.history.loading === "outline" ? "outline" : null, + }; + return { ...current, history }; + }); + if (refreshOutline) { + yield* SubscriptionRef.update(historyOutlineRefreshes, (revision) => revision + 1); + } + } yield* setThread(result.thread); } else if (result.kind === "deleted") { yield* setDeleted(); @@ -318,6 +721,17 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make onSome: (service) => service.changes.pipe(Stream.filter((reason) => reason === "application-active")), }); + const messagePaginationSupported = yield* SubscriptionRef.make(false); + yield* SubscriptionRef.changes(messagePaginationSupported).pipe( + Stream.filter((supported) => supported), + Stream.runForEach(() => loadHistoryOutline), + Effect.forkScoped, + ); + yield* SubscriptionRef.changes(historyOutlineRefreshes).pipe( + Stream.filter((revision) => revision > 0), + Stream.runForEach(() => loadHistoryOutline), + Effect.forkScoped, + ); yield* setSynchronizing; yield* Effect.forkScoped( @@ -334,6 +748,25 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); yield* setSynchronizing; + const currentLiveThread = yield* Ref.get(liveThread); + 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(currentLiveThread, (thread) => displayThreadHistory(thread, history)), + liveData: currentLiveThread, + history, + }; + }); let current = yield* SubscriptionRef.get(state); if ( @@ -352,6 +785,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make current = yield* SubscriptionRef.get(state); } } + yield* SubscriptionRef.set(messagePaginationSupported, supportsMessagePagination); const sequence = yield* SubscriptionRef.get(lastSequence); const canResume = Option.isSome(current.data); @@ -379,9 +813,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); 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, @@ -390,12 +824,18 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ), ); - return Object.assign(state, { loadPreviousMessages }); + return Object.assign(state, { + loadPreviousMessages, + loadNextMessages, + loadMessagesAround, + }); }); type EnvironmentThreadStateSubscription = SubscriptionRef.SubscriptionRef & { readonly loadPreviousMessages: Effect.Effect; + readonly loadNextMessages: Effect.Effect; + readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; }; export function threadStateChanges( @@ -472,6 +912,46 @@ export function createEnvironmentThreadStateAtoms( 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.void; + }), + ), + scheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + }, + }), + 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: "singleFlight", + key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + }, + }), }; } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 561aa9b71c6..933a2a89efe 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -39,7 +39,8 @@ import { OrchestrationReadModel, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, - OrchestrationThreadMessagePage, + OrchestrationThreadHistoryOutline, + OrchestrationThreadHistoryPage, ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT, } from "./orchestration.ts"; import { @@ -478,6 +479,21 @@ const EnvironmentOrchestrationThreadMessagesQuery = Schema.Struct({ limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), }); +const EnvironmentOrchestrationThreadMessagesAfterQuery = Schema.Struct({ + afterCreatedAt: IsoDateTime, + afterMessageId: MessageId, + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), +}); + +const EnvironmentOrchestrationThreadMessageAroundParams = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); + +const EnvironmentOrchestrationThreadMessageAroundQuery = Schema.Struct({ + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT)), +}); + export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration") .add( HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", { @@ -507,10 +523,48 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr headers: OptionalBearerHeaders, params: EnvironmentOrchestrationThreadSnapshotParams, query: EnvironmentOrchestrationThreadMessagesQuery, - success: OrchestrationThreadMessagePage, + 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 79ce51d97b4..358b8814027 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -365,6 +365,7 @@ export const OrchestrationLatestTurn = Schema.Struct({ export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; export const ORCHESTRATION_THREAD_MESSAGE_PAGE_MAX_LIMIT = 200; +export const ORCHESTRATION_THREAD_HISTORY_OUTLINE_MAX_LANDMARKS = 128; export const OrchestrationThreadMessageCursor = Schema.Struct({ createdAt: IsoDateTime, @@ -374,10 +375,25 @@ export type OrchestrationThreadMessageCursor = typeof OrchestrationThreadMessage export const OrchestrationThreadMessageHistory = Schema.Struct({ hasMoreBefore: Schema.Boolean, + hasMoreAfter: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), cursor: Schema.NullOr(OrchestrationThreadMessageCursor), }); export type OrchestrationThreadMessageHistory = typeof OrchestrationThreadMessageHistory.Type; +export const OrchestrationThreadHistoryLandmark = Schema.Struct({ + messageId: MessageId, + ordinal: 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 OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -559,11 +575,13 @@ export const OrchestrationThreadDetailSnapshot = Schema.Struct({ }); export type OrchestrationThreadDetailSnapshot = typeof OrchestrationThreadDetailSnapshot.Type; -export const OrchestrationThreadMessagePage = Schema.Struct({ +export const OrchestrationThreadHistoryPage = Schema.Struct({ messages: Schema.Array(OrchestrationMessage), + proposedPlans: Schema.Array(OrchestrationProposedPlan), + activities: Schema.Array(OrchestrationThreadActivity), messageHistory: OrchestrationThreadMessageHistory, }); -export type OrchestrationThreadMessagePage = typeof OrchestrationThreadMessagePage.Type; +export type OrchestrationThreadHistoryPage = typeof OrchestrationThreadHistoryPage.Type; export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), From 552c519f3e59b6cbd25f09b0427291eb9250a1fa Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 18:24:10 +0300 Subject: [PATCH 03/31] stabilize progressive thread history --- FORK.md | 23 +- .../ActivityPayloadProjection.ts | 10 + .../Layers/ProjectionSnapshotQuery.ts | 134 +++- apps/server/src/orchestration/http.ts | 11 +- apps/web/src/components/ChatView.tsx | 34 +- .../src/components/chat/MessagesTimeline.tsx | 738 +++++++++++++++++- apps/web/src/connection/storage.ts | 604 ++++++++++++++ .../src/platform/persistence.ts | 56 ++ .../client-runtime/src/state/threadReducer.ts | 13 + .../src/state/threadSnapshotHttp.ts | 18 +- packages/client-runtime/src/state/threads.ts | 353 ++++++++- packages/contracts/src/orchestration.ts | 4 + 12 files changed, 1877 insertions(+), 121 deletions(-) diff --git a/FORK.md b/FORK.md index 0b4257fe00b..a3dc997c618 100644 --- a/FORK.md +++ b/FORK.md @@ -9,6 +9,11 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Squash commits when merging fork PRs. - Exception: upstream actualization PRs may preserve upstream commit structure when that makes future syncs easier to audit. +### 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. + ### Release And CI - Fork workflows create/update a daily stable release PR while main-branch pushes produce nightly releases. @@ -36,6 +41,8 @@ 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`. +- 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. ### Goals UI @@ -53,8 +60,20 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Thread detail snapshots keep a bounded live tail. Historical browsing uses bounded, bidirectional keyset windows, while the web minimap samples landmarks across the full thread and - loads the selected segment on demand. Historical windows retain every message and cap work - telemetry per segment. + loads the selected segment on demand. The web timeline represents unloaded ranges as virtual + scroll space and swaps bounded segments in as the user approaches or jumps into them. Historical + windows retain every message, cap work telemetry per segment, and only display activity for turns + represented by the active message window. Viewport navigation, minimap jumps, and unloaded spacers + share one fixed per-message scroll axis and load the nearest landmark window through a trailing + throttle instead of chaining relative before/after page loads. The content container keeps the + full-history extent fixed while the trailing spacer absorbs measured segment height. Legend List + position stabilization is disabled for these threads, and its cached window geometry is refreshed + when a segment moves without interrupting an active smooth scroll. + Paginated history responses use the same client-facing activity payload projection as full thread + snapshots, and failed segment requests release their pending navigation target instead of leaving + the virtual loader locked. Minimap jumps begin scrolling through virtual history immediately while + the segment loads, then align the loaded target with a smooth correction instead of teleporting + the viewport after the response. - Desktop context-menu style is configurable. - 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/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 5fd0cc8984b..3fd91dff9a8 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 { @@ -213,6 +214,15 @@ export function projectThreadDetailSnapshot( }; } +export function projectThreadHistoryPage( + page: OrchestrationThreadHistoryPage, +): OrchestrationThreadHistoryPage { + return { + ...page, + activities: page.activities.map(projectActivityPayload), + }; +} + export function projectActivityEvent(event: OrchestrationEvent): OrchestrationEvent { if (event.type !== "thread.activity-appended") { return event; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 7e0820eb03b..a9d2d53e2f7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -135,6 +135,11 @@ const ThreadMessagePageLookupInput = Schema.Struct({ cursorMessageId: MessageId, fetchLimit: PositiveInt, }); +const ThreadMessageCursorLookupInput = Schema.Struct({ + threadId: ThreadId, + cursorCreatedAt: IsoDateTime, + cursorMessageId: MessageId, +}); const ThreadMessageIdLookupInput = Schema.Struct({ threadId: ThreadId, messageId: MessageId, @@ -152,10 +157,14 @@ const THREAD_HISTORY_PAGE_ACTIVITY_LIMIT = 500; const ProjectionThreadHistoryLandmarkRowSchema = Schema.Struct({ messageId: MessageId, ordinal: NonNegativeInt, + messageIndex: NonNegativeInt, createdAt: IsoDateTime, preview: Schema.String, totalUserMessages: NonNegativeInt, }); +const ProjectionThreadMessageCountRowSchema = Schema.Struct({ + count: NonNegativeInt, +}); const ProjectionProjectLookupRowSchema = ProjectionProjectDbRowSchema; const ProjectionThreadIdLookupRowSchema = Schema.Struct({ threadId: ThreadId, @@ -351,6 +360,38 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { LIMIT 1 `, }); + const countThreadMessageRows = SqlSchema.findOne({ + Request: ThreadIdLookupInput, + Result: ProjectionThreadMessageCountRowSchema, + execute: ({ threadId }) => + sql` + SELECT COUNT(*) AS count + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `, + }); + const countThreadMessageRowsBefore = 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 countThreadMessageRowsThrough = 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 listThreadMessageRowsBefore = SqlSchema.findAll({ Request: ThreadMessagePageLookupInput, Result: ProjectionThreadMessageDbRowSchema, @@ -477,15 +518,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { Result: ProjectionThreadHistoryLandmarkRowSchema, execute: ({ threadId, maxLandmarks }) => sql` - WITH ranked AS ( + WITH message_order AS ( SELECT message_id, + role, created_at, - ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS ordinal, - COUNT(*) OVER () AS total_user_messages + ROW_NUMBER() OVER (ORDER BY created_at ASC, message_id ASC) - 1 AS message_index FROM projection_thread_messages WHERE thread_id = ${threadId} - AND role = 'user' + ), + ranked 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' ), bucketed AS ( SELECT @@ -507,6 +557,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { SELECT bucketed.message_id AS "messageId", bucketed.ordinal, + bucketed.message_index AS "messageIndex", bucketed.created_at AS "createdAt", substr(messages.text, 1, 240) AS preview, bucketed.total_user_messages AS "totalUserMessages" @@ -549,7 +600,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly before: OrchestrationThreadMessageCursor; readonly limit: number; }) { - const [thread, rows] = yield* Effect.all([ + const [thread, rows, totalMessageCount, messageCountBeforeCursor] = yield* Effect.all([ getActiveThreadForHistory({ threadId: input.threadId }), listThreadMessageRowsBefore({ threadId: input.threadId, @@ -557,6 +608,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { cursorMessageId: input.before.messageId, fetchLimit: input.limit + 1, }), + countThreadMessageRows({ threadId: input.threadId }), + countThreadMessageRowsBefore({ + threadId: input.threadId, + cursorCreatedAt: input.before.createdAt, + cursorMessageId: input.before.messageId, + }), ]).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -569,9 +626,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const hasMoreBefore = rows.length > input.limit; - const pageRows = hasMoreBefore ? rows.slice(1) : rows; + const pageRows = rows.length > input.limit ? rows.slice(1) : rows; const firstRow = pageRows[0]; + const endIndex = messageCountBeforeCursor.count; + const startIndex = endIndex - pageRows.length; const historyRange = firstRow === undefined ? { activities: [], proposedPlans: [] } @@ -585,8 +643,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: historyRange.activities, proposedPlans: historyRange.proposedPlans, messageHistory: { - hasMoreBefore, - hasMoreAfter: true, + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessageCount.count, + startIndex, + endIndex, + totalMessages: totalMessageCount.count, cursor: firstRow === undefined ? null @@ -604,7 +665,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly after: OrchestrationThreadMessageCursor; readonly limit: number; }) { - const [thread, rows] = yield* Effect.all([ + const [thread, rows, totalMessageCount, messageCountThroughCursor] = yield* Effect.all([ getActiveThreadForHistory({ threadId: input.threadId }), listThreadMessageRowsAfter({ threadId: input.threadId, @@ -612,6 +673,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { cursorMessageId: input.after.messageId, fetchLimit: input.limit + 1, }), + countThreadMessageRows({ threadId: input.threadId }), + countThreadMessageRowsThrough({ + threadId: input.threadId, + cursorCreatedAt: input.after.createdAt, + cursorMessageId: input.after.messageId, + }), ]).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -628,6 +695,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const pageRows = hasMoreAfter ? rows.slice(0, input.limit) : rows; const firstRow = pageRows[0]; const nextRow = rows[input.limit]; + const startIndex = messageCountThroughCursor.count; + const endIndex = startIndex + pageRows.length; const historyRange = firstRow === undefined ? { activities: [], proposedPlans: [] } @@ -641,8 +710,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: historyRange.activities, proposedPlans: historyRange.proposedPlans, messageHistory: { - hasMoreBefore: true, - hasMoreAfter, + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessageCount.count, + startIndex, + endIndex, + totalMessages: totalMessageCount.count, cursor: firstRow === undefined ? null @@ -660,9 +732,10 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { readonly messageId: MessageId; readonly limit: number; }) { - const [thread, target] = yield* Effect.all([ + const [thread, target, totalMessageCount] = yield* Effect.all([ getActiveThreadForHistory({ threadId: input.threadId }), getThreadMessageRowById({ threadId: input.threadId, messageId: input.messageId }), + countThreadMessageRows({ threadId: input.threadId }), ]).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -675,7 +748,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { return Option.none(); } - const [beforeRows, afterRows] = yield* Effect.all([ + const [beforeRows, afterRows, messageCountBeforeTarget] = yield* Effect.all([ listThreadMessageRowsBefore({ threadId: input.threadId, cursorCreatedAt: target.value.createdAt, @@ -688,6 +761,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { cursorMessageId: target.value.messageId, fetchLimit: input.limit + 1, }), + countThreadMessageRowsBefore({ + threadId: input.threadId, + cursorCreatedAt: target.value.createdAt, + cursorMessageId: target.value.messageId, + }), ]).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -708,6 +786,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { if (firstRow === undefined) { return Option.none(); } + const startIndex = messageCountBeforeTarget.count - beforeCount; + const endIndex = startIndex + visibleRows.length; const nextRow = afterRows[afterCount]; const historyRange = yield* loadThreadHistoryRange({ threadId: input.threadId, @@ -719,8 +799,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: historyRange.activities, proposedPlans: historyRange.proposedPlans, messageHistory: { - hasMoreBefore: beforeRows.length > beforeCount, - hasMoreAfter: afterRows.length > afterCount, + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessageCount.count, + startIndex, + endIndex, + totalMessages: totalMessageCount.count, cursor: { createdAt: firstRow.createdAt, messageId: firstRow.messageId, @@ -754,6 +837,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { landmarks: rows.map((row) => ({ messageId: row.messageId, ordinal: row.ordinal, + messageIndex: row.messageIndex, createdAt: row.createdAt, preview: row.preview, })), @@ -2497,6 +2581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointRows, latestTurnRow, sessionRow, + messageCount, ] = yield* Effect.all([ getActiveThreadRowById({ threadId }).pipe( Effect.flatMap((option) => @@ -2565,6 +2650,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + messageLimit === undefined + ? Effect.succeed(null) + : countThreadMessageRows({ threadId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailById:countMessages:query", + "ProjectionSnapshotQuery.getThreadDetailById:countMessages:decodeRow", + ), + ), + ), ]); if (Option.isNone(threadRow)) { @@ -2574,6 +2669,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const hasMoreMessagesBefore = messageLimit !== undefined && messageRows.length > messageLimit; const visibleMessageRows = hasMoreMessagesBefore ? messageRows.slice(1) : messageRows; const firstMessageRow = visibleMessageRows[0]; + const totalMessages = messageCount?.count ?? visibleMessageRows.length; + const startIndex = totalMessages - visibleMessageRows.length; const thread = { id: threadRow.value.threadId, projectId: threadRow.value.projectId, @@ -2598,8 +2695,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ? {} : { messageHistory: { - hasMoreBefore: hasMoreMessagesBefore, + hasMoreBefore: startIndex > 0, hasMoreAfter: false, + startIndex, + endIndex: totalMessages, + totalMessages, cursor: firstMessageRow === undefined ? null diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index 2ec5c6e0606..b1bd6c6bcda 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, @@ -95,7 +98,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( if (Option.isNone(page)) { return yield* failEnvironmentNotFound("thread_not_found"); } - return page.value; + return projectThreadHistoryPage(page.value); }), ) .handle( @@ -120,7 +123,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( if (Option.isNone(page)) { return yield* failEnvironmentNotFound("thread_not_found"); } - return page.value; + return projectThreadHistoryPage(page.value); }), ) .handle( @@ -142,7 +145,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( if (Option.isNone(page)) { return yield* failEnvironmentNotFound("thread_not_found"); } - return page.value; + return projectThreadHistoryPage(page.value); }), ) .handle( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63dfbc72fac..fa5120024c7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1137,12 +1137,6 @@ function ChatViewContent(props: ChatViewProps) { }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); const requestThreadGoal = useAtomCommand(threadEnvironment.requestGoal, { reportFailure: false }); - const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { - reportFailure: false, - }); - const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { - reportFailure: false, - }); const loadMessagesAround = useAtomCommand(environmentThreads.loadMessagesAround, { reportFailure: false, }); @@ -1421,18 +1415,6 @@ function ChatViewContent(props: ChatViewProps) { const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; const operationalThread = isServerThread ? liveServerThread : activeThread; - const handleLoadPreviousMessages = useCallback(() => { - void loadPreviousMessages({ - environmentId, - input: { threadId }, - }); - }, [environmentId, loadPreviousMessages, threadId]); - const handleLoadNextMessages = useCallback(() => { - void loadNextMessages({ - environmentId, - input: { threadId }, - }); - }, [environmentId, loadNextMessages, threadId]); const handleSelectHistoryMessage = useCallback( (messageId: MessageId) => { if ( @@ -1445,6 +1427,15 @@ function ChatViewContent(props: ChatViewProps) { 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], @@ -5917,8 +5908,9 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} - hasPreviousMessages={activeThread.messageHistory?.hasMoreBefore === true} - hasNextMessages={activeThread.messageHistory?.hasMoreAfter === true} + {...(activeThread.messageHistory === undefined + ? {} + : { messageHistory: activeThread.messageHistory })} isLoadingPreviousMessages={ environmentThreadState.history.kind === "ready" && (environmentThreadState.history.loading === "before" || @@ -5928,8 +5920,6 @@ function ChatViewContent(props: ChatViewProps) { environmentThreadState.history.kind === "ready" && environmentThreadState.history.loading === "after" } - onLoadPreviousMessages={handleLoadPreviousMessages} - onLoadNextMessages={handleLoadNextMessages} historyOutline={ environmentThreadState.history.kind === "ready" ? environmentThreadState.history.outline diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2d94c0c068d..2602a19b82b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2,6 +2,7 @@ import { type EnvironmentId, type MessageId, type OrchestrationThreadHistoryOutline, + type OrchestrationThreadMessageHistory, type ScopedThreadRef, type ServerProviderSkill, type TurnId, @@ -15,12 +16,15 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, + type PointerEvent, type ReactNode, + type RefObject, } from "react"; import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; @@ -60,6 +64,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"; @@ -149,6 +154,9 @@ interface TimelineRowActivityState { const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_FOOTER =
; +const TIMELINE_VIRTUAL_MESSAGE_SIZE = 120; +const TIMELINE_HISTORY_SCROLL_THROTTLE_MS = 180; +const TIMELINE_HISTORY_SKELETON_PERIOD = TIMELINE_VIRTUAL_MESSAGE_SIZE * 2; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; // --------------------------------------------------------------------------- @@ -184,12 +192,9 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; - hasPreviousMessages?: boolean; - hasNextMessages?: boolean; + messageHistory?: OrchestrationThreadMessageHistory; isLoadingPreviousMessages?: boolean; isLoadingNextMessages?: boolean; - onLoadPreviousMessages?: () => void; - onLoadNextMessages?: () => void; historyOutline?: OrchestrationThreadHistoryOutline | null; historyTargetMessageId?: MessageId | null; onSelectHistoryMessage?: (messageId: MessageId) => void; @@ -229,12 +234,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, - hasPreviousMessages = false, - hasNextMessages = false, + messageHistory, isLoadingPreviousMessages = false, isLoadingNextMessages = false, - onLoadPreviousMessages, - onLoadNextMessages, historyOutline = null, historyTargetMessageId = null, onSelectHistoryMessage, @@ -243,6 +245,40 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); + const requestedHistoryMessageIdRef = useRef(null); + const requestedHistoryMessageIndexRef = useRef(null); + const historyNavigationEnabledRef = useRef(false); + const historyNavigationPendingRef = useRef(false); + const historyNavigationTimeoutRef = useRef(null); + const historyNavigationLastRunAtRef = useRef(0); + const historyPointerActiveRef = useRef(false); + const historyPointerReleasePendingRef = useRef(false); + const historyBeforeSpacerRef = useRef(null); + const historyAfterSpacerRef = useRef(null); + const historyBeforeSkeletonsRef = useRef(null); + const historyAfterSkeletonsRef = useRef(null); + const virtualHistoryBeforeSize = + (messageHistory?.startIndex ?? 0) * TIMELINE_VIRTUAL_MESSAGE_SIZE; + const virtualHistoryAfterSize = + Math.max(0, (messageHistory?.totalMessages ?? 0) - (messageHistory?.endIndex ?? 0)) * + TIMELINE_VIRTUAL_MESSAGE_SIZE; + const [historySpacerOverride, setHistorySpacerOverride] = useState<{ + readonly startIndex: number; + readonly endIndex: number; + readonly beforeSize: number; + readonly afterSize: number; + } | null>(null); + const historySpacerOverrideMatchesWindow = + historySpacerOverride !== null && + messageHistory !== undefined && + historySpacerOverride.startIndex === messageHistory.startIndex && + historySpacerOverride.endIndex === messageHistory.endIndex; + const historyBeforeSize = historySpacerOverrideMatchesWindow + ? historySpacerOverride.beforeSize + : virtualHistoryBeforeSize; + const historyAfterSize = historySpacerOverrideMatchesWindow + ? historySpacerOverride.afterSize + : virtualHistoryAfterSize; const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -353,6 +389,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const enableHistoryNavigation = useCallback(() => { + historyNavigationEnabledRef.current = true; + }, []); + const beginHistoryPointerNavigation = useCallback( + (event: PointerEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if (event.target !== scrollNode) { + return; + } + historyNavigationEnabledRef.current = true; + historyPointerActiveRef.current = true; + historyPointerReleasePendingRef.current = false; + }, + [listRef], + ); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -384,12 +435,167 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } - if (!state || minimapItems.length === 0) { + if (!state) { return; } - const scrollTop = state.scroll ?? 0; - const scrollBottom = scrollTop + (state.scrollLength ?? 0); + const scrollNode = listRef.current?.getScrollableNode(); + const scrollTop = scrollNode?.scrollTop ?? state.scroll ?? 0; + const scrollLength = scrollNode?.clientHeight ?? state.scrollLength ?? 0; + const scrollBottom = scrollTop + scrollLength; + const renderedHistoryBeforeSize = + historyBeforeSpacerRef.current?.offsetHeight ?? historyBeforeSize; + const renderedHistoryAfterSize = + historyAfterSpacerRef.current?.offsetHeight ?? historyAfterSize; + const loadedHistoryEnd = + (scrollNode?.scrollHeight ?? state.contentLength ?? 0) - renderedHistoryAfterSize; + const historyBeforeSkeletons = historyBeforeSkeletonsRef.current; + if (historyBeforeSkeletons !== null) { + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollTop / TIMELINE_HISTORY_SKELETON_PERIOD) * + TIMELINE_HISTORY_SKELETON_PERIOD - + TIMELINE_HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryBeforeSize - TIMELINE_HISTORY_SKELETON_PERIOD) / + TIMELINE_HISTORY_SKELETON_PERIOD, + ) * TIMELINE_HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyBeforeSkeletons.style.transform !== transform) { + historyBeforeSkeletons.style.transform = transform; + } + } + const historyAfterSkeletons = historyAfterSkeletonsRef.current; + if (historyAfterSkeletons !== null) { + const scrollWithinHistory = Math.max(0, scrollTop - loadedHistoryEnd); + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollWithinHistory / TIMELINE_HISTORY_SKELETON_PERIOD) * + TIMELINE_HISTORY_SKELETON_PERIOD - + TIMELINE_HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryAfterSize - TIMELINE_HISTORY_SKELETON_PERIOD) / + TIMELINE_HISTORY_SKELETON_PERIOD, + ) * TIMELINE_HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyAfterSkeletons.style.transform !== transform) { + historyAfterSkeletons.style.transform = transform; + } + } + const historyRequestInProgress = + isLoadingPreviousMessages || + isLoadingNextMessages || + historyTargetMessageId !== null || + requestedHistoryMessageIdRef.current !== null; + const viewportMessageIndex = + messageHistory === undefined || scrollNode === undefined + ? null + : Math.max( + 0, + Math.min( + messageHistory.totalMessages - 1, + (scrollTop + scrollLength / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE, + ), + ); + if ( + !historyRequestInProgress && + viewportMessageIndex !== null && + messageHistory !== undefined && + (viewportMessageIndex < messageHistory.startIndex || + viewportMessageIndex >= messageHistory.endIndex) + ) { + historyNavigationEnabledRef.current = true; + historyPointerReleasePendingRef.current = !historyPointerActiveRef.current; + } + + if (historyNavigationEnabledRef.current) { + historyNavigationPendingRef.current = true; + if (!historyRequestInProgress && historyNavigationTimeoutRef.current === null) { + const throttleDelay = Math.max( + 0, + historyNavigationLastRunAtRef.current + + TIMELINE_HISTORY_SCROLL_THROTTLE_MS - + performance.now(), + ); + historyNavigationTimeoutRef.current = window.setTimeout(() => { + historyNavigationTimeoutRef.current = null; + historyNavigationPendingRef.current = false; + if (!historyNavigationEnabledRef.current) { + return; + } + historyNavigationLastRunAtRef.current = performance.now(); + const latestList = listRef.current; + const latestScrollNode = latestList?.getScrollableNode(); + if (latestScrollNode === undefined) { + return; + } + let requestedSegment = false; + const latestScrollTop = latestScrollNode.scrollTop; + const latestScrollLength = latestScrollNode.clientHeight; + if ( + historyOutline !== null && + onSelectHistoryMessage !== undefined && + messageHistory !== undefined && + historyOutline.landmarks.length > 0 + ) { + const clampedMessageIndex = Math.max( + 0, + Math.min( + messageHistory.totalMessages - 1, + (latestScrollTop + latestScrollLength / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE, + ), + ); + if ( + clampedMessageIndex < messageHistory.startIndex || + clampedMessageIndex >= messageHistory.endIndex + ) { + let target = historyOutline.landmarks[0]!; + let targetMessageIndex = + target.messageIndex ?? + (target.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + for (const landmark of historyOutline.landmarks) { + const landmarkMessageIndex = + landmark.messageIndex ?? + (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + if ( + Math.abs(landmarkMessageIndex - clampedMessageIndex) < + Math.abs(targetMessageIndex - clampedMessageIndex) + ) { + target = landmark; + targetMessageIndex = landmarkMessageIndex; + } + } + historyPointerReleasePendingRef.current = !historyPointerActiveRef.current; + requestedHistoryMessageIdRef.current = target.messageId; + requestedHistoryMessageIndexRef.current = Math.round(targetMessageIndex); + onManualNavigation(); + onSelectHistoryMessage(target.messageId); + requestedSegment = true; + } + } + if (!requestedSegment && !historyPointerActiveRef.current) { + historyNavigationEnabledRef.current = false; + if (historyPointerReleasePendingRef.current) { + historyPointerReleasePendingRef.current = false; + } + } + }, throttleDelay); + } + } for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); @@ -397,7 +603,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ continue; } - const rowTop = resolveTimelineRowTop(state, item.rowIndex); + const rowTopWithinWindow = resolveTimelineRowTop(state, item.rowIndex); + const rowTop = rowTopWithinWindow === null ? null : historyBeforeSize + rowTopWithinWindow; const rowHeight = resolveTimelineRowHeight(state, item.rowIndex); const inView = rowTop !== null && @@ -406,12 +613,209 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + historyOutline, + historyAfterSize, + historyBeforeSize, + historyTargetMessageId, + isLoadingNextMessages, + isLoadingPreviousMessages, + listRef, + messageHistory, + minimapItems, + minimapStripMap, + onIsAtEndChange, + onManualNavigation, + onSelectHistoryMessage, + ]); + + const finishHistoryPointerNavigation = useCallback(() => { + if (!historyPointerActiveRef.current) { + return; + } + historyPointerActiveRef.current = false; + historyPointerReleasePendingRef.current = true; + if (historyNavigationEnabledRef.current) { + historyNavigationPendingRef.current = true; + handleScroll(); + } else if ( + !isLoadingPreviousMessages && + !isLoadingNextMessages && + historyTargetMessageId === null && + requestedHistoryMessageIdRef.current === null + ) { + historyPointerReleasePendingRef.current = false; + } + }, [handleScroll, historyTargetMessageId, isLoadingNextMessages, isLoadingPreviousMessages]); useEffect(() => { - const frame = requestAnimationFrame(handleScroll); + window.addEventListener("pointerup", finishHistoryPointerNavigation); + window.addEventListener("pointercancel", finishHistoryPointerNavigation); + return () => { + window.removeEventListener("pointerup", finishHistoryPointerNavigation); + window.removeEventListener("pointercancel", finishHistoryPointerNavigation); + }; + }, [finishHistoryPointerNavigation]); + + useLayoutEffect(() => { + if (messageHistory === undefined) { + return; + } + const beforeSpacer = historyBeforeSpacerRef.current; + const afterSpacer = historyAfterSpacerRef.current; + const beforeSpacerContainer = beforeSpacer?.parentElement; + const loadedRowsContainer = beforeSpacerContainer?.nextElementSibling; + const afterSpacerContainer = afterSpacer?.parentElement; + if ( + beforeSpacer === null || + beforeSpacerContainer === null || + beforeSpacerContainer === undefined || + loadedRowsContainer === null || + loadedRowsContainer === undefined || + afterSpacer === null || + afterSpacerContainer === null || + afterSpacerContainer === undefined + ) { + return; + } + + const stabilizeScrollExtent = () => { + let delta = + messageHistory.totalMessages * TIMELINE_VIRTUAL_MESSAGE_SIZE - + beforeSpacer.offsetHeight - + loadedRowsContainer.getBoundingClientRect().height - + afterSpacer.offsetHeight; + if (Math.abs(delta) <= 1) { + return; + } + let nextBeforeSize = beforeSpacer.offsetHeight; + let nextAfterSize = afterSpacer.offsetHeight; + if (delta > 0) { + nextAfterSize += delta; + } else { + const afterReduction = Math.min(nextAfterSize, -delta); + nextAfterSize -= afterReduction; + delta += afterReduction; + if (delta < 0) { + nextBeforeSize = Math.max(0, nextBeforeSize + delta); + } + } + setHistorySpacerOverride((current) => { + if ( + current?.startIndex === messageHistory.startIndex && + current.endIndex === messageHistory.endIndex && + Math.abs(current.beforeSize - nextBeforeSize) <= 1 && + Math.abs(current.afterSize - nextAfterSize) <= 1 + ) { + return current; + } + return { + startIndex: messageHistory.startIndex, + endIndex: messageHistory.endIndex, + beforeSize: nextBeforeSize, + afterSize: nextAfterSize, + }; + }); + }; + + stabilizeScrollExtent(); + const observer = new ResizeObserver(stabilizeScrollExtent); + observer.observe(beforeSpacerContainer); + observer.observe(loadedRowsContainer); + return () => { + observer.disconnect(); + }; + }, [messageHistory, rows.length]); + + useEffect(() => { + if (historyTargetMessageId !== null || isLoadingPreviousMessages || isLoadingNextMessages) { + if (historyNavigationTimeoutRef.current !== null) { + window.clearTimeout(historyNavigationTimeoutRef.current); + historyNavigationTimeoutRef.current = null; + } + return; + } + const settledRequestMessageId = requestedHistoryMessageIdRef.current; + const frame = requestAnimationFrame(() => { + if (requestedHistoryMessageIdRef.current !== settledRequestMessageId) { + return; + } + const requestFailed = settledRequestMessageId !== null; + requestedHistoryMessageIdRef.current = null; + requestedHistoryMessageIndexRef.current = null; + if (requestFailed) { + historyNavigationEnabledRef.current = false; + historyNavigationPendingRef.current = false; + historyPointerReleasePendingRef.current = false; + if (!historyPointerActiveRef.current) { + historyNavigationEnabledRef.current = false; + } + } else if (historyNavigationPendingRef.current && historyNavigationEnabledRef.current) { + handleScroll(); + } else if (historyPointerReleasePendingRef.current && !historyPointerActiveRef.current) { + historyPointerReleasePendingRef.current = false; + historyNavigationEnabledRef.current = false; + } + }); return () => cancelAnimationFrame(frame); - }, [handleScroll, rows.length]); + }, [handleScroll, historyTargetMessageId, isLoadingNextMessages, isLoadingPreviousMessages]); + + useEffect( + () => () => { + if (historyNavigationTimeoutRef.current !== null) { + window.clearTimeout(historyNavigationTimeoutRef.current); + } + }, + [], + ); + + useEffect(() => { + const frame = requestAnimationFrame(handleScroll); + return () => { + cancelAnimationFrame(frame); + }; + }, [handleScroll, historyAfterSize, historyBeforeSize, rows.length]); + + useLayoutEffect(() => { + if (messageHistory === undefined) { + return; + } + const list = listRef.current; + const scrollNode = list?.getScrollableNode(); + if (list === null || list === undefined || scrollNode === undefined) { + return; + } + + list.clearCaches(); + const timeout = window.setTimeout(() => { + const loadedRowsContainer = historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; + const viewportMessageIndex = + (scrollNode.scrollTop + scrollNode.clientHeight / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE; + if ( + loadedRowsContainer === null || + loadedRowsContainer === undefined || + viewportMessageIndex < messageHistory.startIndex || + viewportMessageIndex >= messageHistory.endIndex + ) { + return; + } + const viewportRect = scrollNode.getBoundingClientRect(); + const viewportContainsRenderedRow = Array.from(loadedRowsContainer.children).some((row) => { + const rowRect = row.getBoundingClientRect(); + return rowRect.bottom > viewportRect.top && rowRect.top < viewportRect.bottom; + }); + if (viewportContainsRenderedRow) { + return; + } + const scrollTop = scrollNode.scrollTop; + const nudgeScrollTop = scrollTop > 1 ? scrollTop - 2 : scrollTop + 2; + void list.scrollToOffset({ offset: nudgeScrollTop, animated: false }); + void list.scrollToOffset({ offset: scrollTop, animated: false }); + }, TIMELINE_HISTORY_SCROLL_THROTTLE_MS); + return () => { + window.clearTimeout(timeout); + }; + }, [historyBeforeSize, listRef, messageHistory?.endIndex, messageHistory?.startIndex]); useEffect(() => { if (historyTargetMessageId === null) { @@ -423,16 +827,188 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (rowIndex < 0) { return; } - const frame = requestAnimationFrame(() => { - void listRef.current?.scrollToIndex({ - index: rowIndex, - animated: false, - viewOffset: 24, - }); + requestedHistoryMessageIdRef.current = null; + if (historyNavigationEnabledRef.current) { + let cancelled = false; + let frame = 0; + let attempts = 0; + const placeTarget = () => { + frame = requestAnimationFrame(() => { + if (cancelled) { + return; + } + const state = listRef.current?.getState?.(); + const scrollNode = listRef.current?.getScrollableNode(); + const beforeSpacer = historyBeforeSpacerRef.current; + const afterSpacer = historyAfterSpacerRef.current; + const rowTop = state === undefined ? null : resolveTimelineRowTop(state, rowIndex); + if ( + state === undefined || + scrollNode === undefined || + beforeSpacer === null || + afterSpacer === null || + rowTop === null + ) { + attempts += 1; + if (attempts < 90) { + placeTarget(); + } else { + requestedHistoryMessageIndexRef.current = null; + onHistoryTargetReady?.(); + } + return; + } + const delta = Math.max( + -beforeSpacer.offsetHeight, + Math.min( + afterSpacer.offsetHeight, + scrollNode.scrollTop + 24 - beforeSpacer.offsetHeight - rowTop, + ), + ); + if (Math.abs(delta) > 1) { + if (messageHistory !== undefined) { + setHistorySpacerOverride({ + startIndex: messageHistory.startIndex, + endIndex: messageHistory.endIndex, + beforeSize: beforeSpacer.offsetHeight + delta, + afterSize: afterSpacer.offsetHeight - delta, + }); + attempts += 1; + if (attempts < 90) { + placeTarget(); + } else { + requestedHistoryMessageIndexRef.current = null; + onHistoryTargetReady?.(); + } + return; + } + } + requestedHistoryMessageIndexRef.current = null; + onHistoryTargetReady?.(); + }); + }; + placeTarget(); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + }; + } + let cancelled = false; + let frame = 0; + let alignmentTimeout: number | null = null; + let alignmentScrollNode: HTMLElement | null = null; + let alignmentScrollEnd: (() => void) | null = null; + let mountAttempts = 0; + let alignmentAttempts = 0; + let approximateScrollStarted = false; + let completed = false; + const clearAlignmentWait = () => { + if (alignmentTimeout !== null) { + window.clearTimeout(alignmentTimeout); + alignmentTimeout = null; + } + if (alignmentScrollNode !== null && alignmentScrollEnd !== null) { + alignmentScrollNode.removeEventListener("scrollend", alignmentScrollEnd); + } + alignmentScrollNode = null; + alignmentScrollEnd = null; + }; + const completeTarget = () => { + if (completed) { + return; + } + completed = true; + clearAlignmentWait(); + requestedHistoryMessageIndexRef.current = null; onHistoryTargetReady?.(); - }); - return () => cancelAnimationFrame(frame); - }, [historyTargetMessageId, listRef, onHistoryTargetReady, rows]); + }; + const positionTarget = () => { + frame = requestAnimationFrame(() => { + if (cancelled) { + return; + } + if (historyPointerActiveRef.current) { + completeTarget(); + return; + } + const list = listRef.current; + if (list === null || timelineViewportElement === null) { + return; + } + const scrollNode = list.getScrollableNode(); + const target = timelineViewportElement.querySelector( + `[data-message-id="${CSS.escape(historyTargetMessageId)}"]`, + ); + if (target === null) { + mountAttempts += 1; + if (!approximateScrollStarted && mountAttempts >= 2) { + approximateScrollStarted = true; + const messageIndex = requestedHistoryMessageIndexRef.current; + if (messageIndex === null) { + void list.scrollToIndex({ + index: rowIndex, + animated: true, + viewOffset: 24, + }); + } else { + scrollNode.scrollTo({ + behavior: "smooth", + top: Math.max( + 0, + Math.min( + scrollNode.scrollHeight - scrollNode.clientHeight, + messageIndex * TIMELINE_VIRTUAL_MESSAGE_SIZE - 24, + ), + ), + }); + } + } + if (mountAttempts < 90) { + positionTarget(); + } else { + completeTarget(); + } + return; + } + const targetScrollTop = + scrollNode.scrollTop + + target.getBoundingClientRect().top - + scrollNode.getBoundingClientRect().top - + 24; + if (Math.abs(targetScrollTop - scrollNode.scrollTop) <= 2 || alignmentAttempts >= 2) { + completeTarget(); + return; + } + alignmentAttempts += 1; + clearAlignmentWait(); + const finishAlignment = () => { + clearAlignmentWait(); + positionTarget(); + }; + alignmentScrollNode = scrollNode; + alignmentScrollEnd = finishAlignment; + scrollNode.addEventListener("scrollend", finishAlignment, { once: true }); + alignmentTimeout = window.setTimeout(finishAlignment, 500); + scrollNode.scrollTo({ + behavior: "smooth", + top: targetScrollTop, + }); + }); + }; + positionTarget(); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + clearAlignmentWait(); + }; + }, [ + historyTargetMessageId, + listRef, + messageHistory, + onHistoryTargetReady, + rows, + timelineViewportElement, + ]); useEffect(() => { if (!timelineViewportElement) { @@ -527,7 +1103,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
+
ref={listRef} data={rows} @@ -535,11 +1118,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} + estimatedHeaderSize={ + historyBeforeSize > 0 ? historyBeforeSize : topFadeEnabled ? 48 : 16 + } initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace || hasNextMessages + anchoredEndSpace || messageHistory?.hasMoreAfter === true ? false : { animated: false, @@ -550,21 +1136,41 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, } } - maintainVisibleContentPosition={{ - data: true, - size: false, - }} - onStartReached={hasPreviousMessages ? onLoadPreviousMessages : undefined} - onStartReachedThreshold={0.8} - onEndReached={hasNextMessages ? onLoadNextMessages : undefined} - onEndReachedThreshold={0.8} + maintainVisibleContentPosition={ + messageHistory === undefined && historyTargetMessageId === null + ? { + data: true, + size: true, + } + : false + } onScroll={handleScroll} + {...(messageHistory === undefined + ? {} + : { + contentContainerStyle: { + height: + messageHistory.totalMessages * TIMELINE_VIRTUAL_MESSAGE_SIZE + + contentInsetEndAdjustment, + overflow: "hidden", + }, + })} 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={ - isLoadingPreviousMessages ? ( + messageHistory !== undefined ? ( +
+ {virtualHistoryBeforeSize > 0 ? ( + + ) : null} +
+ ) : isLoadingPreviousMessages ? (
Loading earlier messages...
@@ -573,7 +1179,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ) } ListFooterComponent={ - isLoadingNextMessages ? ( + messageHistory !== undefined ? ( +
+ {virtualHistoryAfterSize > 0 ? ( + + ) : null} +
+ ) : isLoadingNextMessages ? (
Loading newer messages...
@@ -589,8 +1205,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} onSelect={(item) => { + historyNavigationEnabledRef.current = false; + historyNavigationPendingRef.current = false; + if (historyNavigationTimeoutRef.current !== null) { + window.clearTimeout(historyNavigationTimeoutRef.current); + historyNavigationTimeoutRef.current = null; + } onManualNavigation(); if (item.rowIndex === null) { + requestedHistoryMessageIndexRef.current = item.messageIndex; + const scrollNode = listRef.current?.getScrollableNode(); + if (item.messageIndex !== null && scrollNode !== undefined) { + scrollNode.scrollTo({ + behavior: "smooth", + top: Math.max( + 0, + Math.min( + scrollNode.scrollHeight - scrollNode.clientHeight, + item.messageIndex * TIMELINE_VIRTUAL_MESSAGE_SIZE - 24, + ), + ), + }); + } onSelectHistoryMessage?.(item.id); } else { void listRef.current?.scrollToIndex({ @@ -607,6 +1243,33 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); +function TimelineHistorySkeletons({ windowRef }: { windowRef: RefObject }) { + return ( + + ); +} + function keyExtractor(item: MessagesTimelineRow) { return item.id; } @@ -617,6 +1280,7 @@ function getItemType(item: MessagesTimelineRow) { interface TimelineMinimapItem { readonly id: MessageId; + readonly messageIndex: number | null; readonly rowIndex: number | null; readonly userText: string | null; readonly assistantText: string | null; @@ -646,6 +1310,7 @@ function deriveTimelineMinimapItems( const rowIndex = rowIndexByMessageId.get(landmark.messageId) ?? null; return { id: landmark.messageId, + messageIndex: landmark.messageIndex ?? null, rowIndex, userText: compactMinimapPreview(landmark.preview), assistantText: @@ -664,6 +1329,7 @@ function deriveTimelineMinimapItems( items.push({ id: row.message.id, + messageIndex: null, rowIndex: index, userText: compactMinimapPreview(row.message.text), assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 04d237a5030..5f871dbbf42 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 { @@ -19,7 +20,11 @@ import { } from "@t3tools/client-runtime/connection"; import { EnvironmentId, + NonNegativeInt, + OrchestrationMessage, + OrchestrationProposedPlan, OrchestrationShellSnapshot, + OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, ServerConfig, ThreadId, @@ -35,6 +40,16 @@ import * as Semaphore from "effect/Semaphore"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; +const THREAD_HISTORY_DATABASE_NAME = "t3code:tarik02-thread-history-cache"; +const THREAD_HISTORY_DATABASE_VERSION = 2; +const THREAD_HISTORY_LEGACY_STORE_NAME = "thread-history"; +const THREAD_HISTORY_THREAD_STORE_NAME = "thread"; +const THREAD_HISTORY_MESSAGE_STORE_NAME = "message"; +const THREAD_HISTORY_ACTIVITY_STORE_NAME = "activity"; +const THREAD_HISTORY_PLAN_STORE_NAME = "plan"; +const THREAD_HISTORY_MESSAGE_ID_INDEX_NAME = "by-message-id"; +const THREAD_HISTORY_ACTIVITY_POSITION_INDEX_NAME = "by-position"; +const THREAD_HISTORY_PLAN_POSITION_INDEX_NAME = "by-position"; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; @@ -42,6 +57,7 @@ const SERVER_CONFIG_STORE_NAME = "server-config"; const VCS_REFS_STORE_NAME = "vcs-refs"; const CATALOG_KEY = "document"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const THREAD_HISTORY_CACHE_SCHEMA_VERSION = 1; const StoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), @@ -59,6 +75,45 @@ const StoredThreadSnapshot = Schema.Struct({ snapshot: OrchestrationThreadDetailSnapshot, }); const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const StoredThreadHistoryRange = Schema.Struct({ + startIndex: NonNegativeInt, + endIndex: NonNegativeInt, +}); +const StoredThreadHistoryThread = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_HISTORY_CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + totalMessages: NonNegativeInt, + ranges: Schema.Array(StoredThreadHistoryRange), +}); +const StoredThreadHistoryMessage = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_HISTORY_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(THREAD_HISTORY_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(THREAD_HISTORY_CACHE_SCHEMA_VERSION), + scope: Schema.String, + environmentId: EnvironmentId, + threadId: ThreadId, + position: NonNegativeInt, + planId: OrchestrationProposedPlan.fields.id, + plan: OrchestrationProposedPlan, +}); const StoredServerConfig = Schema.Struct({ schemaVersion: Schema.Literal(1), environmentId: EnvironmentId, @@ -79,6 +134,16 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); +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), +); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson); @@ -101,6 +166,8 @@ function persistenceError( | "load-thread" | "save-thread" | "remove-thread" + | "load-thread-history" + | "save-thread-history" | "load-server-config" | "save-server-config" | "load-vcs-refs" @@ -149,6 +216,68 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( }); }); +const openThreadHistoryDatabase = Effect.fn("web.connectionStorage.openThreadHistoryDatabase")( + function* () { + return yield* Effect.callback((resume) => { + if (globalThis.indexedDB === undefined) { + resume( + Effect.fail(catalogError("open", "IndexedDB is unavailable in this browser context.")), + ); + return; + } + const request = indexedDB.open(THREAD_HISTORY_DATABASE_NAME, THREAD_HISTORY_DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + if (request.result.objectStoreNames.contains(THREAD_HISTORY_LEGACY_STORE_NAME)) { + request.result.deleteObjectStore(THREAD_HISTORY_LEGACY_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(THREAD_HISTORY_THREAD_STORE_NAME)) { + request.result.createObjectStore(THREAD_HISTORY_THREAD_STORE_NAME, { + keyPath: "scope", + }); + } + if (!request.result.objectStoreNames.contains(THREAD_HISTORY_MESSAGE_STORE_NAME)) { + const store = request.result.createObjectStore(THREAD_HISTORY_MESSAGE_STORE_NAME, { + keyPath: ["scope", "index"], + }); + store.createIndex(THREAD_HISTORY_MESSAGE_ID_INDEX_NAME, ["scope", "messageId"], { + unique: true, + }); + } + if (!request.result.objectStoreNames.contains(THREAD_HISTORY_ACTIVITY_STORE_NAME)) { + const store = request.result.createObjectStore(THREAD_HISTORY_ACTIVITY_STORE_NAME, { + keyPath: ["scope", "activityId"], + }); + store.createIndex( + THREAD_HISTORY_ACTIVITY_POSITION_INDEX_NAME, + ["scope", "position", "activity.createdAt", "activityId"], + { unique: true }, + ); + } + if (!request.result.objectStoreNames.contains(THREAD_HISTORY_PLAN_STORE_NAME)) { + const store = request.result.createObjectStore(THREAD_HISTORY_PLAN_STORE_NAME, { + keyPath: ["scope", "planId"], + }); + store.createIndex( + THREAD_HISTORY_PLAN_POSITION_INDEX_NAME, + ["scope", "position", "plan.createdAt", "planId"], + { unique: true }, + ); + } + }); + request.addEventListener("error", () => { + resume( + Effect.fail( + catalogError("open thread history cache", request.error ?? "Unknown IndexedDB error"), + ), + ); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }); + }, +); + function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { return Effect.callback((resume) => { const request = database.transaction(storeName, "readonly").objectStore(storeName).get(key); @@ -161,6 +290,24 @@ function readDatabaseValue(database: IDBDatabase, storeName: string, key: IDBVal }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValue")); } +function readDatabaseValuesInRange( + 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(catalogError("read", request.error ?? "Unknown IndexedDB read error"))); + }); + request.addEventListener("success", () => { + resume(Effect.succeed(request.result)); + }); + }).pipe(Effect.withSpan("web.connectionStorage.readDatabaseValuesInRange")); +} + function writeDatabaseValue( database: IDBDatabase, storeName: string, @@ -181,6 +328,47 @@ function writeDatabaseValue( }).pipe(Effect.withSpan("web.connectionStorage.writeDatabaseValue")); } +function writeThreadHistoryPage( + database: IDBDatabase, + thread: object, + messages: ReadonlyArray, + activities: ReadonlyArray, + plans: ReadonlyArray, +) { + return Effect.callback((resume) => { + const transaction = database.transaction( + [ + THREAD_HISTORY_THREAD_STORE_NAME, + THREAD_HISTORY_MESSAGE_STORE_NAME, + THREAD_HISTORY_ACTIVITY_STORE_NAME, + THREAD_HISTORY_PLAN_STORE_NAME, + ], + "readwrite", + ); + transaction.addEventListener("error", () => { + resume( + Effect.fail(catalogError("write", transaction.error ?? "Unknown IndexedDB write error")), + ); + }); + transaction.addEventListener("complete", () => { + resume(Effect.void); + }); + transaction.objectStore(THREAD_HISTORY_THREAD_STORE_NAME).put(thread); + const messageStore = transaction.objectStore(THREAD_HISTORY_MESSAGE_STORE_NAME); + for (const message of messages) { + messageStore.put(message); + } + const activityStore = transaction.objectStore(THREAD_HISTORY_ACTIVITY_STORE_NAME); + for (const activity of activities) { + activityStore.put(activity); + } + const planStore = transaction.objectStore(THREAD_HISTORY_PLAN_STORE_NAME); + for (const plan of plans) { + planStore.put(plan); + } + }).pipe(Effect.withSpan("web.connectionStorage.writeThreadHistoryPage")); +} + function removeDatabaseValue(database: IDBDatabase, storeName: string, key: IDBValidKey) { return Effect.callback((resume) => { const transaction = database.transaction(storeName, "readwrite"); @@ -228,6 +416,119 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } +function threadHistoryScope(environmentId: EnvironmentId, threadId: ThreadId) { + return `${environmentId}:${threadId}`; +} + +const loadThreadHistoryThread = Effect.fn("web.connectionStorage.loadThreadHistoryThread")( + function* (database: IDBDatabase, environmentId: EnvironmentId, threadId: ThreadId) { + const scope = threadHistoryScope(environmentId, threadId); + const raw = yield* readDatabaseValue(database, THREAD_HISTORY_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 loadThreadHistoryPage = Effect.fn("web.connectionStorage.loadThreadHistoryPage")(function* ( + database: IDBDatabase, + environmentId: EnvironmentId, + threadId: ThreadId, + startIndex: number, + endIndex: number, + totalMessages: number, +) { + if (endIndex <= startIndex) { + return Option.none(); + } + const scope = threadHistoryScope(environmentId, threadId); + const [rawMessages, rawActivities, rawPlans] = yield* Effect.all([ + readDatabaseValuesInRange( + database, + THREAD_HISTORY_MESSAGE_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex - 1]), + ), + readDatabaseValuesInRange( + database, + THREAD_HISTORY_ACTIVITY_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex, "\uffff", "\uffff"]), + THREAD_HISTORY_ACTIVITY_POSITION_INDEX_NAME, + ), + readDatabaseValuesInRange( + database, + THREAD_HISTORY_PLAN_STORE_NAME, + IDBKeyRange.bound([scope, startIndex], [scope, endIndex, "\uffff", "\uffff"]), + THREAD_HISTORY_PLAN_POSITION_INDEX_NAME, + ), + ]); + const [messageRecords, activityRecords, planRecords] = yield* Effect.all([ + decodeStoredThreadHistoryMessages(rawMessages), + decodeStoredThreadHistoryActivities(rawActivities), + 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, + }, + }, + }); +}); + function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { return `${environmentId}:${cwd}`; } @@ -363,6 +664,11 @@ export const connectionStorageLayer = Layer.effectContext( const database = yield* Effect.acquireRelease(openDatabase(), (database) => Effect.sync(() => database.close()), ); + const threadHistoryDatabase = yield* Effect.acquireRelease( + openThreadHistoryDatabase(), + (database) => Effect.sync(() => database.close()), + ); + const threadHistoryWriteLock = yield* Semaphore.make(1); const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); const targetStore = ConnectionTargetStore.of({ @@ -640,6 +946,303 @@ export const connectionStorageLayer = Layer.effectContext( VCS_REFS_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_MESSAGE_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_ACTIVITY_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_PLAN_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + }); + const threadHistoryCacheStore = ThreadHistoryCacheStore.of({ + loadPrevious: (environmentId, threadId, endIndex, limit) => + Effect.gen(function* () { + const thread = yield* loadThreadHistoryThread( + threadHistoryDatabase, + 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) { + return { + page: yield* loadThreadHistoryPage( + threadHistoryDatabase, + environmentId, + threadId, + Math.max(range.startIndex, endIndex - limit), + endIndex, + thread.value.totalMessages, + ), + requestLimit: limit, + }; + } + const nearestRange = thread.value.ranges.findLast( + (candidate) => candidate.endIndex < endIndex, + ); + return { + page: Option.none(), + requestLimit: Math.min( + limit, + endIndex - (nearestRange?.endIndex ?? Math.max(0, endIndex - limit)), + ), + }; + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + loadNext: (environmentId, threadId, startIndex, limit) => + Effect.gen(function* () { + const thread = yield* loadThreadHistoryThread( + threadHistoryDatabase, + 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) { + return { + page: yield* loadThreadHistoryPage( + threadHistoryDatabase, + environmentId, + threadId, + startIndex, + Math.min(range.endIndex, startIndex + limit), + thread.value.totalMessages, + ), + requestLimit: limit, + }; + } + const nearestRange = thread.value.ranges.find( + (candidate) => candidate.startIndex > startIndex, + ); + return { + page: Option.none(), + requestLimit: Math.min( + limit, + (nearestRange?.startIndex ?? startIndex + limit) - startIndex, + ), + }; + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + loadAround: (environmentId, threadId, messageId, limit) => + Effect.gen(function* () { + const scope = threadHistoryScope(environmentId, threadId); + const [thread, rawTargets] = yield* Effect.all([ + loadThreadHistoryThread(threadHistoryDatabase, environmentId, threadId), + readDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_MESSAGE_STORE_NAME, + IDBKeyRange.only([scope, messageId]), + THREAD_HISTORY_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(); + } + let startIndex = Math.max(range.startIndex, target.index - Math.floor((limit - 1) / 2)); + const endIndex = Math.min(range.endIndex, startIndex + limit); + startIndex = Math.max(range.startIndex, endIndex - limit); + return yield* loadThreadHistoryPage( + threadHistoryDatabase, + environmentId, + threadId, + startIndex, + endIndex, + thread.value.totalMessages, + ); + }).pipe(Effect.mapError((cause) => persistenceError("load-thread-history", cause))), + loadTotalMessages: (environmentId, threadId) => + loadThreadHistoryThread(threadHistoryDatabase, environmentId, threadId).pipe( + Effect.map(Option.map((thread) => thread.totalMessages)), + Effect.mapError((cause) => persistenceError("load-thread-history", cause)), + ), + save: (environmentId, threadId, page) => + threadHistoryWriteLock + .withPermits(1)( + Effect.gen(function* () { + if (page.messages.length === 0) { + return; + } + const scope = threadHistoryScope(environmentId, threadId); + const current = yield* loadThreadHistoryThread( + threadHistoryDatabase, + 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 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: THREAD_HISTORY_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: THREAD_HISTORY_CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + position: page.messageHistory.startIndex + planMessageOffset, + planId: plan.id, + plan, + }; + }); + yield* writeThreadHistoryPage( + threadHistoryDatabase, + { + schemaVersion: THREAD_HISTORY_CACHE_SCHEMA_VERSION, + scope, + environmentId, + threadId, + totalMessages: Math.max( + Option.isSome(current) ? current.value.totalMessages : 0, + page.messageHistory.totalMessages, + ), + ranges: mergedRanges, + }, + page.messages.map((message, offset) => ({ + schemaVersion: THREAD_HISTORY_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) => + Effect.all( + [ + removeDatabaseValue( + threadHistoryDatabase, + THREAD_HISTORY_THREAD_STORE_NAME, + threadHistoryScope(environmentId, threadId), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_MESSAGE_STORE_NAME, + IDBKeyRange.bound( + [threadHistoryScope(environmentId, threadId), 0], + [threadHistoryScope(environmentId, threadId), Number.MAX_SAFE_INTEGER], + ), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_ACTIVITY_STORE_NAME, + IDBKeyRange.bound( + [threadHistoryScope(environmentId, threadId)], + [threadHistoryScope(environmentId, threadId), "\uffff"], + ), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_PLAN_STORE_NAME, + IDBKeyRange.bound( + [threadHistoryScope(environmentId, threadId)], + [threadHistoryScope(environmentId, threadId), "\uffff"], + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + clear: (environmentId) => + Effect.all( + [ + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_THREAD_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_MESSAGE_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_ACTIVITY_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), + removeDatabaseValuesInRange( + threadHistoryDatabase, + THREAD_HISTORY_PLAN_STORE_NAME, + IDBKeyRange.bound([`${environmentId}:`], [`${environmentId}:\uffff`]), + ), ], { concurrency: "unbounded", discard: true }, ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), @@ -651,6 +1254,7 @@ export const connectionStorageLayer = Layer.effectContext( Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), Context.add(EnvironmentCacheStore, cacheStore), + Context.add(ThreadHistoryCacheStore, threadHistoryCacheStore), ); }), ); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8723ac06939..35fc98a4aaf 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,7 +1,9 @@ import { type EnvironmentId, + type MessageId, type OrchestrationShellSnapshot, type OrchestrationThreadDetailSnapshot, + type OrchestrationThreadHistoryPage, type ServerConfig, type ThreadId, type VcsListRefsResult, @@ -26,6 +28,8 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} +export interface ThreadHistoryCacheLookup { + readonly page: Option.Option; + 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 loadTotalMessages: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly save: ( + environmentId: EnvironmentId, + threadId: ThreadId, + page: OrchestrationThreadHistoryPage, + ) => Effect.Effect; + readonly remove: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Effect.Effect; + readonly clear: (environmentId: EnvironmentId) => Effect.Effect; +}>("@t3tools/client-runtime/platform/persistence/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()), + loadTotalMessages: () => Effect.succeed(Option.none()), + save: () => Effect.void, + remove: () => Effect.void, + clear: () => Effect.void, + }), +}) {} + export class EnvironmentOwnedDataCleanup extends Context.Reference<{ readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 0ce54417266..b7884cf0d29 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -316,6 +316,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, diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index e11a7d9d24f..1463f773ee7 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -80,6 +80,7 @@ export const fetchEnvironmentThreadMessagesBefore = Effect.fn( readonly prepared: PreparedConnection; readonly threadId: ThreadId; readonly before: OrchestrationThreadMessageCursor; + readonly limit: number; readonly signer: Option.Option; readonly timeoutMs?: number; }) { @@ -91,7 +92,7 @@ export const fetchEnvironmentThreadMessagesBefore = Effect.fn( ); requestUrl.searchParams.set("beforeCreatedAt", input.before.createdAt); requestUrl.searchParams.set("beforeMessageId", input.before.messageId); - requestUrl.searchParams.set("limit", String(THREAD_MESSAGE_PAGE_SIZE)); + requestUrl.searchParams.set("limit", String(input.limit)); const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); const headers = yield* buildEnvironmentAuthHeaders( input.prepared.httpAuthorization, @@ -109,7 +110,7 @@ export const fetchEnvironmentThreadMessagesBefore = Effect.fn( query: { beforeCreatedAt: input.before.createdAt, beforeMessageId: input.before.messageId, - limit: THREAD_MESSAGE_PAGE_SIZE, + limit: input.limit, }, headers, }), @@ -123,6 +124,7 @@ export const fetchEnvironmentThreadMessagesAfter = Effect.fn( readonly prepared: PreparedConnection; readonly threadId: ThreadId; readonly after: OrchestrationThreadMessageCursor; + readonly limit: number; readonly signer: Option.Option; readonly timeoutMs?: number; }) { @@ -134,7 +136,7 @@ export const fetchEnvironmentThreadMessagesAfter = Effect.fn( ); requestUrl.searchParams.set("afterCreatedAt", input.after.createdAt); requestUrl.searchParams.set("afterMessageId", input.after.messageId); - requestUrl.searchParams.set("limit", String(THREAD_MESSAGE_PAGE_SIZE)); + requestUrl.searchParams.set("limit", String(input.limit)); const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); const headers = yield* buildEnvironmentAuthHeaders( input.prepared.httpAuthorization, @@ -152,7 +154,7 @@ export const fetchEnvironmentThreadMessagesAfter = Effect.fn( query: { afterCreatedAt: input.after.createdAt, afterMessageId: input.after.messageId, - limit: THREAD_MESSAGE_PAGE_SIZE, + limit: input.limit, }, headers, }), @@ -249,11 +251,13 @@ export class ThreadSnapshotLoader extends Context.Service< prepared: PreparedConnection, threadId: ThreadId, before: OrchestrationThreadMessageCursor, + limit: number, ) => Effect.Effect>; readonly loadNextMessages: ( prepared: PreparedConnection, threadId: ThreadId, after: OrchestrationThreadMessageCursor, + limit: number, ) => Effect.Effect>; readonly loadMessagesAround: ( prepared: PreparedConnection, @@ -315,8 +319,9 @@ export const threadSnapshotLoaderLayer: Layer.Layer< prepared: PreparedConnection, threadId: ThreadId, before: OrchestrationThreadMessageCursor, + limit: number, ) => - fetchEnvironmentThreadMessagesBefore({ prepared, threadId, before, signer }).pipe( + fetchEnvironmentThreadMessagesBefore({ prepared, threadId, before, limit, signer }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchTags({ @@ -337,8 +342,9 @@ export const threadSnapshotLoaderLayer: Layer.Layer< prepared: PreparedConnection, threadId: ThreadId, after: OrchestrationThreadMessageCursor, + limit: number, ) => - fetchEnvironmentThreadMessagesAfter({ prepared, threadId, after, signer }).pipe( + fetchEnvironmentThreadMessagesAfter({ prepared, threadId, after, limit, signer }).pipe( Effect.map(Option.some), Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catchTags({ diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 751acf5286a..df93ab54e94 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -21,9 +21,13 @@ import { EnvironmentRegistry } from "../connection/registry.ts"; 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 { EnvironmentCacheStore, ThreadHistoryCacheStore } from "../platform/persistence.ts"; import { subscribeDynamic } from "../rpc/client.ts"; -import { THREAD_MESSAGE_PAGE_SIZE, ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; +import { + THREAD_HISTORY_AROUND_PAGE_SIZE, + THREAD_MESSAGE_PAGE_SIZE, + ThreadSnapshotLoader, +} from "./threadSnapshotHttp.ts"; import { parseThreadKey, threadKey } from "./entities.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts"; @@ -58,20 +62,39 @@ function shouldPersistThread(thread: OrchestrationThread): boolean { } function boundLiveThread(thread: OrchestrationThread): OrchestrationThread { - if (thread.messageHistory === undefined || thread.messages.length <= THREAD_MESSAGE_PAGE_SIZE) { + if (thread.messageHistory === undefined) { return thread; } - const messages = thread.messages.slice(-THREAD_MESSAGE_PAGE_SIZE); + const messages = + thread.messages.length > THREAD_MESSAGE_PAGE_SIZE + ? thread.messages.slice(-THREAD_MESSAGE_PAGE_SIZE) + : thread.messages; 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: true, - hasMoreAfter: false, + 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, @@ -83,8 +106,6 @@ function boundLiveThread(thread: OrchestrationThread): OrchestrationThread { function mergeThreadHistoryPages(input: { readonly older: OrchestrationThreadHistoryPage; readonly newer: OrchestrationThreadHistoryPage; - readonly hasMoreBefore: boolean; - readonly hasMoreAfter: boolean; }): OrchestrationThreadHistoryPage { const messages = [ ...new Map( @@ -114,13 +135,28 @@ function mergeThreadHistoryPages(input: { 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: input.hasMoreBefore, - hasMoreAfter: input.hasMoreAfter, + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < totalMessages, + startIndex, + endIndex, + totalMessages, cursor: firstMessage === undefined ? null @@ -148,6 +184,14 @@ function boundThreadHistoryPage( if (firstMessage === undefined || lastMessage === undefined) { return page; } + const startIndex = + preserve === "older" + ? page.messageHistory.startIndex + : page.messageHistory.endIndex - messages.length; + const endIndex = + preserve === "older" + ? page.messageHistory.startIndex + messages.length + : page.messageHistory.endIndex; return { messages, activities: page.activities.filter( @@ -158,8 +202,11 @@ function boundThreadHistoryPage( (plan) => plan.createdAt >= firstMessage.createdAt && plan.createdAt <= lastMessage.createdAt, ), messageHistory: { - hasMoreBefore: preserve === "newer" ? true : page.messageHistory.hasMoreBefore, - hasMoreAfter: preserve === "older" ? true : page.messageHistory.hasMoreAfter, + hasMoreBefore: startIndex > 0, + hasMoreAfter: endIndex < page.messageHistory.totalMessages, + startIndex, + endIndex, + totalMessages: page.messageHistory.totalMessages, cursor: { createdAt: firstMessage.createdAt, messageId: firstMessage.id, @@ -175,17 +222,34 @@ function displayThreadHistory( if (history.kind === "disabled" || history.window === null) { return liveThread; } - if (history.window.messageHistory.hasMoreAfter) { + 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: history.window.messages, - activities: history.window.activities, - proposedPlans: history.window.proposedPlans, - messageHistory: history.window.messageHistory, + messages: visibleHistoryWindow.messages, + activities: visibleHistoryWindow.activities, + proposedPlans: visibleHistoryWindow.proposedPlans, + messageHistory: { + ...visibleHistoryWindow.messageHistory, + hasMoreAfter: visibleHistoryWindow.messageHistory.endIndex < totalMessages, + totalMessages, + }, }; } const merged = mergeThreadHistoryPages({ - older: history.window, + older: visibleHistoryWindow, newer: { messages: liveThread.messages, activities: liveThread.activities, @@ -193,11 +257,12 @@ function displayThreadHistory( messageHistory: liveThread.messageHistory ?? { hasMoreBefore: false, hasMoreAfter: false, + startIndex: 0, + endIndex: liveThread.messages.length, + totalMessages: liveThread.messages.length, cursor: null, }, }, - hasMoreBefore: history.window.messageHistory.hasMoreBefore, - hasMoreAfter: false, }); return { ...liveThread, @@ -213,9 +278,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; + const historyCache = yield* ThreadHistoryCacheStore; const snapshotLoader = yield* ThreadSnapshotLoader; const wakeups = yield* Effect.serviceOption(ConnectionWakeups.ConnectionWakeups); const environmentId = supervisor.target.environmentId; + const cacheHistoryPage = Effect.fn("EnvironmentThreadState.cacheHistoryPage")(function* ( + page: OrchestrationThreadHistoryPage, + ) { + yield* historyCache.save(environmentId, threadId, page).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( @@ -274,6 +370,19 @@ 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, + }); + } }); yield* Stream.fromQueue(persistence).pipe( @@ -405,13 +514,78 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return; } + const historyLookup = yield* historyCache + .loadPrevious(environmentId, threadId, sourceHistory.startIndex, THREAD_MESSAGE_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_MESSAGE_PAGE_SIZE, + }), + ), + }), + ); + if (Option.isSome(historyLookup.page)) { + const liveMessages = currentLiveThread.value.messages; + const firstLiveMessage = liveMessages[0]; + const currentWindow = current.history.window ?? { + messages: liveMessages, + activities: + firstLiveMessage === undefined + ? [] + : currentLiveThread.value.activities.filter( + (activity) => activity.createdAt >= firstLiveMessage.createdAt, + ), + proposedPlans: + firstLiveMessage === undefined + ? [] + : currentLiveThread.value.proposedPlans.filter( + (plan) => plan.createdAt >= firstLiveMessage.createdAt, + ), + messageHistory: currentLiveThread.value.messageHistory ?? { + hasMoreBefore: false, + hasMoreAfter: false, + startIndex: 0, + endIndex: liveMessages.length, + totalMessages: liveMessages.length, + cursor: null, + }, + }; + const window = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: historyLookup.page.value, + newer: currentWindow, + }), + "older", + ); + const history: EnvironmentThreadHistoryState = { ...current.history, window }; + yield* SubscriptionRef.set(state, { + ...current, + data: Option.some(displayThreadHistory(currentLiveThread.value, history)), + history, + }); + return; + } + yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "before" }, }); yield* Effect.gen(function* () { const prepared = yield* preparedConnection; - const page = yield* snapshotLoader.loadPreviousMessages(prepared, threadId, cursor); + const page = yield* snapshotLoader.loadPreviousMessages( + prepared, + threadId, + cursor, + historyLookup.requestLimit, + ); if (Option.isNone(page)) { return; } @@ -444,6 +618,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make messageHistory: latestLiveThread.value.messageHistory ?? { hasMoreBefore: false, hasMoreAfter: false, + startIndex: 0, + endIndex: liveMessages.length, + totalMessages: liveMessages.length, cursor: null, }, }; @@ -451,8 +628,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make mergeThreadHistoryPages({ older: page.value, newer: currentWindow, - hasMoreBefore: page.value.messageHistory.hasMoreBefore, - hasMoreAfter: currentWindow.messageHistory.hasMoreAfter, }), "older", ); @@ -462,6 +637,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: Option.some(displayThreadHistory(latestLiveThread.value, history)), history, }); + yield* cacheHistoryPage(page.value); }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => @@ -492,16 +668,59 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make return; } + const historyLookup = yield* historyCache + .loadNext(environmentId, threadId, window.messageHistory.endIndex, THREAD_MESSAGE_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_MESSAGE_PAGE_SIZE, + }), + ), + }), + ); + if (Option.isSome(historyLookup.page)) { + const nextWindow = boundThreadHistoryPage( + mergeThreadHistoryPages({ + older: window, + newer: historyLookup.page.value, + }), + "newer", + ); + const history: EnvironmentThreadHistoryState = { + ...current.history, + window: nextWindow, + }; + yield* SubscriptionRef.set(state, { + ...current, + data: Option.some(displayThreadHistory(currentLiveThread.value, history)), + history, + }); + return; + } + yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "after" }, }); yield* Effect.gen(function* () { const prepared = yield* preparedConnection; - const page = yield* snapshotLoader.loadNextMessages(prepared, threadId, { - createdAt: lastMessage.createdAt, - messageId: lastMessage.id, - }); + const page = yield* snapshotLoader.loadNextMessages( + prepared, + threadId, + { + createdAt: lastMessage.createdAt, + messageId: lastMessage.id, + }, + historyLookup.requestLimit, + ); if (Option.isNone(page)) { return; } @@ -520,8 +739,6 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make mergeThreadHistoryPages({ older: latest.history.window, newer: page.value, - hasMoreBefore: latest.history.window.messageHistory.hasMoreBefore, - hasMoreAfter: page.value.messageHistory.hasMoreAfter, }), "newer", ); @@ -534,6 +751,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: Option.some(displayThreadHistory(latestLiveThread.value, history)), history, }); + yield* cacheHistoryPage(page.value); }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => @@ -553,17 +771,60 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) { const current = yield* SubscriptionRef.get(state); if (current.history.kind === "disabled" || current.history.loading !== null) { - return; + return false; + } + const cachedPage = yield* historyCache + .loadAround(environmentId, threadId, messageId, THREAD_HISTORY_AROUND_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()), + ), + }), + ); + if (Option.isSome(cachedPage)) { + const currentLiveThread = yield* Ref.get(liveThread); + if (Option.isNone(currentLiveThread)) { + return false; + } + const totalMessages = Math.max( + cachedPage.value.messageHistory.totalMessages, + currentLiveThread.value.messageHistory?.totalMessages ?? + currentLiveThread.value.messages.length, + ); + const history: EnvironmentThreadHistoryState = { + ...current.history, + window: { + ...cachedPage.value, + messageHistory: { + ...cachedPage.value.messageHistory, + hasMoreAfter: cachedPage.value.messageHistory.endIndex < totalMessages, + totalMessages, + }, + }, + }; + yield* SubscriptionRef.set(state, { + ...current, + data: Option.some(displayThreadHistory(currentLiveThread.value, history)), + history, + }); + return true; } yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "around" }, }); - yield* Effect.gen(function* () { + return yield* Effect.gen(function* () { const prepared = yield* preparedConnection; const page = yield* snapshotLoader.loadMessagesAround(prepared, threadId, messageId); if (Option.isNone(page)) { - return; + return false; } const latest = yield* SubscriptionRef.get(state); @@ -573,7 +834,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make latest.history.loading !== "around" || Option.isNone(latestLiveThread) ) { - return; + return false; } const history: EnvironmentThreadHistoryState = { ...latest.history, @@ -584,6 +845,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: Option.some(displayThreadHistory(latestLiveThread.value, history)), history, }); + yield* cacheHistoryPage(page.value); + return true; }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => @@ -601,6 +864,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make 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(), @@ -635,6 +899,26 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } if (item.kind === "snapshot") { + const snapshotTotalMessages = + item.snapshot.thread.messageHistory?.totalMessages ?? item.snapshot.thread.messages.length; + const cachedTotalMessages = yield* historyCache + .loadTotalMessages(environmentId, threadId) + .pipe( + Effect.catchTags({ + ConnectionPersistenceError: (error) => + Effect.logWarning("Could not inspect cached thread history.").pipe( + Effect.annotateLogs({ + environmentId, + threadId, + error: error.message, + }), + Effect.as(Option.none()), + ), + }), + ); + if (Option.isSome(cachedTotalMessages) && cachedTotalMessages.value > snapshotTotalMessages) { + yield* clearHistoryCache(); + } yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); return; @@ -658,6 +942,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const sentUserMessage = item.event.type === "thread.message-sent" && item.event.payload.role === "user"; if (item.event.type === "thread.reverted") { + yield* clearHistoryCache(); const current = yield* SubscriptionRef.get(state); const refreshOutline = current.history.kind === "ready" && current.history.loading !== "outline"; @@ -835,7 +1120,7 @@ type EnvironmentThreadStateSubscription = SubscriptionRef.SubscriptionRef & { readonly loadPreviousMessages: Effect.Effect; readonly loadNextMessages: Effect.Effect; - readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; + readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; }; export function threadStateChanges( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 358b8814027..68b709baae5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -376,6 +376,9 @@ export type OrchestrationThreadMessageCursor = typeof OrchestrationThreadMessage 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; @@ -383,6 +386,7 @@ export type OrchestrationThreadMessageHistory = typeof OrchestrationThreadMessag export const OrchestrationThreadHistoryLandmark = Schema.Struct({ messageId: MessageId, ordinal: NonNegativeInt, + messageIndex: Schema.optional(NonNegativeInt), createdAt: IsoDateTime, preview: Schema.String, }); From e73ce8f71ba43fba54152893686d47fe3be6fdf6 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 20:15:16 +0300 Subject: [PATCH 04/31] stabilize progressive history with viewport anchors --- FORK.md | 5 +- apps/web/src/components/ChatView.tsx | 5 +- .../src/components/chat/MessagesTimeline.tsx | 752 +-------------- .../chat/useProgressiveTimelineHistory.ts | 903 ++++++++++++++++++ packages/client-runtime/src/state/threads.ts | 2 +- 5 files changed, 954 insertions(+), 713 deletions(-) create mode 100644 apps/web/src/components/chat/useProgressiveTimelineHistory.ts diff --git a/FORK.md b/FORK.md index a3dc997c618..35bb3051d38 100644 --- a/FORK.md +++ b/FORK.md @@ -73,7 +73,10 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork snapshots, and failed segment requests release their pending navigation target instead of leaving the virtual loader locked. Minimap jumps begin scrolling through virtual history immediately while the segment loads, then align the loaded target with a smooth correction instead of teleporting - the viewport after the response. + the viewport after the response. Scrollbar movement keeps a logical viewport anchor, while minimap + jumps keep a concrete message anchor; segment swaps and layout resizes reconcile against that + anchor without changing the fixed scroll extent. Around-window requests keep one active load and + coalesce intermediate targets to the latest requested position. - Desktop context-menu style is configurable. - 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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fa5120024c7..a8b07e72a8c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1417,10 +1417,7 @@ function ChatViewContent(props: ChatViewProps) { const operationalThread = isServerThread ? liveServerThread : activeThread; const handleSelectHistoryMessage = useCallback( (messageId: MessageId) => { - if ( - environmentThreadState.history.kind !== "ready" || - environmentThreadState.history.loading !== null - ) { + if (environmentThreadState.history.kind !== "ready") { return; } setHistoryTarget({ threadKey: routeThreadKey, messageId }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 2602a19b82b..0f8a344ceb2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -16,13 +16,11 @@ import { use, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, - type PointerEvent, type ReactNode, type RefObject, } from "react"; @@ -76,7 +74,6 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, - resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, @@ -114,6 +111,10 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; +import { + useProgressiveTimelineHistory, + type TimelineHistoryNavigationTarget, +} from "./useProgressiveTimelineHistory"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -154,9 +155,6 @@ interface TimelineRowActivityState { const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_FOOTER =
; -const TIMELINE_VIRTUAL_MESSAGE_SIZE = 120; -const TIMELINE_HISTORY_SCROLL_THROTTLE_MS = 180; -const TIMELINE_HISTORY_SKELETON_PERIOD = TIMELINE_VIRTUAL_MESSAGE_SIZE * 2; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; // --------------------------------------------------------------------------- @@ -245,40 +243,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); const [minimapStripMap] = useState(() => new Map()); - const requestedHistoryMessageIdRef = useRef(null); - const requestedHistoryMessageIndexRef = useRef(null); - const historyNavigationEnabledRef = useRef(false); - const historyNavigationPendingRef = useRef(false); - const historyNavigationTimeoutRef = useRef(null); - const historyNavigationLastRunAtRef = useRef(0); - const historyPointerActiveRef = useRef(false); - const historyPointerReleasePendingRef = useRef(false); - const historyBeforeSpacerRef = useRef(null); - const historyAfterSpacerRef = useRef(null); - const historyBeforeSkeletonsRef = useRef(null); - const historyAfterSkeletonsRef = useRef(null); - const virtualHistoryBeforeSize = - (messageHistory?.startIndex ?? 0) * TIMELINE_VIRTUAL_MESSAGE_SIZE; - const virtualHistoryAfterSize = - Math.max(0, (messageHistory?.totalMessages ?? 0) - (messageHistory?.endIndex ?? 0)) * - TIMELINE_VIRTUAL_MESSAGE_SIZE; - const [historySpacerOverride, setHistorySpacerOverride] = useState<{ - readonly startIndex: number; - readonly endIndex: number; - readonly beforeSize: number; - readonly afterSize: number; - } | null>(null); - const historySpacerOverrideMatchesWindow = - historySpacerOverride !== null && - messageHistory !== undefined && - historySpacerOverride.startIndex === messageHistory.startIndex && - historySpacerOverride.endIndex === messageHistory.endIndex; - const historyBeforeSize = historySpacerOverrideMatchesWindow - ? historySpacerOverride.beforeSize - : virtualHistoryBeforeSize; - const historyAfterSize = historySpacerOverrideMatchesWindow - ? historySpacerOverride.afterSize - : virtualHistoryAfterSize; const onToggleTurnFold = useCallback((turnId: TurnId) => { setExpandedTurnIds((existing) => { @@ -389,21 +353,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); - const enableHistoryNavigation = useCallback(() => { - historyNavigationEnabledRef.current = true; - }, []); - const beginHistoryPointerNavigation = useCallback( - (event: PointerEvent) => { - const scrollNode = listRef.current?.getScrollableNode(); - if (event.target !== scrollNode) { - return; - } - historyNavigationEnabledRef.current = true; - historyPointerActiveRef.current = true; - historyPointerReleasePendingRef.current = false; - }, - [listRef], - ); + const progressiveHistory = useProgressiveTimelineHistory({ + historyOutline, + historyTargetMessageId, + isLoadingNextMessages, + isLoadingPreviousMessages, + listRef, + messageHistory, + minimapItems, + minimapStripMap, + onHistoryTargetReady, + onIsAtEndChange, + onManualNavigation, + onSelectHistoryMessage, + rows, + timelineViewportElement, + }); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -429,587 +394,6 @@ 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) { - return; - } - - const scrollNode = listRef.current?.getScrollableNode(); - const scrollTop = scrollNode?.scrollTop ?? state.scroll ?? 0; - const scrollLength = scrollNode?.clientHeight ?? state.scrollLength ?? 0; - const scrollBottom = scrollTop + scrollLength; - const renderedHistoryBeforeSize = - historyBeforeSpacerRef.current?.offsetHeight ?? historyBeforeSize; - const renderedHistoryAfterSize = - historyAfterSpacerRef.current?.offsetHeight ?? historyAfterSize; - const loadedHistoryEnd = - (scrollNode?.scrollHeight ?? state.contentLength ?? 0) - renderedHistoryAfterSize; - const historyBeforeSkeletons = historyBeforeSkeletonsRef.current; - if (historyBeforeSkeletons !== null) { - const offset = Math.min( - Math.max( - 0, - Math.floor(scrollTop / TIMELINE_HISTORY_SKELETON_PERIOD) * - TIMELINE_HISTORY_SKELETON_PERIOD - - TIMELINE_HISTORY_SKELETON_PERIOD, - ), - Math.max( - 0, - Math.floor( - (renderedHistoryBeforeSize - TIMELINE_HISTORY_SKELETON_PERIOD) / - TIMELINE_HISTORY_SKELETON_PERIOD, - ) * TIMELINE_HISTORY_SKELETON_PERIOD, - ), - ); - const transform = `translateY(${offset}px)`; - if (historyBeforeSkeletons.style.transform !== transform) { - historyBeforeSkeletons.style.transform = transform; - } - } - const historyAfterSkeletons = historyAfterSkeletonsRef.current; - if (historyAfterSkeletons !== null) { - const scrollWithinHistory = Math.max(0, scrollTop - loadedHistoryEnd); - const offset = Math.min( - Math.max( - 0, - Math.floor(scrollWithinHistory / TIMELINE_HISTORY_SKELETON_PERIOD) * - TIMELINE_HISTORY_SKELETON_PERIOD - - TIMELINE_HISTORY_SKELETON_PERIOD, - ), - Math.max( - 0, - Math.floor( - (renderedHistoryAfterSize - TIMELINE_HISTORY_SKELETON_PERIOD) / - TIMELINE_HISTORY_SKELETON_PERIOD, - ) * TIMELINE_HISTORY_SKELETON_PERIOD, - ), - ); - const transform = `translateY(${offset}px)`; - if (historyAfterSkeletons.style.transform !== transform) { - historyAfterSkeletons.style.transform = transform; - } - } - const historyRequestInProgress = - isLoadingPreviousMessages || - isLoadingNextMessages || - historyTargetMessageId !== null || - requestedHistoryMessageIdRef.current !== null; - const viewportMessageIndex = - messageHistory === undefined || scrollNode === undefined - ? null - : Math.max( - 0, - Math.min( - messageHistory.totalMessages - 1, - (scrollTop + scrollLength / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE, - ), - ); - if ( - !historyRequestInProgress && - viewportMessageIndex !== null && - messageHistory !== undefined && - (viewportMessageIndex < messageHistory.startIndex || - viewportMessageIndex >= messageHistory.endIndex) - ) { - historyNavigationEnabledRef.current = true; - historyPointerReleasePendingRef.current = !historyPointerActiveRef.current; - } - - if (historyNavigationEnabledRef.current) { - historyNavigationPendingRef.current = true; - if (!historyRequestInProgress && historyNavigationTimeoutRef.current === null) { - const throttleDelay = Math.max( - 0, - historyNavigationLastRunAtRef.current + - TIMELINE_HISTORY_SCROLL_THROTTLE_MS - - performance.now(), - ); - historyNavigationTimeoutRef.current = window.setTimeout(() => { - historyNavigationTimeoutRef.current = null; - historyNavigationPendingRef.current = false; - if (!historyNavigationEnabledRef.current) { - return; - } - historyNavigationLastRunAtRef.current = performance.now(); - const latestList = listRef.current; - const latestScrollNode = latestList?.getScrollableNode(); - if (latestScrollNode === undefined) { - return; - } - let requestedSegment = false; - const latestScrollTop = latestScrollNode.scrollTop; - const latestScrollLength = latestScrollNode.clientHeight; - if ( - historyOutline !== null && - onSelectHistoryMessage !== undefined && - messageHistory !== undefined && - historyOutline.landmarks.length > 0 - ) { - const clampedMessageIndex = Math.max( - 0, - Math.min( - messageHistory.totalMessages - 1, - (latestScrollTop + latestScrollLength / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE, - ), - ); - if ( - clampedMessageIndex < messageHistory.startIndex || - clampedMessageIndex >= messageHistory.endIndex - ) { - let target = historyOutline.landmarks[0]!; - let targetMessageIndex = - target.messageIndex ?? - (target.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * - Math.max(0, messageHistory.totalMessages - 1); - for (const landmark of historyOutline.landmarks) { - const landmarkMessageIndex = - landmark.messageIndex ?? - (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * - Math.max(0, messageHistory.totalMessages - 1); - if ( - Math.abs(landmarkMessageIndex - clampedMessageIndex) < - Math.abs(targetMessageIndex - clampedMessageIndex) - ) { - target = landmark; - targetMessageIndex = landmarkMessageIndex; - } - } - historyPointerReleasePendingRef.current = !historyPointerActiveRef.current; - requestedHistoryMessageIdRef.current = target.messageId; - requestedHistoryMessageIndexRef.current = Math.round(targetMessageIndex); - onManualNavigation(); - onSelectHistoryMessage(target.messageId); - requestedSegment = true; - } - } - if (!requestedSegment && !historyPointerActiveRef.current) { - historyNavigationEnabledRef.current = false; - if (historyPointerReleasePendingRef.current) { - historyPointerReleasePendingRef.current = false; - } - } - }, throttleDelay); - } - } - - for (const item of minimapItems) { - const strip = minimapStripMap.get(item.id); - if (!strip || item.rowIndex === null) { - continue; - } - - const rowTopWithinWindow = resolveTimelineRowTop(state, item.rowIndex); - const rowTop = rowTopWithinWindow === null ? null : historyBeforeSize + rowTopWithinWindow; - 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"; - } - }, [ - historyOutline, - historyAfterSize, - historyBeforeSize, - historyTargetMessageId, - isLoadingNextMessages, - isLoadingPreviousMessages, - listRef, - messageHistory, - minimapItems, - minimapStripMap, - onIsAtEndChange, - onManualNavigation, - onSelectHistoryMessage, - ]); - - const finishHistoryPointerNavigation = useCallback(() => { - if (!historyPointerActiveRef.current) { - return; - } - historyPointerActiveRef.current = false; - historyPointerReleasePendingRef.current = true; - if (historyNavigationEnabledRef.current) { - historyNavigationPendingRef.current = true; - handleScroll(); - } else if ( - !isLoadingPreviousMessages && - !isLoadingNextMessages && - historyTargetMessageId === null && - requestedHistoryMessageIdRef.current === null - ) { - historyPointerReleasePendingRef.current = false; - } - }, [handleScroll, historyTargetMessageId, isLoadingNextMessages, isLoadingPreviousMessages]); - - useEffect(() => { - window.addEventListener("pointerup", finishHistoryPointerNavigation); - window.addEventListener("pointercancel", finishHistoryPointerNavigation); - return () => { - window.removeEventListener("pointerup", finishHistoryPointerNavigation); - window.removeEventListener("pointercancel", finishHistoryPointerNavigation); - }; - }, [finishHistoryPointerNavigation]); - - useLayoutEffect(() => { - if (messageHistory === undefined) { - return; - } - const beforeSpacer = historyBeforeSpacerRef.current; - const afterSpacer = historyAfterSpacerRef.current; - const beforeSpacerContainer = beforeSpacer?.parentElement; - const loadedRowsContainer = beforeSpacerContainer?.nextElementSibling; - const afterSpacerContainer = afterSpacer?.parentElement; - if ( - beforeSpacer === null || - beforeSpacerContainer === null || - beforeSpacerContainer === undefined || - loadedRowsContainer === null || - loadedRowsContainer === undefined || - afterSpacer === null || - afterSpacerContainer === null || - afterSpacerContainer === undefined - ) { - return; - } - - const stabilizeScrollExtent = () => { - let delta = - messageHistory.totalMessages * TIMELINE_VIRTUAL_MESSAGE_SIZE - - beforeSpacer.offsetHeight - - loadedRowsContainer.getBoundingClientRect().height - - afterSpacer.offsetHeight; - if (Math.abs(delta) <= 1) { - return; - } - let nextBeforeSize = beforeSpacer.offsetHeight; - let nextAfterSize = afterSpacer.offsetHeight; - if (delta > 0) { - nextAfterSize += delta; - } else { - const afterReduction = Math.min(nextAfterSize, -delta); - nextAfterSize -= afterReduction; - delta += afterReduction; - if (delta < 0) { - nextBeforeSize = Math.max(0, nextBeforeSize + delta); - } - } - setHistorySpacerOverride((current) => { - if ( - current?.startIndex === messageHistory.startIndex && - current.endIndex === messageHistory.endIndex && - Math.abs(current.beforeSize - nextBeforeSize) <= 1 && - Math.abs(current.afterSize - nextAfterSize) <= 1 - ) { - return current; - } - return { - startIndex: messageHistory.startIndex, - endIndex: messageHistory.endIndex, - beforeSize: nextBeforeSize, - afterSize: nextAfterSize, - }; - }); - }; - - stabilizeScrollExtent(); - const observer = new ResizeObserver(stabilizeScrollExtent); - observer.observe(beforeSpacerContainer); - observer.observe(loadedRowsContainer); - return () => { - observer.disconnect(); - }; - }, [messageHistory, rows.length]); - - useEffect(() => { - if (historyTargetMessageId !== null || isLoadingPreviousMessages || isLoadingNextMessages) { - if (historyNavigationTimeoutRef.current !== null) { - window.clearTimeout(historyNavigationTimeoutRef.current); - historyNavigationTimeoutRef.current = null; - } - return; - } - const settledRequestMessageId = requestedHistoryMessageIdRef.current; - const frame = requestAnimationFrame(() => { - if (requestedHistoryMessageIdRef.current !== settledRequestMessageId) { - return; - } - const requestFailed = settledRequestMessageId !== null; - requestedHistoryMessageIdRef.current = null; - requestedHistoryMessageIndexRef.current = null; - if (requestFailed) { - historyNavigationEnabledRef.current = false; - historyNavigationPendingRef.current = false; - historyPointerReleasePendingRef.current = false; - if (!historyPointerActiveRef.current) { - historyNavigationEnabledRef.current = false; - } - } else if (historyNavigationPendingRef.current && historyNavigationEnabledRef.current) { - handleScroll(); - } else if (historyPointerReleasePendingRef.current && !historyPointerActiveRef.current) { - historyPointerReleasePendingRef.current = false; - historyNavigationEnabledRef.current = false; - } - }); - return () => cancelAnimationFrame(frame); - }, [handleScroll, historyTargetMessageId, isLoadingNextMessages, isLoadingPreviousMessages]); - - useEffect( - () => () => { - if (historyNavigationTimeoutRef.current !== null) { - window.clearTimeout(historyNavigationTimeoutRef.current); - } - }, - [], - ); - - useEffect(() => { - const frame = requestAnimationFrame(handleScroll); - return () => { - cancelAnimationFrame(frame); - }; - }, [handleScroll, historyAfterSize, historyBeforeSize, rows.length]); - - useLayoutEffect(() => { - if (messageHistory === undefined) { - return; - } - const list = listRef.current; - const scrollNode = list?.getScrollableNode(); - if (list === null || list === undefined || scrollNode === undefined) { - return; - } - - list.clearCaches(); - const timeout = window.setTimeout(() => { - const loadedRowsContainer = historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; - const viewportMessageIndex = - (scrollNode.scrollTop + scrollNode.clientHeight / 2) / TIMELINE_VIRTUAL_MESSAGE_SIZE; - if ( - loadedRowsContainer === null || - loadedRowsContainer === undefined || - viewportMessageIndex < messageHistory.startIndex || - viewportMessageIndex >= messageHistory.endIndex - ) { - return; - } - const viewportRect = scrollNode.getBoundingClientRect(); - const viewportContainsRenderedRow = Array.from(loadedRowsContainer.children).some((row) => { - const rowRect = row.getBoundingClientRect(); - return rowRect.bottom > viewportRect.top && rowRect.top < viewportRect.bottom; - }); - if (viewportContainsRenderedRow) { - return; - } - const scrollTop = scrollNode.scrollTop; - const nudgeScrollTop = scrollTop > 1 ? scrollTop - 2 : scrollTop + 2; - void list.scrollToOffset({ offset: nudgeScrollTop, animated: false }); - void list.scrollToOffset({ offset: scrollTop, animated: false }); - }, TIMELINE_HISTORY_SCROLL_THROTTLE_MS); - return () => { - window.clearTimeout(timeout); - }; - }, [historyBeforeSize, listRef, messageHistory?.endIndex, messageHistory?.startIndex]); - - useEffect(() => { - if (historyTargetMessageId === null) { - return; - } - const rowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.id === historyTargetMessageId, - ); - if (rowIndex < 0) { - return; - } - requestedHistoryMessageIdRef.current = null; - if (historyNavigationEnabledRef.current) { - let cancelled = false; - let frame = 0; - let attempts = 0; - const placeTarget = () => { - frame = requestAnimationFrame(() => { - if (cancelled) { - return; - } - const state = listRef.current?.getState?.(); - const scrollNode = listRef.current?.getScrollableNode(); - const beforeSpacer = historyBeforeSpacerRef.current; - const afterSpacer = historyAfterSpacerRef.current; - const rowTop = state === undefined ? null : resolveTimelineRowTop(state, rowIndex); - if ( - state === undefined || - scrollNode === undefined || - beforeSpacer === null || - afterSpacer === null || - rowTop === null - ) { - attempts += 1; - if (attempts < 90) { - placeTarget(); - } else { - requestedHistoryMessageIndexRef.current = null; - onHistoryTargetReady?.(); - } - return; - } - const delta = Math.max( - -beforeSpacer.offsetHeight, - Math.min( - afterSpacer.offsetHeight, - scrollNode.scrollTop + 24 - beforeSpacer.offsetHeight - rowTop, - ), - ); - if (Math.abs(delta) > 1) { - if (messageHistory !== undefined) { - setHistorySpacerOverride({ - startIndex: messageHistory.startIndex, - endIndex: messageHistory.endIndex, - beforeSize: beforeSpacer.offsetHeight + delta, - afterSize: afterSpacer.offsetHeight - delta, - }); - attempts += 1; - if (attempts < 90) { - placeTarget(); - } else { - requestedHistoryMessageIndexRef.current = null; - onHistoryTargetReady?.(); - } - return; - } - } - requestedHistoryMessageIndexRef.current = null; - onHistoryTargetReady?.(); - }); - }; - placeTarget(); - return () => { - cancelled = true; - cancelAnimationFrame(frame); - }; - } - let cancelled = false; - let frame = 0; - let alignmentTimeout: number | null = null; - let alignmentScrollNode: HTMLElement | null = null; - let alignmentScrollEnd: (() => void) | null = null; - let mountAttempts = 0; - let alignmentAttempts = 0; - let approximateScrollStarted = false; - let completed = false; - const clearAlignmentWait = () => { - if (alignmentTimeout !== null) { - window.clearTimeout(alignmentTimeout); - alignmentTimeout = null; - } - if (alignmentScrollNode !== null && alignmentScrollEnd !== null) { - alignmentScrollNode.removeEventListener("scrollend", alignmentScrollEnd); - } - alignmentScrollNode = null; - alignmentScrollEnd = null; - }; - const completeTarget = () => { - if (completed) { - return; - } - completed = true; - clearAlignmentWait(); - requestedHistoryMessageIndexRef.current = null; - onHistoryTargetReady?.(); - }; - const positionTarget = () => { - frame = requestAnimationFrame(() => { - if (cancelled) { - return; - } - if (historyPointerActiveRef.current) { - completeTarget(); - return; - } - const list = listRef.current; - if (list === null || timelineViewportElement === null) { - return; - } - const scrollNode = list.getScrollableNode(); - const target = timelineViewportElement.querySelector( - `[data-message-id="${CSS.escape(historyTargetMessageId)}"]`, - ); - if (target === null) { - mountAttempts += 1; - if (!approximateScrollStarted && mountAttempts >= 2) { - approximateScrollStarted = true; - const messageIndex = requestedHistoryMessageIndexRef.current; - if (messageIndex === null) { - void list.scrollToIndex({ - index: rowIndex, - animated: true, - viewOffset: 24, - }); - } else { - scrollNode.scrollTo({ - behavior: "smooth", - top: Math.max( - 0, - Math.min( - scrollNode.scrollHeight - scrollNode.clientHeight, - messageIndex * TIMELINE_VIRTUAL_MESSAGE_SIZE - 24, - ), - ), - }); - } - } - if (mountAttempts < 90) { - positionTarget(); - } else { - completeTarget(); - } - return; - } - const targetScrollTop = - scrollNode.scrollTop + - target.getBoundingClientRect().top - - scrollNode.getBoundingClientRect().top - - 24; - if (Math.abs(targetScrollTop - scrollNode.scrollTop) <= 2 || alignmentAttempts >= 2) { - completeTarget(); - return; - } - alignmentAttempts += 1; - clearAlignmentWait(); - const finishAlignment = () => { - clearAlignmentWait(); - positionTarget(); - }; - alignmentScrollNode = scrollNode; - alignmentScrollEnd = finishAlignment; - scrollNode.addEventListener("scrollend", finishAlignment, { once: true }); - alignmentTimeout = window.setTimeout(finishAlignment, 500); - scrollNode.scrollTo({ - behavior: "smooth", - top: targetScrollTop, - }); - }); - }; - positionTarget(); - return () => { - cancelled = true; - cancelAnimationFrame(frame); - clearAlignmentWait(); - }; - }, [ - historyTargetMessageId, - listRef, - messageHistory, - onHistoryTargetReady, - rows, - timelineViewportElement, - ]); - useEffect(() => { if (!timelineViewportElement) { return; @@ -1106,10 +490,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
ref={listRef} @@ -1119,7 +503,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ renderItem={renderItem} estimatedItemSize={90} estimatedHeaderSize={ - historyBeforeSize > 0 ? historyBeforeSize : topFadeEnabled ? 48 : 16 + progressiveHistory.historyBeforeSize > 0 + ? progressiveHistory.historyBeforeSize + : topFadeEnabled + ? 48 + : 16 } initialScrollAtEnd {...(anchoredEndSpace ? { anchoredEndSpace } : {})} @@ -1144,14 +532,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } : false } - onScroll={handleScroll} + onScroll={progressiveHistory.handleScroll} {...(messageHistory === undefined ? {} : { contentContainerStyle: { - height: - messageHistory.totalMessages * TIMELINE_VIRTUAL_MESSAGE_SIZE + - contentInsetEndAdjustment, + height: progressiveHistory.contentHeight + contentInsetEndAdjustment, overflow: "hidden", }, })} @@ -1162,12 +548,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListHeaderComponent={ messageHistory !== undefined ? (
- {virtualHistoryBeforeSize > 0 ? ( - + {progressiveHistory.virtualHistoryBeforeSize > 0 ? ( + ) : null}
) : isLoadingPreviousMessages ? ( @@ -1181,12 +569,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ListFooterComponent={ messageHistory !== undefined ? (
- {virtualHistoryAfterSize > 0 ? ( - + {progressiveHistory.virtualHistoryAfterSize > 0 ? ( + ) : null}
) : isLoadingNextMessages ? ( @@ -1204,38 +594,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} - onSelect={(item) => { - historyNavigationEnabledRef.current = false; - historyNavigationPendingRef.current = false; - if (historyNavigationTimeoutRef.current !== null) { - window.clearTimeout(historyNavigationTimeoutRef.current); - historyNavigationTimeoutRef.current = null; - } - onManualNavigation(); - if (item.rowIndex === null) { - requestedHistoryMessageIndexRef.current = item.messageIndex; - const scrollNode = listRef.current?.getScrollableNode(); - if (item.messageIndex !== null && scrollNode !== undefined) { - scrollNode.scrollTo({ - behavior: "smooth", - top: Math.max( - 0, - Math.min( - scrollNode.scrollHeight - scrollNode.clientHeight, - item.messageIndex * TIMELINE_VIRTUAL_MESSAGE_SIZE - 24, - ), - ), - }); - } - onSelectHistoryMessage?.(item.id); - } else { - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - } - }} + onSelect={progressiveHistory.selectHistoryTarget} />
@@ -1278,22 +637,11 @@ function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } -interface TimelineMinimapItem { - readonly id: MessageId; - readonly messageIndex: number | null; - readonly rowIndex: number | null; +interface TimelineMinimapItem extends TimelineHistoryNavigationTarget { readonly userText: string | null; readonly assistantText: string | null; } -interface TimelinePositionState { - readonly contentLength?: number; - readonly scroll?: number; - readonly scrollLength?: number; - readonly positionAtIndex?: (index: number) => number | undefined; - readonly sizeAtIndex?: (index: number) => number | undefined; -} - function deriveTimelineMinimapItems( rows: ReadonlyArray, outline: OrchestrationThreadHistoryOutline | null, @@ -1363,16 +711,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; } diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts new file mode 100644 index 00000000000..c020f3ddde7 --- /dev/null +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -0,0 +1,903 @@ +import { + type MessageId, + type OrchestrationThreadHistoryOutline, + type OrchestrationThreadMessageHistory, +} from "@t3tools/contracts"; +import { type LegendListRef } from "@legendapp/list/react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type PointerEvent, + type RefObject, +} from "react"; +import { resolveTimelineIsAtEnd, type MessagesTimelineRow } from "./MessagesTimeline.logic"; + +const VIRTUAL_MESSAGE_SIZE = 120; +const HISTORY_LOAD_THROTTLE_MS = 180; +const HISTORY_SKELETON_PERIOD = VIRTUAL_MESSAGE_SIZE * 2; +const MESSAGE_VIEWPORT_OFFSET = 24; + +type ViewportAnchor = + | { + readonly kind: "logical"; + readonly messageIndex: number; + readonly viewportOffset: number; + } + | { + readonly kind: "message"; + readonly messageId: MessageId; + readonly viewportOffset: number; + }; + +type PendingHistoryRequest = + | { + readonly kind: "logical"; + readonly anchorMessageIndex: number; + readonly messageId: MessageId; + } + | { + readonly kind: "message"; + readonly messageId: MessageId; + }; + +interface HistoryLayoutMeasurement { + readonly windowKey: string; + readonly loadedSize: number; + readonly beforeSize: number; +} + +interface ResolvedLogicalAnchor { + readonly anchorMessageIndex: number; + readonly messageId: MessageId; + readonly windowKey: string; +} + +export interface TimelineHistoryNavigationTarget { + readonly id: MessageId; + readonly messageIndex: number | null; + readonly rowIndex: number | null; +} + +interface UseProgressiveTimelineHistoryInput { + readonly historyOutline: OrchestrationThreadHistoryOutline | null; + readonly historyTargetMessageId: MessageId | null; + readonly isLoadingNextMessages: boolean; + readonly isLoadingPreviousMessages: boolean; + readonly listRef: RefObject; + readonly messageHistory: OrchestrationThreadMessageHistory | undefined; + readonly minimapItems: ReadonlyArray; + readonly minimapStripMap: Map; + readonly onHistoryTargetReady: (() => void) | undefined; + readonly onIsAtEndChange: (isAtEnd: boolean) => void; + readonly onManualNavigation: () => void; + readonly onSelectHistoryMessage: ((messageId: MessageId) => void) | undefined; + readonly rows: ReadonlyArray; + readonly timelineViewportElement: HTMLDivElement | null; +} + +export function useProgressiveTimelineHistory({ + historyOutline, + historyTargetMessageId, + isLoadingNextMessages, + isLoadingPreviousMessages, + listRef, + messageHistory, + minimapItems, + minimapStripMap, + onHistoryTargetReady, + onIsAtEndChange, + onManualNavigation, + onSelectHistoryMessage, + rows, + timelineViewportElement, +}: UseProgressiveTimelineHistoryInput) { + const anchorRef = useRef(null); + const pendingRequestRef = useRef(null); + const resolvedLogicalAnchorRef = useRef(null); + const programmaticScrollRef = useRef<"instant" | "message" | "aligning" | null>(null); + const pointerActiveRef = useRef(false); + const loadTimeoutRef = useRef(null); + const lastLoadAtRef = useRef(0); + const reconcileFrameRef = useRef(null); + const reconcileAnchorRef = useRef<() => void>(() => {}); + const releaseProgrammaticScrollFrameRef = useRef(null); + const alignmentCleanupRef = useRef<(() => void) | null>(null); + const historyBeforeSpacerRef = useRef(null); + const historyAfterSpacerRef = useRef(null); + const historyBeforeSkeletonsRef = useRef(null); + const historyAfterSkeletonsRef = useRef(null); + const clearedWindowKeyRef = useRef(null); + const [layoutMeasurement, setLayoutMeasurement] = useState(null); + const loadedMessageIds = useMemo( + () => + rows.flatMap((row) => { + if (row.kind === "message") { + return [row.message.id]; + } + return []; + }), + [rows], + ); + + const historyWindowKey = + messageHistory === undefined + ? null + : `${messageHistory.startIndex}:${messageHistory.endIndex}:${messageHistory.totalMessages}`; + const estimatedLoadedSize = + messageHistory === undefined + ? 0 + : (messageHistory.endIndex - messageHistory.startIndex) * VIRTUAL_MESSAGE_SIZE; + const loadedSize = + layoutMeasurement?.windowKey === historyWindowKey + ? layoutMeasurement.loadedSize + : estimatedLoadedSize; + const totalHistorySize = (messageHistory?.totalMessages ?? 0) * VIRTUAL_MESSAGE_SIZE; + const virtualHistoryBeforeSize = (messageHistory?.startIndex ?? 0) * VIRTUAL_MESSAGE_SIZE; + const measuredHistoryBeforeSize = + layoutMeasurement?.windowKey === historyWindowKey + ? layoutMeasurement.beforeSize + : virtualHistoryBeforeSize; + const historyBeforeSize = Math.min( + measuredHistoryBeforeSize, + Math.max(0, totalHistorySize - loadedSize), + ); + const historyAfterSize = Math.max(0, totalHistorySize - historyBeforeSize - loadedSize); + const virtualHistoryAfterSize = + Math.max(0, (messageHistory?.totalMessages ?? 0) - (messageHistory?.endIndex ?? 0)) * + VIRTUAL_MESSAGE_SIZE; + const historyRequestInProgress = + isLoadingPreviousMessages || isLoadingNextMessages || historyTargetMessageId !== null; + const historyLoadInProgress = isLoadingPreviousMessages || isLoadingNextMessages; + + const clearAlignmentWait = useCallback(() => { + alignmentCleanupRef.current?.(); + alignmentCleanupRef.current = null; + }, []); + + const captureLogicalAnchor = useCallback(() => { + if (messageHistory === undefined) { + return; + } + const scrollNode = listRef.current?.getScrollableNode(); + if (scrollNode === undefined) { + return; + } + const viewportOffset = scrollNode.clientHeight / 2; + resolvedLogicalAnchorRef.current = null; + anchorRef.current = { + kind: "logical", + messageIndex: Math.max( + 0, + Math.min( + messageHistory.totalMessages - 1, + (scrollNode.scrollTop + viewportOffset) / VIRTUAL_MESSAGE_SIZE, + ), + ), + viewportOffset, + }; + }, [listRef, messageHistory]); + + const releaseInstantScroll = useCallback(() => { + if (releaseProgrammaticScrollFrameRef.current !== null) { + cancelAnimationFrame(releaseProgrammaticScrollFrameRef.current); + } + releaseProgrammaticScrollFrameRef.current = requestAnimationFrame(() => { + releaseProgrammaticScrollFrameRef.current = null; + if (programmaticScrollRef.current === "instant") { + programmaticScrollRef.current = null; + } + }); + }, []); + + const queueReconciliation = useCallback(() => { + if (reconcileFrameRef.current !== null) { + return; + } + reconcileFrameRef.current = requestAnimationFrame(() => { + reconcileFrameRef.current = null; + reconcileAnchorRef.current(); + }); + }, []); + + const reconcileAnchor = useCallback(() => { + if (messageHistory === undefined || timelineViewportElement === null) { + return false; + } + const list = listRef.current; + const scrollNode = list?.getScrollableNode(); + const anchor = anchorRef.current; + if (list === null || list === undefined || scrollNode === undefined || anchor === null) { + return false; + } + + if (anchor.kind === "logical") { + const pendingRequest = pendingRequestRef.current; + if ( + pendingRequest?.kind === "logical" && + Math.abs(pendingRequest.anchorMessageIndex - anchor.messageIndex) > 0.5 + ) { + return false; + } + const resolvedAnchor = resolvedLogicalAnchorRef.current; + const alignmentMessageId = + pendingRequest?.kind === "logical" + ? pendingRequest.messageId + : resolvedAnchor?.windowKey === historyWindowKey && + Math.abs(resolvedAnchor.anchorMessageIndex - anchor.messageIndex) <= 0.01 + ? resolvedAnchor.messageId + : null; + if (alignmentMessageId !== null && historyWindowKey !== null) { + const target = timelineViewportElement.querySelector( + `[data-message-id="${CSS.escape(alignmentMessageId)}"]`, + ); + const loadedRowsContainer = + historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; + if (target === null || loadedRowsContainer === null || loadedRowsContainer === undefined) { + return false; + } + const nextBeforeSize = Math.max( + 0, + Math.min( + totalHistorySize - loadedSize, + scrollNode.scrollTop + + anchor.viewportOffset - + (target.getBoundingClientRect().top - + loadedRowsContainer.getBoundingClientRect().top), + ), + ); + if (Math.abs(historyBeforeSize - nextBeforeSize) > 1) { + setLayoutMeasurement({ + windowKey: historyWindowKey, + loadedSize, + beforeSize: nextBeforeSize, + }); + return false; + } + } + + const scrollTop = Math.max( + 0, + Math.min( + scrollNode.scrollHeight - scrollNode.clientHeight, + anchor.messageIndex * VIRTUAL_MESSAGE_SIZE - anchor.viewportOffset, + ), + ); + if (Math.abs(scrollNode.scrollTop - scrollTop) > 1) { + programmaticScrollRef.current = "instant"; + void list.scrollToOffset({ offset: scrollTop, animated: false }); + releaseInstantScroll(); + queueReconciliation(); + return false; + } + return true; + } + + const target = timelineViewportElement.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + if (target === null) { + return false; + } + + const scrollTop = + scrollNode.scrollTop + + target.getBoundingClientRect().top - + scrollNode.getBoundingClientRect().top - + anchor.viewportOffset; + if (Math.abs(scrollNode.scrollTop - scrollTop) <= 2) { + clearAlignmentWait(); + programmaticScrollRef.current = null; + pendingRequestRef.current = null; + if (historyTargetMessageId === anchor.messageId) { + onHistoryTargetReady?.(); + } + return true; + } + if (pendingRequestRef.current?.kind !== "message") { + clearAlignmentWait(); + programmaticScrollRef.current = "instant"; + void list.scrollToOffset({ offset: scrollTop, animated: false }); + releaseInstantScroll(); + queueReconciliation(); + return false; + } + if (programmaticScrollRef.current === "aligning") { + return false; + } + + clearAlignmentWait(); + programmaticScrollRef.current = "aligning"; + const finishAlignment = () => { + clearAlignmentWait(); + if (programmaticScrollRef.current === "aligning") { + programmaticScrollRef.current = "message"; + } + queueReconciliation(); + }; + scrollNode.addEventListener("scrollend", finishAlignment, { once: true }); + const timeout = window.setTimeout(finishAlignment, 500); + alignmentCleanupRef.current = () => { + window.clearTimeout(timeout); + scrollNode.removeEventListener("scrollend", finishAlignment); + }; + scrollNode.scrollTo({ behavior: "smooth", top: scrollTop }); + return false; + }, [ + clearAlignmentWait, + historyBeforeSize, + historyTargetMessageId, + historyWindowKey, + listRef, + loadedSize, + messageHistory, + onHistoryTargetReady, + queueReconciliation, + releaseInstantScroll, + timelineViewportElement, + totalHistorySize, + ]); + reconcileAnchorRef.current = reconcileAnchor; + + const requestHistoryForAnchor = useCallback(() => { + const anchor = anchorRef.current; + if ( + anchor?.kind !== "logical" || + messageHistory === undefined || + historyOutline === null || + onSelectHistoryMessage === undefined || + historyOutline.landmarks.length === 0 || + (resolvedLogicalAnchorRef.current?.windowKey === historyWindowKey && + Math.abs(resolvedLogicalAnchorRef.current.anchorMessageIndex - anchor.messageIndex) <= 0.01) + ) { + return; + } + + let target = historyOutline.landmarks[0]!; + let targetMessageIndex = + target.messageIndex ?? + (target.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + for (const landmark of historyOutline.landmarks) { + const landmarkMessageIndex = + landmark.messageIndex ?? + (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + if ( + Math.abs(landmarkMessageIndex - anchor.messageIndex) < + Math.abs(targetMessageIndex - anchor.messageIndex) + ) { + target = landmark; + targetMessageIndex = landmarkMessageIndex; + } + } + const targetIsLoaded = minimapItems.some( + (item) => item.id === target.messageId && item.rowIndex !== null, + ); + if (targetIsLoaded) { + pendingRequestRef.current = { + kind: "logical", + anchorMessageIndex: anchor.messageIndex, + messageId: target.messageId, + }; + queueReconciliation(); + return; + } + if ( + anchor.messageIndex >= messageHistory.startIndex && + anchor.messageIndex < messageHistory.endIndex + ) { + let loadedTarget: TimelineHistoryNavigationTarget | null = null; + let loadedTargetDistance = Number.POSITIVE_INFINITY; + for (const item of minimapItems) { + if (item.rowIndex === null || item.messageIndex === null) { + continue; + } + const distance = Math.abs(item.messageIndex - anchor.messageIndex); + if (distance < loadedTargetDistance) { + loadedTarget = item; + loadedTargetDistance = distance; + } + } + if (loadedTarget !== null) { + pendingRequestRef.current = { + kind: "logical", + anchorMessageIndex: anchor.messageIndex, + messageId: loadedTarget.id, + }; + queueReconciliation(); + return; + } + const fallbackMessageId = + loadedMessageIds[ + Math.round( + ((anchor.messageIndex - messageHistory.startIndex) / + Math.max(1, messageHistory.endIndex - messageHistory.startIndex - 1)) * + Math.max(0, loadedMessageIds.length - 1), + ) + ]; + if (fallbackMessageId !== undefined) { + pendingRequestRef.current = { + kind: "logical", + anchorMessageIndex: anchor.messageIndex, + messageId: fallbackMessageId, + }; + queueReconciliation(); + } + return; + } + + const targetAlreadyRequested = + pendingRequestRef.current?.messageId === target.messageId && + historyTargetMessageId === target.messageId; + pendingRequestRef.current = { + kind: "logical", + anchorMessageIndex: anchor.messageIndex, + messageId: target.messageId, + }; + if (targetAlreadyRequested) { + return; + } + lastLoadAtRef.current = performance.now(); + onManualNavigation(); + onSelectHistoryMessage(target.messageId); + }, [ + historyOutline, + historyTargetMessageId, + historyWindowKey, + loadedMessageIds, + messageHistory, + minimapItems, + onManualNavigation, + onSelectHistoryMessage, + queueReconciliation, + ]); + + const scheduleHistoryLoad = useCallback(() => { + if (loadTimeoutRef.current !== null) { + return; + } + const delay = Math.max(0, lastLoadAtRef.current + HISTORY_LOAD_THROTTLE_MS - performance.now()); + loadTimeoutRef.current = window.setTimeout(() => { + loadTimeoutRef.current = null; + requestHistoryForAnchor(); + }, delay); + }, [requestHistoryForAnchor]); + + const handleScroll = useCallback(() => { + const state = listRef.current?.getState?.(); + const isAtEnd = resolveTimelineIsAtEnd(state); + if (isAtEnd !== undefined) { + onIsAtEndChange(isAtEnd); + } + if (state === undefined) { + return; + } + + const scrollNode = listRef.current?.getScrollableNode(); + const scrollTop = scrollNode?.scrollTop ?? state.scroll ?? 0; + const scrollLength = scrollNode?.clientHeight ?? state.scrollLength ?? 0; + const scrollBottom = scrollTop + scrollLength; + const renderedHistoryBeforeSize = + historyBeforeSpacerRef.current?.offsetHeight ?? historyBeforeSize; + const renderedHistoryAfterSize = + historyAfterSpacerRef.current?.offsetHeight ?? historyAfterSize; + const loadedHistoryEnd = + (scrollNode?.scrollHeight ?? state.contentLength ?? 0) - renderedHistoryAfterSize; + const historyBeforeSkeletons = historyBeforeSkeletonsRef.current; + if (historyBeforeSkeletons !== null) { + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollTop / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - + HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryBeforeSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, + ) * HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyBeforeSkeletons.style.transform !== transform) { + historyBeforeSkeletons.style.transform = transform; + } + } + const historyAfterSkeletons = historyAfterSkeletonsRef.current; + if (historyAfterSkeletons !== null) { + const scrollWithinHistory = Math.max(0, scrollTop - loadedHistoryEnd); + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollWithinHistory / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - + HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryAfterSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, + ) * HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyAfterSkeletons.style.transform !== transform) { + historyAfterSkeletons.style.transform = transform; + } + } + + if (messageHistory !== undefined && programmaticScrollRef.current === null) { + captureLogicalAnchor(); + scheduleHistoryLoad(); + } else if ( + programmaticScrollRef.current === "message" || + programmaticScrollRef.current === "aligning" + ) { + queueReconciliation(); + } + + for (const item of minimapItems) { + const strip = minimapStripMap.get(item.id); + if (strip === undefined || item.rowIndex === null) { + continue; + } + const rowTopWithinWindow = state.positionAtIndex?.(item.rowIndex); + const rowHeight = state.sizeAtIndex?.(item.rowIndex); + const rowTop = + rowTopWithinWindow === undefined || !Number.isFinite(rowTopWithinWindow) + ? null + : historyBeforeSize + rowTopWithinWindow; + const resolvedRowHeight = + rowHeight === undefined || !Number.isFinite(rowHeight) ? null : rowHeight; + const inView = + rowTop !== null && + rowTop < scrollBottom && + rowTop + Math.max(1, resolvedRowHeight ?? 1) > scrollTop; + strip.dataset.inView = inView ? "true" : "false"; + } + }, [ + captureLogicalAnchor, + historyAfterSize, + historyBeforeSize, + listRef, + messageHistory, + minimapItems, + minimapStripMap, + onIsAtEndChange, + queueReconciliation, + scheduleHistoryLoad, + ]); + + const beginUserNavigation = useCallback(() => { + const anchor = anchorRef.current; + clearAlignmentWait(); + programmaticScrollRef.current = null; + if (anchor?.kind === "message" && historyTargetMessageId === anchor.messageId) { + onHistoryTargetReady?.(); + } + captureLogicalAnchor(); + onManualNavigation(); + }, [ + captureLogicalAnchor, + clearAlignmentWait, + historyTargetMessageId, + onHistoryTargetReady, + onManualNavigation, + ]); + + const beginScrollbarPointerNavigation = useCallback( + (event: PointerEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if (event.target !== scrollNode) { + return; + } + pointerActiveRef.current = true; + beginUserNavigation(); + }, + [beginUserNavigation, listRef], + ); + + const finishScrollbarPointerNavigation = useCallback(() => { + if (!pointerActiveRef.current) { + return; + } + pointerActiveRef.current = false; + captureLogicalAnchor(); + scheduleHistoryLoad(); + }, [captureLogicalAnchor, scheduleHistoryLoad]); + + const selectHistoryTarget = useCallback( + (item: TimelineHistoryNavigationTarget) => { + onManualNavigation(); + if (messageHistory === undefined) { + if (item.rowIndex !== null) { + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: MESSAGE_VIEWPORT_OFFSET, + }); + } + return; + } + + clearAlignmentWait(); + anchorRef.current = { + kind: "message", + messageId: item.id, + viewportOffset: MESSAGE_VIEWPORT_OFFSET, + }; + pendingRequestRef.current = { + kind: "message", + messageId: item.id, + }; + programmaticScrollRef.current = "message"; + + const list = listRef.current; + const scrollNode = list?.getScrollableNode(); + if (list === null || list === undefined || scrollNode === undefined) { + return; + } + if (item.rowIndex !== null) { + void list.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: MESSAGE_VIEWPORT_OFFSET, + }); + queueReconciliation(); + return; + } + if (item.messageIndex !== null) { + scrollNode.scrollTo({ + behavior: "smooth", + top: Math.max( + 0, + Math.min( + scrollNode.scrollHeight - scrollNode.clientHeight, + item.messageIndex * VIRTUAL_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, + ), + ), + }); + } + onSelectHistoryMessage?.(item.id); + }, + [ + clearAlignmentWait, + listRef, + messageHistory, + onManualNavigation, + onSelectHistoryMessage, + queueReconciliation, + ], + ); + + useLayoutEffect(() => { + if (messageHistory === undefined || historyWindowKey === null) { + return; + } + const beforeSpacer = historyBeforeSpacerRef.current; + const loadedRowsContainer = beforeSpacer?.parentElement?.nextElementSibling; + if (loadedRowsContainer === null || loadedRowsContainer === undefined) { + return; + } + + const measure = () => { + const loadedSize = loadedRowsContainer.getBoundingClientRect().height; + setLayoutMeasurement((current) => + current?.windowKey === historyWindowKey && Math.abs(current.loadedSize - loadedSize) <= 1 + ? current + : { + windowKey: historyWindowKey, + loadedSize, + beforeSize: + current?.windowKey === historyWindowKey + ? Math.min(current.beforeSize, Math.max(0, totalHistorySize - loadedSize)) + : Math.min(virtualHistoryBeforeSize, Math.max(0, totalHistorySize - loadedSize)), + }, + ); + queueReconciliation(); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(loadedRowsContainer); + return () => { + observer.disconnect(); + }; + }, [ + historyWindowKey, + messageHistory, + queueReconciliation, + rows.length, + totalHistorySize, + virtualHistoryBeforeSize, + ]); + + useLayoutEffect(() => { + if (messageHistory === undefined || historyWindowKey === null) { + return; + } + const list = listRef.current; + const scrollNode = list?.getScrollableNode(); + if (list === null || list === undefined || scrollNode === undefined) { + return; + } + if (anchorRef.current === null) { + captureLogicalAnchor(); + } + scheduleHistoryLoad(); + if (clearedWindowKeyRef.current !== historyWindowKey) { + clearedWindowKeyRef.current = historyWindowKey; + list.clearCaches(); + } + queueReconciliation(); + + const frame = requestAnimationFrame(() => { + const anchor = anchorRef.current; + if ( + anchor?.kind !== "logical" || + anchor.messageIndex < messageHistory.startIndex || + anchor.messageIndex >= messageHistory.endIndex + ) { + return; + } + const loadedRowsContainer = historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; + if (loadedRowsContainer === null || loadedRowsContainer === undefined) { + return; + } + const viewportRect = scrollNode.getBoundingClientRect(); + const viewportContainsRenderedRow = Array.from(loadedRowsContainer.children).some((row) => { + const rowRect = row.getBoundingClientRect(); + return rowRect.bottom > viewportRect.top && rowRect.top < viewportRect.bottom; + }); + if (viewportContainsRenderedRow) { + return; + } + const scrollTop = scrollNode.scrollTop; + programmaticScrollRef.current = "instant"; + void list.scrollToOffset({ + offset: scrollTop > 1 ? scrollTop - 2 : scrollTop + 2, + animated: false, + }); + void list.scrollToOffset({ offset: scrollTop, animated: false }); + releaseInstantScroll(); + }); + return () => { + cancelAnimationFrame(frame); + }; + }, [ + captureLogicalAnchor, + historyAfterSize, + historyBeforeSize, + historyWindowKey, + listRef, + messageHistory, + queueReconciliation, + releaseInstantScroll, + rows.length, + scheduleHistoryLoad, + ]); + + useEffect(() => { + if ( + messageHistory === undefined || + historyWindowKey === null || + historyLoadInProgress || + pendingRequestRef.current?.kind !== "logical" + ) { + return; + } + const anchor = anchorRef.current; + const pendingRequest = pendingRequestRef.current; + if (anchor?.kind !== "logical" || pendingRequest?.kind !== "logical") { + return; + } + if (Math.abs(pendingRequest.anchorMessageIndex - anchor.messageIndex) > 0.5) { + pendingRequestRef.current = null; + onHistoryTargetReady?.(); + return; + } + const frame = requestAnimationFrame(() => { + if (!reconcileAnchor()) { + return; + } + resolvedLogicalAnchorRef.current = { + anchorMessageIndex: anchor.messageIndex, + messageId: pendingRequest.messageId, + windowKey: historyWindowKey, + }; + pendingRequestRef.current = null; + onHistoryTargetReady?.(); + scheduleHistoryLoad(); + }); + return () => cancelAnimationFrame(frame); + }, [ + historyLoadInProgress, + historyWindowKey, + messageHistory, + onHistoryTargetReady, + reconcileAnchor, + scheduleHistoryLoad, + ]); + + useEffect(() => { + if (!historyRequestInProgress) { + scheduleHistoryLoad(); + } + }, [historyRequestInProgress, scheduleHistoryLoad]); + + useEffect(() => { + if (anchorRef.current?.kind === "message" && pendingRequestRef.current?.kind === "message") { + queueReconciliation(); + } + }, [historyRequestInProgress, historyWindowKey, queueReconciliation, rows]); + + useEffect(() => { + if (timelineViewportElement === null) { + return; + } + const observer = new ResizeObserver(queueReconciliation); + observer.observe(timelineViewportElement); + return () => { + observer.disconnect(); + }; + }, [queueReconciliation, timelineViewportElement]); + + useEffect(() => { + window.addEventListener("pointerup", finishScrollbarPointerNavigation); + window.addEventListener("pointercancel", finishScrollbarPointerNavigation); + return () => { + window.removeEventListener("pointerup", finishScrollbarPointerNavigation); + window.removeEventListener("pointercancel", finishScrollbarPointerNavigation); + }; + }, [finishScrollbarPointerNavigation]); + + useEffect( + () => () => { + clearAlignmentWait(); + if (loadTimeoutRef.current !== null) { + window.clearTimeout(loadTimeoutRef.current); + loadTimeoutRef.current = null; + } + if (reconcileFrameRef.current !== null) { + cancelAnimationFrame(reconcileFrameRef.current); + reconcileFrameRef.current = null; + } + if (releaseProgrammaticScrollFrameRef.current !== null) { + cancelAnimationFrame(releaseProgrammaticScrollFrameRef.current); + releaseProgrammaticScrollFrameRef.current = null; + } + }, + [clearAlignmentWait], + ); + + return useMemo( + () => ({ + beginScrollbarPointerNavigation, + beginUserNavigation, + contentHeight: totalHistorySize, + handleScroll, + historyAfterSize, + historyAfterSkeletonsRef, + historyAfterSpacerRef, + historyBeforeSize, + historyBeforeSkeletonsRef, + historyBeforeSpacerRef, + selectHistoryTarget, + virtualHistoryAfterSize, + virtualHistoryBeforeSize, + }), + [ + beginScrollbarPointerNavigation, + beginUserNavigation, + handleScroll, + historyAfterSize, + historyBeforeSize, + selectHistoryTarget, + totalHistorySize, + virtualHistoryAfterSize, + virtualHistoryBeforeSize, + ], + ); +} diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index df93ab54e94..3f06b72a864 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -1233,7 +1233,7 @@ export function createEnvironmentThreadStateAtoms( ), scheduler, concurrency: { - mode: "singleFlight", + mode: "latest", key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), }, }), From 4f0834122df7ad8b505d6eaa42dcf1caf238ba41 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 22:13:46 +0300 Subject: [PATCH 05/31] stabilize progressive history scrolling --- FORK.md | 13 +- apps/web/src/components/ChatView.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 11 +- .../chat/useProgressiveTimelineHistory.ts | 377 ++++++++++++------ 4 files changed, 265 insertions(+), 140 deletions(-) diff --git a/FORK.md b/FORK.md index 8c7c90af551..ee4c2a4aa46 100644 --- a/FORK.md +++ b/FORK.md @@ -76,10 +76,15 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork snapshots, and failed segment requests release their pending navigation target instead of leaving the virtual loader locked. Minimap jumps begin scrolling through virtual history immediately while the segment loads, then align the loaded target with a smooth correction instead of teleporting - the viewport after the response. Scrollbar movement keeps a logical viewport anchor, while minimap - jumps keep a concrete message anchor; segment swaps and layout resizes reconcile against that - anchor without changing the fixed scroll extent. Around-window requests keep one active load and - coalesce intermediate targets to the latest requested position. + the viewport after the response. When smooth motion is unavailable, the estimated-position + fallback still moves inline skeletons into the viewport before exact alignment. Scrollbar movement + keeps a logical viewport anchor, while minimap jumps keep a concrete message anchor; segment swaps + and layout resizes reconcile against that anchor without changing the fixed scroll extent. The + programmatic seek ends with the scroll motion, not the segment request, and manual input replaces + it with a logical anchor so a slow request cannot lock the scrollbar. Segment reconciliation does + not run while the native scrollbar thumb is held, and cold minimap requests begin in a separate + task after the seek starts. Around-window requests keep one active load and coalesce intermediate + targets to the latest requested position. - Desktop context-menu style is configurable. - 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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 46eb472ef0b..4ffef6165ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1446,7 +1446,9 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, environmentThreadState.history, loadMessagesAround, routeThreadKey, threadId], ); const handleHistoryTargetReady = useCallback(() => { - setHistoryTarget((current) => ({ ...current, messageId: null })); + setHistoryTarget((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); }, []); const threadError = isServerThread ? (localServerError ?? serverThread?.session?.lastError ?? null) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0f8a344ceb2..178d4fa4ec3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -16,6 +16,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -394,7 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); - useEffect(() => { + useLayoutEffect(() => { if (!timelineViewportElement) { return; } @@ -408,13 +409,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]); @@ -509,7 +509,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ? 48 : 16 } - initialScrollAtEnd + initialScrollAtEnd={messageHistory === undefined} + {...(messageHistory === undefined + ? {} + : { initialScrollOffset: progressiveHistory.historyBeforeSize })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts index c020f3ddde7..174082f1530 100644 --- a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -98,18 +98,19 @@ export function useProgressiveTimelineHistory({ const anchorRef = useRef(null); const pendingRequestRef = useRef(null); const resolvedLogicalAnchorRef = useRef(null); - const programmaticScrollRef = useRef<"instant" | "message" | "aligning" | null>(null); + const programmaticScrollRef = useRef<"instant" | "seeking" | "aligning" | null>(null); const pointerActiveRef = useRef(false); const loadTimeoutRef = useRef(null); const lastLoadAtRef = useRef(0); - const reconcileFrameRef = useRef(null); + const reconcileTimeoutRef = useRef(null); const reconcileAnchorRef = useRef<() => void>(() => {}); - const releaseProgrammaticScrollFrameRef = useRef(null); + const releaseProgrammaticScrollTimeoutRef = useRef(null); const alignmentCleanupRef = useRef<(() => void) | null>(null); const historyBeforeSpacerRef = useRef(null); const historyAfterSpacerRef = useRef(null); const historyBeforeSkeletonsRef = useRef(null); const historyAfterSkeletonsRef = useRef(null); + const historyInitializedRef = useRef(false); const clearedWindowKeyRef = useRef(null); const [layoutMeasurement, setLayoutMeasurement] = useState(null); const loadedMessageIds = useMemo( @@ -182,31 +183,88 @@ export function useProgressiveTimelineHistory({ }, [listRef, messageHistory]); const releaseInstantScroll = useCallback(() => { - if (releaseProgrammaticScrollFrameRef.current !== null) { - cancelAnimationFrame(releaseProgrammaticScrollFrameRef.current); + if (releaseProgrammaticScrollTimeoutRef.current !== null) { + window.clearTimeout(releaseProgrammaticScrollTimeoutRef.current); } - releaseProgrammaticScrollFrameRef.current = requestAnimationFrame(() => { - releaseProgrammaticScrollFrameRef.current = null; + releaseProgrammaticScrollTimeoutRef.current = window.setTimeout(() => { + releaseProgrammaticScrollTimeoutRef.current = null; if (programmaticScrollRef.current === "instant") { programmaticScrollRef.current = null; } - }); + }, 0); }, []); const queueReconciliation = useCallback(() => { - if (reconcileFrameRef.current !== null) { + if (reconcileTimeoutRef.current !== null) { return; } - reconcileFrameRef.current = requestAnimationFrame(() => { - reconcileFrameRef.current = null; + reconcileTimeoutRef.current = window.setTimeout(() => { + reconcileTimeoutRef.current = null; reconcileAnchorRef.current(); - }); + }, 0); }, []); + const positionHistorySkeletons = useCallback( + (scrollTop: number, scrollHeight: number) => { + const renderedHistoryBeforeSize = + historyBeforeSpacerRef.current?.offsetHeight ?? historyBeforeSize; + const renderedHistoryAfterSize = + historyAfterSpacerRef.current?.offsetHeight ?? historyAfterSize; + const historyBeforeSkeletons = historyBeforeSkeletonsRef.current; + if (historyBeforeSkeletons !== null) { + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollTop / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - + HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryBeforeSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, + ) * HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyBeforeSkeletons.style.transform !== transform) { + historyBeforeSkeletons.style.transform = transform; + } + } + const historyAfterSkeletons = historyAfterSkeletonsRef.current; + if (historyAfterSkeletons !== null) { + const scrollWithinHistory = Math.max( + 0, + scrollTop - (scrollHeight - renderedHistoryAfterSize), + ); + const offset = Math.min( + Math.max( + 0, + Math.floor(scrollWithinHistory / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - + HISTORY_SKELETON_PERIOD, + ), + Math.max( + 0, + Math.floor( + (renderedHistoryAfterSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, + ) * HISTORY_SKELETON_PERIOD, + ), + ); + const transform = `translateY(${offset}px)`; + if (historyAfterSkeletons.style.transform !== transform) { + historyAfterSkeletons.style.transform = transform; + } + } + }, + [historyAfterSize, historyBeforeSize], + ); + const reconcileAnchor = useCallback(() => { if (messageHistory === undefined || timelineViewportElement === null) { return false; } + if (pointerActiveRef.current) { + return false; + } const list = listRef.current; const scrollNode = list?.getScrollableNode(); const anchor = anchorRef.current; @@ -234,9 +292,8 @@ export function useProgressiveTimelineHistory({ const target = timelineViewportElement.querySelector( `[data-message-id="${CSS.escape(alignmentMessageId)}"]`, ); - const loadedRowsContainer = - historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; - if (target === null || loadedRowsContainer === null || loadedRowsContainer === undefined) { + const beforeSpacer = historyBeforeSpacerRef.current; + if (target === null || beforeSpacer === null) { return false; } const nextBeforeSize = Math.max( @@ -245,8 +302,7 @@ export function useProgressiveTimelineHistory({ totalHistorySize - loadedSize, scrollNode.scrollTop + anchor.viewportOffset - - (target.getBoundingClientRect().top - - loadedRowsContainer.getBoundingClientRect().top), + (target.getBoundingClientRect().top - beforeSpacer.getBoundingClientRect().bottom), ), ); if (Math.abs(historyBeforeSize - nextBeforeSize) > 1) { @@ -268,6 +324,7 @@ export function useProgressiveTimelineHistory({ ); if (Math.abs(scrollNode.scrollTop - scrollTop) > 1) { programmaticScrollRef.current = "instant"; + positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); void list.scrollToOffset({ offset: scrollTop, animated: false }); releaseInstantScroll(); queueReconciliation(); @@ -282,6 +339,9 @@ export function useProgressiveTimelineHistory({ if (target === null) { return false; } + if (programmaticScrollRef.current === "seeking") { + return false; + } const scrollTop = scrollNode.scrollTop + @@ -300,6 +360,7 @@ export function useProgressiveTimelineHistory({ if (pendingRequestRef.current?.kind !== "message") { clearAlignmentWait(); programmaticScrollRef.current = "instant"; + positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); void list.scrollToOffset({ offset: scrollTop, animated: false }); releaseInstantScroll(); queueReconciliation(); @@ -313,13 +374,18 @@ export function useProgressiveTimelineHistory({ programmaticScrollRef.current = "aligning"; const finishAlignment = () => { clearAlignmentWait(); - if (programmaticScrollRef.current === "aligning") { - programmaticScrollRef.current = "message"; + if (programmaticScrollRef.current !== "aligning") { + return; + } + programmaticScrollRef.current = null; + pendingRequestRef.current = null; + if (historyTargetMessageId === anchor.messageId) { + onHistoryTargetReady?.(); } queueReconciliation(); }; scrollNode.addEventListener("scrollend", finishAlignment, { once: true }); - const timeout = window.setTimeout(finishAlignment, 500); + const timeout = window.setTimeout(finishAlignment, 750); alignmentCleanupRef.current = () => { window.clearTimeout(timeout); scrollNode.removeEventListener("scrollend", finishAlignment); @@ -335,6 +401,7 @@ export function useProgressiveTimelineHistory({ loadedSize, messageHistory, onHistoryTargetReady, + positionHistorySkeletons, queueReconciliation, releaseInstantScroll, timelineViewportElement, @@ -481,62 +548,19 @@ export function useProgressiveTimelineHistory({ const scrollTop = scrollNode?.scrollTop ?? state.scroll ?? 0; const scrollLength = scrollNode?.clientHeight ?? state.scrollLength ?? 0; const scrollBottom = scrollTop + scrollLength; - const renderedHistoryBeforeSize = - historyBeforeSpacerRef.current?.offsetHeight ?? historyBeforeSize; - const renderedHistoryAfterSize = - historyAfterSpacerRef.current?.offsetHeight ?? historyAfterSize; - const loadedHistoryEnd = - (scrollNode?.scrollHeight ?? state.contentLength ?? 0) - renderedHistoryAfterSize; - const historyBeforeSkeletons = historyBeforeSkeletonsRef.current; - if (historyBeforeSkeletons !== null) { - const offset = Math.min( - Math.max( - 0, - Math.floor(scrollTop / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - - HISTORY_SKELETON_PERIOD, - ), - Math.max( - 0, - Math.floor( - (renderedHistoryBeforeSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, - ) * HISTORY_SKELETON_PERIOD, - ), - ); - const transform = `translateY(${offset}px)`; - if (historyBeforeSkeletons.style.transform !== transform) { - historyBeforeSkeletons.style.transform = transform; - } - } - const historyAfterSkeletons = historyAfterSkeletonsRef.current; - if (historyAfterSkeletons !== null) { - const scrollWithinHistory = Math.max(0, scrollTop - loadedHistoryEnd); - const offset = Math.min( - Math.max( - 0, - Math.floor(scrollWithinHistory / HISTORY_SKELETON_PERIOD) * HISTORY_SKELETON_PERIOD - - HISTORY_SKELETON_PERIOD, - ), - Math.max( - 0, - Math.floor( - (renderedHistoryAfterSize - HISTORY_SKELETON_PERIOD) / HISTORY_SKELETON_PERIOD, - ) * HISTORY_SKELETON_PERIOD, - ), - ); - const transform = `translateY(${offset}px)`; - if (historyAfterSkeletons.style.transform !== transform) { - historyAfterSkeletons.style.transform = transform; - } - } + positionHistorySkeletons(scrollTop, scrollNode?.scrollHeight ?? state.contentLength ?? 0); - if (messageHistory !== undefined && programmaticScrollRef.current === null) { - captureLogicalAnchor(); - scheduleHistoryLoad(); - } else if ( - programmaticScrollRef.current === "message" || - programmaticScrollRef.current === "aligning" + if ( + messageHistory !== undefined && + historyInitializedRef.current && + programmaticScrollRef.current === null ) { - queueReconciliation(); + if (anchorRef.current?.kind === "message") { + queueReconciliation(); + } else { + captureLogicalAnchor(); + scheduleHistoryLoad(); + } } for (const item of minimapItems) { @@ -559,39 +583,43 @@ export function useProgressiveTimelineHistory({ strip.dataset.inView = inView ? "true" : "false"; } }, [ - captureLogicalAnchor, - historyAfterSize, historyBeforeSize, listRef, messageHistory, minimapItems, minimapStripMap, onIsAtEndChange, + positionHistorySkeletons, queueReconciliation, scheduleHistoryLoad, ]); const beginUserNavigation = useCallback(() => { - const anchor = anchorRef.current; clearAlignmentWait(); programmaticScrollRef.current = null; - if (anchor?.kind === "message" && historyTargetMessageId === anchor.messageId) { - onHistoryTargetReady?.(); - } + pendingRequestRef.current = null; + onHistoryTargetReady?.(); captureLogicalAnchor(); onManualNavigation(); - }, [ - captureLogicalAnchor, - clearAlignmentWait, - historyTargetMessageId, - onHistoryTargetReady, - onManualNavigation, - ]); + }, [captureLogicalAnchor, clearAlignmentWait, onHistoryTargetReady, onManualNavigation]); const beginScrollbarPointerNavigation = useCallback( - (event: PointerEvent) => { + (event: globalThis.PointerEvent | PointerEvent) => { const scrollNode = listRef.current?.getScrollableNode(); - if (event.target !== scrollNode) { + if (scrollNode === undefined || event.button !== 0 || pointerActiveRef.current) { + return; + } + const rect = scrollNode.getBoundingClientRect(); + const sideGutterWidth = Math.max(0, (scrollNode.offsetWidth - scrollNode.clientWidth) / 2); + if ( + event.target !== scrollNode && + !( + event.clientY >= rect.top && + event.clientY <= rect.bottom && + (event.clientX <= rect.left + sideGutterWidth || + event.clientX >= rect.right - sideGutterWidth) + ) + ) { return; } pointerActiveRef.current = true; @@ -607,7 +635,8 @@ export function useProgressiveTimelineHistory({ pointerActiveRef.current = false; captureLogicalAnchor(); scheduleHistoryLoad(); - }, [captureLogicalAnchor, scheduleHistoryLoad]); + queueReconciliation(); + }, [captureLogicalAnchor, queueReconciliation, scheduleHistoryLoad]); const selectHistoryTarget = useCallback( (item: TimelineHistoryNavigationTarget) => { @@ -624,6 +653,13 @@ export function useProgressiveTimelineHistory({ } clearAlignmentWait(); + if (loadTimeoutRef.current !== null) { + window.clearTimeout(loadTimeoutRef.current); + loadTimeoutRef.current = null; + } + const supersedesHistoryRequest = + historyTargetMessageId !== null && historyTargetMessageId !== item.id; + onHistoryTargetReady?.(); anchorRef.current = { kind: "message", messageId: item.id, @@ -633,43 +669,86 @@ export function useProgressiveTimelineHistory({ kind: "message", messageId: item.id, }; - programmaticScrollRef.current = "message"; + programmaticScrollRef.current = "seeking"; const list = listRef.current; const scrollNode = list?.getScrollableNode(); if (list === null || list === undefined || scrollNode === undefined) { + programmaticScrollRef.current = null; return; } + const estimatedScrollTop = + item.messageIndex === null + ? null + : Math.max( + 0, + Math.min( + scrollNode.scrollHeight - scrollNode.clientHeight, + item.messageIndex * VIRTUAL_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, + ), + ); if (item.rowIndex !== null) { void list.scrollToIndex({ index: item.rowIndex, animated: true, viewOffset: MESSAGE_VIEWPORT_OFFSET, }); - queueReconciliation(); - return; + } else if (estimatedScrollTop !== null) { + scrollNode.scrollTo({ behavior: "smooth", top: estimatedScrollTop }); } - if (item.messageIndex !== null) { - scrollNode.scrollTo({ - behavior: "smooth", - top: Math.max( - 0, - Math.min( - scrollNode.scrollHeight - scrollNode.clientHeight, - item.messageIndex * VIRTUAL_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, - ), - ), + if (item.rowIndex === null || supersedesHistoryRequest) { + window.setTimeout(() => { + if ( + pendingRequestRef.current?.kind === "message" && + pendingRequestRef.current.messageId === item.id + ) { + onSelectHistoryMessage?.(item.id); + } }); } - onSelectHistoryMessage?.(item.id); + + if (item.rowIndex === null && estimatedScrollTop === null) { + programmaticScrollRef.current = null; + return; + } + + const finishSeek = () => { + clearAlignmentWait(); + if (programmaticScrollRef.current !== "seeking") { + return; + } + if ( + estimatedScrollTop !== null && + Math.abs(scrollNode.scrollTop - estimatedScrollTop) > + Math.max(8, scrollNode.clientHeight * 0.02) + ) { + programmaticScrollRef.current = "instant"; + positionHistorySkeletons(estimatedScrollTop, scrollNode.scrollHeight); + void list.scrollToOffset({ offset: estimatedScrollTop, animated: false }); + releaseInstantScroll(); + } else { + programmaticScrollRef.current = null; + } + queueReconciliation(); + }; + scrollNode.addEventListener("scrollend", finishSeek, { once: true }); + const timeout = window.setTimeout(finishSeek, 750); + alignmentCleanupRef.current = () => { + window.clearTimeout(timeout); + scrollNode.removeEventListener("scrollend", finishSeek); + }; }, [ clearAlignmentWait, + historyTargetMessageId, listRef, messageHistory, + onHistoryTargetReady, onManualNavigation, onSelectHistoryMessage, + positionHistorySkeletons, queueReconciliation, + releaseInstantScroll, ], ); @@ -678,13 +757,22 @@ export function useProgressiveTimelineHistory({ return; } const beforeSpacer = historyBeforeSpacerRef.current; - const loadedRowsContainer = beforeSpacer?.parentElement?.nextElementSibling; - if (loadedRowsContainer === null || loadedRowsContainer === undefined) { + const afterSpacer = historyAfterSpacerRef.current; + const loadedRowsContainer = afterSpacer?.parentElement?.previousElementSibling; + if ( + beforeSpacer === null || + afterSpacer === null || + loadedRowsContainer === null || + loadedRowsContainer === undefined + ) { return; } const measure = () => { - const loadedSize = loadedRowsContainer.getBoundingClientRect().height; + const loadedSize = Math.max( + 0, + afterSpacer.getBoundingClientRect().top - beforeSpacer.getBoundingClientRect().bottom, + ); setLayoutMeasurement((current) => current?.windowKey === historyWindowKey && Math.abs(current.loadedSize - loadedSize) <= 1 ? current @@ -702,6 +790,8 @@ export function useProgressiveTimelineHistory({ measure(); const observer = new ResizeObserver(measure); observer.observe(loadedRowsContainer); + observer.observe(beforeSpacer); + observer.observe(afterSpacer); return () => { observer.disconnect(); }; @@ -723,8 +813,30 @@ export function useProgressiveTimelineHistory({ if (list === null || list === undefined || scrollNode === undefined) { return; } - if (anchorRef.current === null) { - captureLogicalAnchor(); + if (!historyInitializedRef.current) { + const initialScrollTop = messageHistory.hasMoreAfter + ? Math.min( + historyBeforeSize, + Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight), + ) + : Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); + anchorRef.current = { + kind: "logical", + messageIndex: Math.max( + 0, + Math.min( + messageHistory.totalMessages - 1, + (initialScrollTop + scrollNode.clientHeight / 2) / VIRTUAL_MESSAGE_SIZE, + ), + ), + viewportOffset: scrollNode.clientHeight / 2, + }; + if (Math.abs(scrollNode.scrollTop - initialScrollTop) > 1) { + programmaticScrollRef.current = "instant"; + void list.scrollToOffset({ offset: initialScrollTop, animated: false }); + releaseInstantScroll(); + } + historyInitializedRef.current = true; } scheduleHistoryLoad(); if (clearedWindowKeyRef.current !== historyWindowKey) { @@ -733,21 +845,21 @@ export function useProgressiveTimelineHistory({ } queueReconciliation(); - const frame = requestAnimationFrame(() => { + const timeout = window.setTimeout(() => { const anchor = anchorRef.current; if ( - anchor?.kind !== "logical" || - anchor.messageIndex < messageHistory.startIndex || - anchor.messageIndex >= messageHistory.endIndex + anchor === null || + (anchor.kind === "logical" && + (anchor.messageIndex < messageHistory.startIndex || + anchor.messageIndex >= messageHistory.endIndex)) || + (anchor.kind === "message" && !loadedMessageIds.includes(anchor.messageId)) ) { return; } - const loadedRowsContainer = historyBeforeSpacerRef.current?.parentElement?.nextElementSibling; - if (loadedRowsContainer === null || loadedRowsContainer === undefined) { - return; - } const viewportRect = scrollNode.getBoundingClientRect(); - const viewportContainsRenderedRow = Array.from(loadedRowsContainer.children).some((row) => { + const viewportContainsRenderedRow = Array.from( + timelineViewportElement?.querySelectorAll("[data-timeline-root]") ?? [], + ).some((row) => { const rowRect = row.getBoundingClientRect(); return rowRect.bottom > viewportRect.top && rowRect.top < viewportRect.bottom; }); @@ -762,21 +874,22 @@ export function useProgressiveTimelineHistory({ }); void list.scrollToOffset({ offset: scrollTop, animated: false }); releaseInstantScroll(); - }); + }, 0); return () => { - cancelAnimationFrame(frame); + window.clearTimeout(timeout); }; }, [ - captureLogicalAnchor, historyAfterSize, historyBeforeSize, historyWindowKey, listRef, + loadedMessageIds, messageHistory, queueReconciliation, releaseInstantScroll, rows.length, scheduleHistoryLoad, + timelineViewportElement, ]); useEffect(() => { @@ -798,7 +911,7 @@ export function useProgressiveTimelineHistory({ onHistoryTargetReady?.(); return; } - const frame = requestAnimationFrame(() => { + const timeout = window.setTimeout(() => { if (!reconcileAnchor()) { return; } @@ -810,8 +923,8 @@ export function useProgressiveTimelineHistory({ pendingRequestRef.current = null; onHistoryTargetReady?.(); scheduleHistoryLoad(); - }); - return () => cancelAnimationFrame(frame); + }, 0); + return () => window.clearTimeout(timeout); }, [ historyLoadInProgress, historyWindowKey, @@ -845,13 +958,15 @@ export function useProgressiveTimelineHistory({ }, [queueReconciliation, timelineViewportElement]); useEffect(() => { + window.addEventListener("pointerdown", beginScrollbarPointerNavigation, true); window.addEventListener("pointerup", finishScrollbarPointerNavigation); window.addEventListener("pointercancel", finishScrollbarPointerNavigation); return () => { + window.removeEventListener("pointerdown", beginScrollbarPointerNavigation, true); window.removeEventListener("pointerup", finishScrollbarPointerNavigation); window.removeEventListener("pointercancel", finishScrollbarPointerNavigation); }; - }, [finishScrollbarPointerNavigation]); + }, [beginScrollbarPointerNavigation, finishScrollbarPointerNavigation]); useEffect( () => () => { @@ -860,13 +975,13 @@ export function useProgressiveTimelineHistory({ window.clearTimeout(loadTimeoutRef.current); loadTimeoutRef.current = null; } - if (reconcileFrameRef.current !== null) { - cancelAnimationFrame(reconcileFrameRef.current); - reconcileFrameRef.current = null; + if (reconcileTimeoutRef.current !== null) { + window.clearTimeout(reconcileTimeoutRef.current); + reconcileTimeoutRef.current = null; } - if (releaseProgrammaticScrollFrameRef.current !== null) { - cancelAnimationFrame(releaseProgrammaticScrollFrameRef.current); - releaseProgrammaticScrollFrameRef.current = null; + if (releaseProgrammaticScrollTimeoutRef.current !== null) { + window.clearTimeout(releaseProgrammaticScrollTimeoutRef.current); + releaseProgrammaticScrollTimeoutRef.current = null; } }, [clearAlignmentWait], From 84aab49dccb3d196cb83ab75a1075c09d94a6ff3 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 28 Jul 2026 23:55:31 +0300 Subject: [PATCH 06/31] simplify progressive history scroll ownership --- FORK.md | 34 +- .../Layers/ProjectionSnapshotQuery.ts | 12 +- .../src/components/chat/MessagesTimeline.tsx | 9 +- .../chat/useProgressiveTimelineHistory.ts | 401 ++++++++---------- apps/web/src/connection/storage.ts | 32 +- packages/client-runtime/src/state/threads.ts | 100 +++-- 6 files changed, 284 insertions(+), 304 deletions(-) diff --git a/FORK.md b/FORK.md index ee4c2a4aa46..a3fd36f435e 100644 --- a/FORK.md +++ b/FORK.md @@ -46,6 +46,8 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Fork-only goal persistence is stored in a sidecar database named `state-tarik02.sqlite`. - 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. ### Goals UI @@ -68,23 +70,21 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork windows retain every message, cap work telemetry per segment, and only display activity for turns represented by the active message window. Viewport navigation, minimap jumps, and unloaded spacers share one fixed per-message scroll axis and load the nearest landmark window through a trailing - throttle instead of chaining relative before/after page loads. The content container keeps the - full-history extent fixed while the trailing spacer absorbs measured segment height. Legend List - position stabilization is disabled for these threads, and its cached window geometry is refreshed - when a segment moves without interrupting an active smooth scroll. - Paginated history responses use the same client-facing activity payload projection as full thread - snapshots, and failed segment requests release their pending navigation target instead of leaving - the virtual loader locked. Minimap jumps begin scrolling through virtual history immediately while - the segment loads, then align the loaded target with a smooth correction instead of teleporting - the viewport after the response. When smooth motion is unavailable, the estimated-position - fallback still moves inline skeletons into the viewport before exact alignment. Scrollbar movement - keeps a logical viewport anchor, while minimap jumps keep a concrete message anchor; segment swaps - and layout resizes reconcile against that anchor without changing the fixed scroll extent. The - programmatic seek ends with the scroll motion, not the segment request, and manual input replaces - it with a logical anchor so a slow request cannot lock the scrollbar. Segment reconciliation does - not run while the native scrollbar thumb is held, and cold minimap requests begin in a separate - task after the seek starts. Around-window requests keep one active load and coalesce intermediate - targets to the latest requested position. + throttle instead of chaining relative before/after page loads. Unloaded ranges keep their fixed + virtual size while the active segment contributes its measured height. Segment changes use Legend + List's data version instead of manually clearing its layout caches. +- 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 instead of leaving the virtual + loader locked. Minimap jumps position virtual history immediately while the segment loads, then use + the loaded row position for a smooth exact correction. Any keyboard, pointer, touch, or wheel input + cancels that motion and removes the concrete target before the browser scrolls. The resulting real + scroll selects the next logical segment without restoring an older viewport position. Holding the + native scrollbar thumb suppresses target alignment but never pauses throttled data loading. + Synchronous offset writes avoid delayed programmatic scrolls taking control back from the user. + Overlapping explicit jumps may fetch concurrently, but only the latest request can replace the + active segment. - Desktop context-menu style is configurable. - 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/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index a9d2d53e2f7..aa92736d6a5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -473,7 +473,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 @@ -1067,7 +1071,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 diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 178d4fa4ec3..816ea118275 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -490,14 +490,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({
ref={listRef} data={rows} + {...(messageHistory === undefined + ? {} + : { + dataVersion: `${messageHistory.startIndex}:${messageHistory.endIndex}:${Math.round(progressiveHistory.historyBeforeSize)}`, + })} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -536,6 +540,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : false } onScroll={progressiveHistory.handleScroll} + onMetricsChange={progressiveHistory.onListMetricsChange} {...(messageHistory === undefined ? {} : { diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts index 174082f1530..601fa8e24f5 100644 --- a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -3,7 +3,7 @@ import { type OrchestrationThreadHistoryOutline, type OrchestrationThreadMessageHistory, } from "@t3tools/contracts"; -import { type LegendListRef } from "@legendapp/list/react"; +import { type LegendListMetrics, type LegendListRef } from "@legendapp/list/react"; import { useCallback, useEffect, @@ -25,7 +25,6 @@ type ViewportAnchor = | { readonly kind: "logical"; readonly messageIndex: number; - readonly viewportOffset: number; } | { readonly kind: "message"; @@ -47,7 +46,6 @@ type PendingHistoryRequest = interface HistoryLayoutMeasurement { readonly windowKey: string; readonly loadedSize: number; - readonly beforeSize: number; } interface ResolvedLogicalAnchor { @@ -98,20 +96,20 @@ export function useProgressiveTimelineHistory({ const anchorRef = useRef(null); const pendingRequestRef = useRef(null); const resolvedLogicalAnchorRef = useRef(null); - const programmaticScrollRef = useRef<"instant" | "seeking" | "aligning" | null>(null); + const programmaticScrollRef = useRef<"seeking" | "aligning" | null>(null); + const expectedScrollTopRef = useRef(null); const pointerActiveRef = useRef(false); const loadTimeoutRef = useRef(null); const lastLoadAtRef = useRef(0); const reconcileTimeoutRef = useRef(null); const reconcileAnchorRef = useRef<() => void>(() => {}); - const releaseProgrammaticScrollTimeoutRef = useRef(null); const alignmentCleanupRef = useRef<(() => void) | null>(null); const historyBeforeSpacerRef = useRef(null); const historyAfterSpacerRef = useRef(null); const historyBeforeSkeletonsRef = useRef(null); const historyAfterSkeletonsRef = useRef(null); const historyInitializedRef = useRef(false); - const clearedWindowKeyRef = useRef(null); + const [listHeaderSize, setListHeaderSize] = useState(0); const [layoutMeasurement, setLayoutMeasurement] = useState(null); const loadedMessageIds = useMemo( () => @@ -136,20 +134,13 @@ export function useProgressiveTimelineHistory({ layoutMeasurement?.windowKey === historyWindowKey ? layoutMeasurement.loadedSize : estimatedLoadedSize; - const totalHistorySize = (messageHistory?.totalMessages ?? 0) * VIRTUAL_MESSAGE_SIZE; const virtualHistoryBeforeSize = (messageHistory?.startIndex ?? 0) * VIRTUAL_MESSAGE_SIZE; - const measuredHistoryBeforeSize = - layoutMeasurement?.windowKey === historyWindowKey - ? layoutMeasurement.beforeSize - : virtualHistoryBeforeSize; - const historyBeforeSize = Math.min( - measuredHistoryBeforeSize, - Math.max(0, totalHistorySize - loadedSize), - ); - const historyAfterSize = Math.max(0, totalHistorySize - historyBeforeSize - loadedSize); + const historyBeforeSize = virtualHistoryBeforeSize; const virtualHistoryAfterSize = Math.max(0, (messageHistory?.totalMessages ?? 0) - (messageHistory?.endIndex ?? 0)) * VIRTUAL_MESSAGE_SIZE; + const historyAfterSize = virtualHistoryAfterSize; + const contentHeight = historyBeforeSize + loadedSize + historyAfterSize; const historyRequestInProgress = isLoadingPreviousMessages || isLoadingNextMessages || historyTargetMessageId !== null; const historyLoadInProgress = isLoadingPreviousMessages || isLoadingNextMessages; @@ -168,31 +159,22 @@ export function useProgressiveTimelineHistory({ return; } const viewportOffset = scrollNode.clientHeight / 2; + const viewportPosition = scrollNode.scrollTop + viewportOffset; + const messageIndex = + viewportPosition <= historyBeforeSize + ? viewportPosition / VIRTUAL_MESSAGE_SIZE + : viewportPosition >= historyBeforeSize + loadedSize + ? messageHistory.endIndex + + (viewportPosition - historyBeforeSize - loadedSize) / VIRTUAL_MESSAGE_SIZE + : messageHistory.startIndex + + ((viewportPosition - historyBeforeSize) / Math.max(1, loadedSize)) * + (messageHistory.endIndex - messageHistory.startIndex); resolvedLogicalAnchorRef.current = null; anchorRef.current = { kind: "logical", - messageIndex: Math.max( - 0, - Math.min( - messageHistory.totalMessages - 1, - (scrollNode.scrollTop + viewportOffset) / VIRTUAL_MESSAGE_SIZE, - ), - ), - viewportOffset, + messageIndex: Math.max(0, Math.min(messageHistory.totalMessages - 1, messageIndex)), }; - }, [listRef, messageHistory]); - - const releaseInstantScroll = useCallback(() => { - if (releaseProgrammaticScrollTimeoutRef.current !== null) { - window.clearTimeout(releaseProgrammaticScrollTimeoutRef.current); - } - releaseProgrammaticScrollTimeoutRef.current = window.setTimeout(() => { - releaseProgrammaticScrollTimeoutRef.current = null; - if (programmaticScrollRef.current === "instant") { - programmaticScrollRef.current = null; - } - }, 0); - }, []); + }, [historyBeforeSize, listRef, loadedSize, messageHistory]); const queueReconciliation = useCallback(() => { if (reconcileTimeoutRef.current !== null) { @@ -258,6 +240,50 @@ export function useProgressiveTimelineHistory({ [historyAfterSize, historyBeforeSize], ); + const setScrollTop = useCallback( + (scrollNode: HTMLElement, scrollTop: number) => { + expectedScrollTopRef.current = scrollTop; + positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); + scrollNode.scrollTo({ behavior: "auto", top: scrollTop }); + }, + [positionHistorySkeletons], + ); + + const smoothScroll = useCallback( + (scrollNode: HTMLElement, scrollTop: number, onFinish: () => void) => { + clearAlignmentWait(); + if (Math.abs(scrollNode.scrollTop - scrollTop) <= 1) { + onFinish(); + return; + } + + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + window.clearTimeout(timeout); + scrollNode.removeEventListener("scrollend", finish); + if (alignmentCleanupRef.current === cleanup) { + alignmentCleanupRef.current = null; + } + onFinish(); + }; + const timeout = window.setTimeout(finish, 1_000); + const cleanup = () => { + finished = true; + window.clearTimeout(timeout); + scrollNode.removeEventListener("scrollend", finish); + }; + alignmentCleanupRef.current = cleanup; + positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); + scrollNode.addEventListener("scrollend", finish, { once: true }); + scrollNode.scrollTo({ behavior: "smooth", top: scrollTop }); + }, + [clearAlignmentWait, positionHistorySkeletons], + ); + const reconcileAnchor = useCallback(() => { if (messageHistory === undefined || timelineViewportElement === null) { return false; @@ -271,6 +297,9 @@ export function useProgressiveTimelineHistory({ if (list === null || list === undefined || scrollNode === undefined || anchor === null) { return false; } + if (Math.abs(listHeaderSize - historyBeforeSize) > 1) { + return false; + } if (anchor.kind === "logical") { const pendingRequest = pendingRequestRef.current; @@ -280,63 +309,50 @@ export function useProgressiveTimelineHistory({ ) { return false; } - const resolvedAnchor = resolvedLogicalAnchorRef.current; - const alignmentMessageId = - pendingRequest?.kind === "logical" - ? pendingRequest.messageId - : resolvedAnchor?.windowKey === historyWindowKey && - Math.abs(resolvedAnchor.anchorMessageIndex - anchor.messageIndex) <= 0.01 - ? resolvedAnchor.messageId - : null; - if (alignmentMessageId !== null && historyWindowKey !== null) { - const target = timelineViewportElement.querySelector( - `[data-message-id="${CSS.escape(alignmentMessageId)}"]`, - ); - const beforeSpacer = historyBeforeSpacerRef.current; - if (target === null || beforeSpacer === null) { - return false; - } - const nextBeforeSize = Math.max( - 0, - Math.min( - totalHistorySize - loadedSize, - scrollNode.scrollTop + - anchor.viewportOffset - - (target.getBoundingClientRect().top - beforeSpacer.getBoundingClientRect().bottom), - ), - ); - if (Math.abs(historyBeforeSize - nextBeforeSize) > 1) { - setLayoutMeasurement({ - windowKey: historyWindowKey, - loadedSize, - beforeSize: nextBeforeSize, - }); - return false; - } - } + return ( + pendingRequest?.kind !== "logical" || + minimapItems.some((item) => item.id === pendingRequest.messageId && item.rowIndex !== null) + ); + } + const target = timelineViewportElement.querySelector( + `[data-message-id="${CSS.escape(anchor.messageId)}"]`, + ); + if (target === null) { + const targetRowIndex = minimapItems.find((item) => item.id === anchor.messageId)?.rowIndex; + const targetPosition = + targetRowIndex === null || targetRowIndex === undefined + ? undefined + : list.getState().positionAtIndex(targetRowIndex); + if ( + targetPosition === undefined || + !Number.isFinite(targetPosition) || + programmaticScrollRef.current === "aligning" + ) { + return false; + } const scrollTop = Math.max( 0, Math.min( scrollNode.scrollHeight - scrollNode.clientHeight, - anchor.messageIndex * VIRTUAL_MESSAGE_SIZE - anchor.viewportOffset, + historyBeforeSize + targetPosition - anchor.viewportOffset, ), ); - if (Math.abs(scrollNode.scrollTop - scrollTop) > 1) { - programmaticScrollRef.current = "instant"; - positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); - void list.scrollToOffset({ offset: scrollTop, animated: false }); - releaseInstantScroll(); - queueReconciliation(); - return false; + if (Math.abs(scrollNode.scrollTop - scrollTop) <= 2) { + pendingRequestRef.current = null; + if (historyTargetMessageId === anchor.messageId) { + onHistoryTargetReady?.(); + } + return true; } - return true; - } - - const target = timelineViewportElement.querySelector( - `[data-message-id="${CSS.escape(anchor.messageId)}"]`, - ); - if (target === null) { + programmaticScrollRef.current = "aligning"; + smoothScroll(scrollNode, scrollTop, () => { + if (programmaticScrollRef.current !== "aligning") { + return; + } + programmaticScrollRef.current = null; + queueReconciliation(); + }); return false; } if (programmaticScrollRef.current === "seeking") { @@ -359,10 +375,7 @@ export function useProgressiveTimelineHistory({ } if (pendingRequestRef.current?.kind !== "message") { clearAlignmentWait(); - programmaticScrollRef.current = "instant"; - positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); - void list.scrollToOffset({ offset: scrollTop, animated: false }); - releaseInstantScroll(); + setScrollTop(scrollNode, scrollTop); queueReconciliation(); return false; } @@ -370,10 +383,8 @@ export function useProgressiveTimelineHistory({ return false; } - clearAlignmentWait(); programmaticScrollRef.current = "aligning"; const finishAlignment = () => { - clearAlignmentWait(); if (programmaticScrollRef.current !== "aligning") { return; } @@ -384,28 +395,21 @@ export function useProgressiveTimelineHistory({ } queueReconciliation(); }; - scrollNode.addEventListener("scrollend", finishAlignment, { once: true }); - const timeout = window.setTimeout(finishAlignment, 750); - alignmentCleanupRef.current = () => { - window.clearTimeout(timeout); - scrollNode.removeEventListener("scrollend", finishAlignment); - }; - scrollNode.scrollTo({ behavior: "smooth", top: scrollTop }); + smoothScroll(scrollNode, scrollTop, finishAlignment); return false; }, [ clearAlignmentWait, historyBeforeSize, historyTargetMessageId, - historyWindowKey, + listHeaderSize, listRef, - loadedSize, messageHistory, + minimapItems, onHistoryTargetReady, - positionHistorySkeletons, queueReconciliation, - releaseInstantScroll, + setScrollTop, + smoothScroll, timelineViewportElement, - totalHistorySize, ]); reconcileAnchorRef.current = reconcileAnchor; @@ -534,6 +538,29 @@ export function useProgressiveTimelineHistory({ }, delay); }, [requestHistoryForAnchor]); + const releaseScrollControl = useCallback(() => { + const scrollNode = listRef.current?.getScrollableNode(); + if (programmaticScrollRef.current !== null && scrollNode !== undefined) { + scrollNode.scrollTo({ behavior: "auto", top: scrollNode.scrollTop }); + } + clearAlignmentWait(); + programmaticScrollRef.current = null; + expectedScrollTopRef.current = null; + anchorRef.current = null; + pendingRequestRef.current = null; + resolvedLogicalAnchorRef.current = null; + onHistoryTargetReady?.(); + }, [clearAlignmentWait, listRef, onHistoryTargetReady]); + + const beginUserNavigation = useCallback(() => { + releaseScrollControl(); + onManualNavigation(); + }, [onManualNavigation, releaseScrollControl]); + + const handleListMetricsChange = useCallback(({ headerSize }: LegendListMetrics) => { + setListHeaderSize((current) => (Math.abs(current - headerSize) <= 1 ? current : headerSize)); + }, []); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); @@ -550,17 +577,23 @@ export function useProgressiveTimelineHistory({ const scrollBottom = scrollTop + scrollLength; positionHistorySkeletons(scrollTop, scrollNode?.scrollHeight ?? state.contentLength ?? 0); + const expectedScrollTop = expectedScrollTopRef.current; + const isExpectedScroll = + expectedScrollTop !== null && Math.abs(scrollTop - expectedScrollTop) <= 1; + if (isExpectedScroll) { + expectedScrollTopRef.current = null; + } + if ( messageHistory !== undefined && historyInitializedRef.current && - programmaticScrollRef.current === null + programmaticScrollRef.current === null && + !isExpectedScroll ) { - if (anchorRef.current?.kind === "message") { - queueReconciliation(); - } else { - captureLogicalAnchor(); - scheduleHistoryLoad(); - } + releaseScrollControl(); + captureLogicalAnchor(); + onManualNavigation(); + scheduleHistoryLoad(); } for (const item of minimapItems) { @@ -588,21 +621,13 @@ export function useProgressiveTimelineHistory({ messageHistory, minimapItems, minimapStripMap, + onManualNavigation, onIsAtEndChange, positionHistorySkeletons, - queueReconciliation, + releaseScrollControl, scheduleHistoryLoad, ]); - const beginUserNavigation = useCallback(() => { - clearAlignmentWait(); - programmaticScrollRef.current = null; - pendingRequestRef.current = null; - onHistoryTargetReady?.(); - captureLogicalAnchor(); - onManualNavigation(); - }, [captureLogicalAnchor, clearAlignmentWait, onHistoryTargetReady, onManualNavigation]); - const beginScrollbarPointerNavigation = useCallback( (event: globalThis.PointerEvent | PointerEvent) => { const scrollNode = listRef.current?.getScrollableNode(); @@ -628,6 +653,14 @@ export function useProgressiveTimelineHistory({ [beginUserNavigation, listRef], ); + const beginPointerNavigation = useCallback( + (event: PointerEvent) => { + beginUserNavigation(); + beginScrollbarPointerNavigation(event); + }, + [beginScrollbarPointerNavigation, beginUserNavigation], + ); + const finishScrollbarPointerNavigation = useCallback(() => { if (!pointerActiveRef.current) { return; @@ -653,6 +686,7 @@ export function useProgressiveTimelineHistory({ } clearAlignmentWait(); + expectedScrollTopRef.current = null; if (loadTimeoutRef.current !== null) { window.clearTimeout(loadTimeoutRef.current); loadTimeoutRef.current = null; @@ -687,15 +721,6 @@ export function useProgressiveTimelineHistory({ item.messageIndex * VIRTUAL_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, ), ); - if (item.rowIndex !== null) { - void list.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: MESSAGE_VIEWPORT_OFFSET, - }); - } else if (estimatedScrollTop !== null) { - scrollNode.scrollTo({ behavior: "smooth", top: estimatedScrollTop }); - } if (item.rowIndex === null || supersedesHistoryRequest) { window.setTimeout(() => { if ( @@ -713,30 +738,24 @@ export function useProgressiveTimelineHistory({ } const finishSeek = () => { - clearAlignmentWait(); if (programmaticScrollRef.current !== "seeking") { return; } - if ( - estimatedScrollTop !== null && - Math.abs(scrollNode.scrollTop - estimatedScrollTop) > - Math.max(8, scrollNode.clientHeight * 0.02) - ) { - programmaticScrollRef.current = "instant"; - positionHistorySkeletons(estimatedScrollTop, scrollNode.scrollHeight); - void list.scrollToOffset({ offset: estimatedScrollTop, animated: false }); - releaseInstantScroll(); - } else { - programmaticScrollRef.current = null; - } + programmaticScrollRef.current = null; queueReconciliation(); }; - scrollNode.addEventListener("scrollend", finishSeek, { once: true }); - const timeout = window.setTimeout(finishSeek, 750); - alignmentCleanupRef.current = () => { - window.clearTimeout(timeout); - scrollNode.removeEventListener("scrollend", finishSeek); - }; + if (estimatedScrollTop !== null) { + setScrollTop(scrollNode, estimatedScrollTop); + finishSeek(); + } else if (item.rowIndex !== null) { + void list.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: MESSAGE_VIEWPORT_OFFSET, + }); + programmaticScrollRef.current = null; + queueReconciliation(); + } }, [ clearAlignmentWait, @@ -746,9 +765,8 @@ export function useProgressiveTimelineHistory({ onHistoryTargetReady, onManualNavigation, onSelectHistoryMessage, - positionHistorySkeletons, queueReconciliation, - releaseInstantScroll, + setScrollTop, ], ); @@ -779,10 +797,6 @@ export function useProgressiveTimelineHistory({ : { windowKey: historyWindowKey, loadedSize, - beforeSize: - current?.windowKey === historyWindowKey - ? Math.min(current.beforeSize, Math.max(0, totalHistorySize - loadedSize)) - : Math.min(virtualHistoryBeforeSize, Math.max(0, totalHistorySize - loadedSize)), }, ); queueReconciliation(); @@ -795,14 +809,7 @@ export function useProgressiveTimelineHistory({ return () => { observer.disconnect(); }; - }, [ - historyWindowKey, - messageHistory, - queueReconciliation, - rows.length, - totalHistorySize, - virtualHistoryBeforeSize, - ]); + }, [historyWindowKey, messageHistory, queueReconciliation, rows.length]); useLayoutEffect(() => { if (messageHistory === undefined || historyWindowKey === null) { @@ -829,67 +836,25 @@ export function useProgressiveTimelineHistory({ (initialScrollTop + scrollNode.clientHeight / 2) / VIRTUAL_MESSAGE_SIZE, ), ), - viewportOffset: scrollNode.clientHeight / 2, }; if (Math.abs(scrollNode.scrollTop - initialScrollTop) > 1) { - programmaticScrollRef.current = "instant"; - void list.scrollToOffset({ offset: initialScrollTop, animated: false }); - releaseInstantScroll(); + setScrollTop(scrollNode, initialScrollTop); } historyInitializedRef.current = true; } scheduleHistoryLoad(); - if (clearedWindowKeyRef.current !== historyWindowKey) { - clearedWindowKeyRef.current = historyWindowKey; - list.clearCaches(); + if (Math.abs(listHeaderSize - historyBeforeSize) <= 1) { + queueReconciliation(); } - queueReconciliation(); - - const timeout = window.setTimeout(() => { - const anchor = anchorRef.current; - if ( - anchor === null || - (anchor.kind === "logical" && - (anchor.messageIndex < messageHistory.startIndex || - anchor.messageIndex >= messageHistory.endIndex)) || - (anchor.kind === "message" && !loadedMessageIds.includes(anchor.messageId)) - ) { - return; - } - const viewportRect = scrollNode.getBoundingClientRect(); - const viewportContainsRenderedRow = Array.from( - timelineViewportElement?.querySelectorAll("[data-timeline-root]") ?? [], - ).some((row) => { - const rowRect = row.getBoundingClientRect(); - return rowRect.bottom > viewportRect.top && rowRect.top < viewportRect.bottom; - }); - if (viewportContainsRenderedRow) { - return; - } - const scrollTop = scrollNode.scrollTop; - programmaticScrollRef.current = "instant"; - void list.scrollToOffset({ - offset: scrollTop > 1 ? scrollTop - 2 : scrollTop + 2, - animated: false, - }); - void list.scrollToOffset({ offset: scrollTop, animated: false }); - releaseInstantScroll(); - }, 0); - return () => { - window.clearTimeout(timeout); - }; }, [ - historyAfterSize, historyBeforeSize, historyWindowKey, + listHeaderSize, listRef, - loadedMessageIds, messageHistory, queueReconciliation, - releaseInstantScroll, - rows.length, scheduleHistoryLoad, - timelineViewportElement, + setScrollTop, ]); useEffect(() => { @@ -958,15 +923,17 @@ export function useProgressiveTimelineHistory({ }, [queueReconciliation, timelineViewportElement]); useEffect(() => { + window.addEventListener("keydown", beginUserNavigation, true); window.addEventListener("pointerdown", beginScrollbarPointerNavigation, true); window.addEventListener("pointerup", finishScrollbarPointerNavigation); window.addEventListener("pointercancel", finishScrollbarPointerNavigation); return () => { + window.removeEventListener("keydown", beginUserNavigation, true); window.removeEventListener("pointerdown", beginScrollbarPointerNavigation, true); window.removeEventListener("pointerup", finishScrollbarPointerNavigation); window.removeEventListener("pointercancel", finishScrollbarPointerNavigation); }; - }, [beginScrollbarPointerNavigation, finishScrollbarPointerNavigation]); + }, [beginScrollbarPointerNavigation, beginUserNavigation, finishScrollbarPointerNavigation]); useEffect( () => () => { @@ -979,19 +946,16 @@ export function useProgressiveTimelineHistory({ window.clearTimeout(reconcileTimeoutRef.current); reconcileTimeoutRef.current = null; } - if (releaseProgrammaticScrollTimeoutRef.current !== null) { - window.clearTimeout(releaseProgrammaticScrollTimeoutRef.current); - releaseProgrammaticScrollTimeoutRef.current = null; - } }, [clearAlignmentWait], ); return useMemo( () => ({ + beginPointerNavigation, beginScrollbarPointerNavigation, beginUserNavigation, - contentHeight: totalHistorySize, + contentHeight, handleScroll, historyAfterSize, historyAfterSkeletonsRef, @@ -999,18 +963,21 @@ export function useProgressiveTimelineHistory({ historyBeforeSize, historyBeforeSkeletonsRef, historyBeforeSpacerRef, + onListMetricsChange: handleListMetricsChange, selectHistoryTarget, virtualHistoryAfterSize, virtualHistoryBeforeSize, }), [ beginScrollbarPointerNavigation, + beginPointerNavigation, beginUserNavigation, handleScroll, historyAfterSize, historyBeforeSize, + handleListMetricsChange, selectHistoryTarget, - totalHistorySize, + contentHeight, virtualHistoryAfterSize, virtualHistoryBeforeSize, ], diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 5f871dbbf42..e7ba7d66555 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -135,15 +135,21 @@ const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); 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), -); +const decodeStoredThreadHistoryMessage = Schema.decodeUnknownEffect(StoredThreadHistoryMessage); +const decodeStoredThreadHistoryMessages = (values: ReadonlyArray) => + Effect.forEach(values, (value) => decodeStoredThreadHistoryMessage(value), { + concurrency: "unbounded", + }); +const decodeStoredThreadHistoryActivity = Schema.decodeUnknownEffect(StoredThreadHistoryActivity); +const decodeStoredThreadHistoryActivities = (values: ReadonlyArray) => + Effect.forEach(values, (value) => decodeStoredThreadHistoryActivity(value), { + concurrency: "unbounded", + }); +const decodeStoredThreadHistoryPlan = Schema.decodeUnknownEffect(StoredThreadHistoryPlan); +const decodeStoredThreadHistoryPlans = (values: ReadonlyArray) => + Effect.forEach(values, (value) => decodeStoredThreadHistoryPlan(value), { + concurrency: "unbounded", + }); const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson); @@ -467,11 +473,9 @@ const loadThreadHistoryPage = Effect.fn("web.connectionStorage.loadThreadHistory THREAD_HISTORY_PLAN_POSITION_INDEX_NAME, ), ]); - const [messageRecords, activityRecords, planRecords] = yield* Effect.all([ - decodeStoredThreadHistoryMessages(rawMessages), - decodeStoredThreadHistoryActivities(rawActivities), - decodeStoredThreadHistoryPlans(rawPlans), - ]); + const messageRecords = yield* decodeStoredThreadHistoryMessages(rawMessages); + const activityRecords = yield* decodeStoredThreadHistoryActivities(rawActivities); + const planRecords = yield* decodeStoredThreadHistoryPlans(rawPlans); if ( messageRecords.length !== endIndex - startIndex || messageRecords.some( diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 3f06b72a864..22ce60975de 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -766,64 +766,42 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + let latestAroundRequestId = 0; const loadMessagesAround = Effect.fn("EnvironmentThreadState.loadMessagesAround")(function* ( messageId: MessageId, ) { + const requestId = ++latestAroundRequestId; const current = yield* SubscriptionRef.get(state); - if (current.history.kind === "disabled" || current.history.loading !== null) { + if (current.history.kind === "disabled") { return false; } - const cachedPage = yield* historyCache - .loadAround(environmentId, threadId, messageId, THREAD_HISTORY_AROUND_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()), - ), - }), - ); - if (Option.isSome(cachedPage)) { - const currentLiveThread = yield* Ref.get(liveThread); - if (Option.isNone(currentLiveThread)) { - return false; - } - const totalMessages = Math.max( - cachedPage.value.messageHistory.totalMessages, - currentLiveThread.value.messageHistory?.totalMessages ?? - currentLiveThread.value.messages.length, - ); - const history: EnvironmentThreadHistoryState = { - ...current.history, - window: { - ...cachedPage.value, - messageHistory: { - ...cachedPage.value.messageHistory, - hasMoreAfter: cachedPage.value.messageHistory.endIndex < totalMessages, - totalMessages, - }, - }, - }; - yield* SubscriptionRef.set(state, { - ...current, - data: Option.some(displayThreadHistory(currentLiveThread.value, history)), - history, - }); - return true; - } yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "around" }, }); return yield* Effect.gen(function* () { - const prepared = yield* preparedConnection; - const page = yield* snapshotLoader.loadMessagesAround(prepared, threadId, messageId); - if (Option.isNone(page)) { + const cachedPage = yield* historyCache + .loadAround(environmentId, threadId, messageId, THREAD_HISTORY_AROUND_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; } @@ -836,21 +814,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ) { 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: page.value, + window, }; yield* SubscriptionRef.set(state, { ...latest, data: Option.some(displayThreadHistory(latestLiveThread.value, history)), history, }); - yield* cacheHistoryPage(page.value); + if (Option.isNone(cachedPage)) { + yield* cacheHistoryPage(page.value); + } return true; }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => - latest.history.kind === "ready" && latest.history.loading === "around" + requestId === latestAroundRequestId && + latest.history.kind === "ready" && + latest.history.loading === "around" ? { ...latest, history: { ...latest.history, loading: null }, @@ -1233,8 +1230,7 @@ export function createEnvironmentThreadStateAtoms( ), scheduler, concurrency: { - mode: "latest", - key: ({ environmentId, input }) => threadKey({ environmentId, threadId: input.threadId }), + mode: "parallel", }, }), }; From 8207ed9cb26e58459d1b49214762668f4acc0b9a Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 09:52:47 +0300 Subject: [PATCH 07/31] fill visible progressive history boundaries --- FORK.md | 8 +- apps/web/src/components/ChatView.tsx | 26 ++- .../src/components/chat/MessagesTimeline.tsx | 15 ++ .../chat/useProgressiveTimelineHistory.ts | 182 ++++++++++++++++-- packages/client-runtime/src/state/threads.ts | 32 +-- 5 files changed, 230 insertions(+), 33 deletions(-) diff --git a/FORK.md b/FORK.md index a3fd36f435e..a148f0ab964 100644 --- a/FORK.md +++ b/FORK.md @@ -70,9 +70,11 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork windows retain every message, cap work telemetry per segment, and only display activity for turns represented by the active message window. Viewport navigation, minimap jumps, and unloaded spacers share one fixed per-message scroll axis and load the nearest landmark window through a trailing - throttle instead of chaining relative before/after page loads. Unloaded ranges keep their fixed - virtual size while the active segment contributes its measured height. Segment changes use Legend - List's data version instead of manually clearing its layout caches. + throttle. When a viewport straddles the active segment boundary, the client extends that segment + with the adjacent page and anchors a rendered message while the virtual spacer is replaced. + Unloaded ranges keep their fixed virtual size while the active segment contributes its measured + height. Segment changes use Legend List's data version instead of manually clearing its layout + caches. - 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. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ffef6165ca..943a0c62575 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1148,6 +1148,12 @@ function ChatViewContent(props: ChatViewProps) { const loadMessagesAround = useAtomCommand(environmentThreads.loadMessagesAround, { reportFailure: false, }); + const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { + reportFailure: false, + }); + const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1450,6 +1456,20 @@ function ChatViewContent(props: ChatViewProps) { 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 ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -5945,12 +5965,14 @@ function ChatViewContent(props: ChatViewProps) { isLoadingPreviousMessages={ environmentThreadState.history.kind === "ready" && (environmentThreadState.history.loading === "before" || - environmentThreadState.history.loading === "around") + environmentThreadState.history.loading === "around" || + environmentThreadState.history.loading === "outline") } isLoadingNextMessages={ environmentThreadState.history.kind === "ready" && environmentThreadState.history.loading === "after" } + isHistoryReady={environmentThreadState.status === "live"} historyOutline={ environmentThreadState.history.kind === "ready" ? environmentThreadState.history.outline @@ -5959,6 +5981,8 @@ function ChatViewContent(props: ChatViewProps) { 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 */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 816ea118275..8af69355761 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -194,10 +194,13 @@ interface MessagesTimelineProps { messageHistory?: OrchestrationThreadMessageHistory; isLoadingPreviousMessages?: boolean; isLoadingNextMessages?: boolean; + isHistoryReady?: boolean; historyOutline?: OrchestrationThreadHistoryOutline | null; historyTargetMessageId?: MessageId | null; onSelectHistoryMessage?: (messageId: MessageId) => void; onHistoryTargetReady?: () => void; + onLoadPreviousMessages?: () => Promise; + onLoadNextMessages?: () => Promise; } // --------------------------------------------------------------------------- @@ -236,10 +239,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ messageHistory, isLoadingPreviousMessages = false, isLoadingNextMessages = false, + isHistoryReady = false, historyOutline = null, historyTargetMessageId = null, onSelectHistoryMessage, onHistoryTargetReady, + onLoadPreviousMessages, + onLoadNextMessages, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -357,6 +363,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const progressiveHistory = useProgressiveTimelineHistory({ historyOutline, historyTargetMessageId, + isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, listRef, @@ -365,6 +372,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ minimapStripMap, onHistoryTargetReady, onIsAtEndChange, + onLoadNextMessages, + onLoadPreviousMessages, onManualNavigation, onSelectHistoryMessage, rows, @@ -574,6 +583,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
) } + {...(messageHistory === undefined + ? {} + : { ListHeaderComponentStyle: { height: progressiveHistory.historyBeforeSize } })} ListFooterComponent={ messageHistory !== undefined ? (
; @@ -71,6 +77,8 @@ interface UseProgressiveTimelineHistoryInput { readonly minimapStripMap: Map; readonly onHistoryTargetReady: (() => void) | undefined; readonly onIsAtEndChange: (isAtEnd: boolean) => void; + readonly onLoadNextMessages: (() => Promise) | undefined; + readonly onLoadPreviousMessages: (() => Promise) | undefined; readonly onManualNavigation: () => void; readonly onSelectHistoryMessage: ((messageId: MessageId) => void) | undefined; readonly rows: ReadonlyArray; @@ -80,6 +88,7 @@ interface UseProgressiveTimelineHistoryInput { export function useProgressiveTimelineHistory({ historyOutline, historyTargetMessageId, + isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, listRef, @@ -88,6 +97,8 @@ export function useProgressiveTimelineHistory({ minimapStripMap, onHistoryTargetReady, onIsAtEndChange, + onLoadNextMessages, + onLoadPreviousMessages, onManualNavigation, onSelectHistoryMessage, rows, @@ -99,10 +110,13 @@ export function useProgressiveTimelineHistory({ const programmaticScrollRef = useRef<"seeking" | "aligning" | null>(null); const expectedScrollTopRef = useRef(null); const pointerActiveRef = useRef(false); + const userNavigationRef = useRef(false); + const userNavigationResetFrameRef = useRef(null); const loadTimeoutRef = useRef(null); const lastLoadAtRef = useRef(0); const reconcileTimeoutRef = useRef(null); const reconcileAnchorRef = useRef<() => void>(() => {}); + const adjacentHistoryRequestRef = useRef(null); const alignmentCleanupRef = useRef<(() => void) | null>(null); const historyBeforeSpacerRef = useRef(null); const historyAfterSpacerRef = useRef(null); @@ -111,16 +125,17 @@ export function useProgressiveTimelineHistory({ const historyInitializedRef = useRef(false); const [listHeaderSize, setListHeaderSize] = useState(0); const [layoutMeasurement, setLayoutMeasurement] = useState(null); - const loadedMessageIds = useMemo( - () => - rows.flatMap((row) => { - if (row.kind === "message") { - return [row.message.id]; - } - return []; - }), - [rows], - ); + const messageRowIndexById = useMemo(() => { + const rowIndexByMessageId = new Map(); + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (row?.kind === "message") { + rowIndexByMessageId.set(row.message.id, rowIndex); + } + } + return rowIndexByMessageId; + }, [rows]); + const loadedMessageIds = useMemo(() => [...messageRowIndexById.keys()], [messageRowIndexById]); const historyWindowKey = messageHistory === undefined @@ -176,6 +191,52 @@ export function useProgressiveTimelineHistory({ }; }, [historyBeforeSize, listRef, loadedSize, messageHistory]); + const captureRenderedMessageAnchor = useCallback(() => { + if (timelineViewportElement === null) { + return false; + } + const viewportRect = timelineViewportElement.getBoundingClientRect(); + const viewportCenter = viewportRect.top + viewportRect.height / 2; + let closest: + | { + readonly messageId: MessageId; + readonly viewportOffset: number; + readonly distance: number; + } + | undefined; + for (const messageId of loadedMessageIds) { + const element = timelineViewportElement.querySelector( + `[data-message-id="${CSS.escape(messageId)}"]`, + ); + if (element === null) { + continue; + } + const rect = element.getBoundingClientRect(); + if (rect.bottom <= viewportRect.top || rect.top >= viewportRect.bottom) { + continue; + } + const distance = Math.abs(rect.top + rect.height / 2 - viewportCenter); + if (closest === undefined || distance < closest.distance) { + closest = { + messageId, + viewportOffset: rect.top - viewportRect.top, + distance, + }; + } + } + if (closest === undefined) { + return false; + } + pendingRequestRef.current = null; + resolvedLogicalAnchorRef.current = null; + anchorRef.current = { + kind: "message", + messageId: closest.messageId, + viewportOffset: closest.viewportOffset, + }; + return true; + }, [loadedMessageIds, timelineViewportElement]); + const queueReconciliation = useCallback(() => { if (reconcileTimeoutRef.current !== null) { return; @@ -319,7 +380,7 @@ export function useProgressiveTimelineHistory({ `[data-message-id="${CSS.escape(anchor.messageId)}"]`, ); if (target === null) { - const targetRowIndex = minimapItems.find((item) => item.id === anchor.messageId)?.rowIndex; + const targetRowIndex = messageRowIndexById.get(anchor.messageId); const targetPosition = targetRowIndex === null || targetRowIndex === undefined ? undefined @@ -403,6 +464,7 @@ export function useProgressiveTimelineHistory({ historyTargetMessageId, listHeaderSize, listRef, + messageRowIndexById, messageHistory, minimapItems, onHistoryTargetReady, @@ -554,6 +616,16 @@ export function useProgressiveTimelineHistory({ const beginUserNavigation = useCallback(() => { releaseScrollControl(); + userNavigationRef.current = true; + if (userNavigationResetFrameRef.current !== null) { + window.cancelAnimationFrame(userNavigationResetFrameRef.current); + } + userNavigationResetFrameRef.current = window.requestAnimationFrame(() => { + userNavigationResetFrameRef.current = null; + if (!pointerActiveRef.current) { + userNavigationRef.current = false; + } + }); onManualNavigation(); }, [onManualNavigation, releaseScrollControl]); @@ -588,12 +660,77 @@ export function useProgressiveTimelineHistory({ messageHistory !== undefined && historyInitializedRef.current && programmaticScrollRef.current === null && - !isExpectedScroll + !isExpectedScroll && + (userNavigationRef.current || pointerActiveRef.current) ) { - releaseScrollControl(); captureLogicalAnchor(); onManualNavigation(); scheduleHistoryLoad(); + if (!pointerActiveRef.current) { + userNavigationRef.current = false; + } + } + + if ( + messageHistory !== undefined && + isHistoryReady && + historyWindowKey !== null && + scrollTop < historyBeforeSize && + scrollBottom >= historyBeforeSize && + messageHistory.hasMoreBefore && + onLoadPreviousMessages !== undefined && + !isLoadingPreviousMessages && + (adjacentHistoryRequestRef.current?.direction !== "before" || + adjacentHistoryRequestRef.current.windowKey !== historyWindowKey) + ) { + captureRenderedMessageAnchor(); + const request: AdjacentHistoryRequest = { + direction: "before", + windowKey: historyWindowKey, + }; + adjacentHistoryRequestRef.current = request; + void onLoadPreviousMessages().then((loaded) => { + const currentRequest = adjacentHistoryRequestRef.current; + if ( + loaded || + currentRequest?.direction !== request.direction || + currentRequest.windowKey !== request.windowKey + ) { + return; + } + adjacentHistoryRequestRef.current = null; + }); + } + const loadedHistoryEnd = historyBeforeSize + loadedSize; + if ( + messageHistory !== undefined && + isHistoryReady && + historyWindowKey !== null && + scrollTop < loadedHistoryEnd && + scrollBottom >= loadedHistoryEnd && + messageHistory.hasMoreAfter && + onLoadNextMessages !== undefined && + !isLoadingNextMessages && + (adjacentHistoryRequestRef.current?.direction !== "after" || + adjacentHistoryRequestRef.current.windowKey !== historyWindowKey) + ) { + captureRenderedMessageAnchor(); + const request: AdjacentHistoryRequest = { + direction: "after", + windowKey: historyWindowKey, + }; + adjacentHistoryRequestRef.current = request; + void onLoadNextMessages().then((loaded) => { + const currentRequest = adjacentHistoryRequestRef.current; + if ( + loaded || + currentRequest?.direction !== request.direction || + currentRequest.windowKey !== request.windowKey + ) { + return; + } + adjacentHistoryRequestRef.current = null; + }); } for (const item of minimapItems) { @@ -617,17 +754,29 @@ export function useProgressiveTimelineHistory({ } }, [ historyBeforeSize, + historyWindowKey, + isHistoryReady, + isLoadingNextMessages, + isLoadingPreviousMessages, listRef, + loadedSize, messageHistory, minimapItems, minimapStripMap, + onLoadNextMessages, + onLoadPreviousMessages, onManualNavigation, onIsAtEndChange, positionHistorySkeletons, - releaseScrollControl, + captureRenderedMessageAnchor, scheduleHistoryLoad, ]); + useEffect(() => { + const frame = window.requestAnimationFrame(handleScroll); + return () => window.cancelAnimationFrame(frame); + }, [handleScroll]); + const beginScrollbarPointerNavigation = useCallback( (event: globalThis.PointerEvent | PointerEvent) => { const scrollNode = listRef.current?.getScrollableNode(); @@ -686,6 +835,7 @@ export function useProgressiveTimelineHistory({ } clearAlignmentWait(); + userNavigationRef.current = false; expectedScrollTopRef.current = null; if (loadTimeoutRef.current !== null) { window.clearTimeout(loadTimeoutRef.current); @@ -946,6 +1096,10 @@ export function useProgressiveTimelineHistory({ window.clearTimeout(reconcileTimeoutRef.current); reconcileTimeoutRef.current = null; } + if (userNavigationResetFrameRef.current !== null) { + window.cancelAnimationFrame(userNavigationResetFrameRef.current); + userNavigationResetFrameRef.current = null; + } }, [clearAlignmentWait], ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 22ce60975de..6b3753967e0 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -500,7 +500,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make current.history.loading !== null || Option.isNone(currentLiveThread) ) { - return; + return false; } const sourceHistory = current.history.window?.messageHistory ?? currentLiveThread.value.messageHistory; @@ -511,7 +511,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make cursor === null || cursor === undefined ) { - return; + return false; } const historyLookup = yield* historyCache @@ -571,14 +571,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: Option.some(displayThreadHistory(currentLiveThread.value, history)), history, }); - return; + return true; } yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "before" }, }); - yield* Effect.gen(function* () { + return yield* Effect.gen(function* () { const prepared = yield* preparedConnection; const page = yield* snapshotLoader.loadPreviousMessages( prepared, @@ -587,7 +587,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make historyLookup.requestLimit, ); if (Option.isNone(page)) { - return; + return false; } const latest = yield* SubscriptionRef.get(state); @@ -597,7 +597,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make latest.history.loading !== "before" || Option.isNone(latestLiveThread) ) { - return; + return false; } const liveMessages = latestLiveThread.value.messages; const firstLiveMessage = liveMessages[0]; @@ -638,6 +638,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make history, }); yield* cacheHistoryPage(page.value); + return true; }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => @@ -665,7 +666,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make lastMessage === undefined || Option.isNone(currentLiveThread) ) { - return; + return false; } const historyLookup = yield* historyCache @@ -703,14 +704,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make data: Option.some(displayThreadHistory(currentLiveThread.value, history)), history, }); - return; + return true; } yield* SubscriptionRef.set(state, { ...current, history: { ...current.history, loading: "after" }, }); - yield* Effect.gen(function* () { + return yield* Effect.gen(function* () { const prepared = yield* preparedConnection; const page = yield* snapshotLoader.loadNextMessages( prepared, @@ -722,7 +723,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make historyLookup.requestLimit, ); if (Option.isNone(page)) { - return; + return false; } const latest = yield* SubscriptionRef.get(state); @@ -733,7 +734,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make latest.history.window === null || Option.isNone(latestLiveThread) ) { - return; + return false; } const nextWindow = boundThreadHistoryPage( mergeThreadHistoryPages({ @@ -752,6 +753,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make history, }); yield* cacheHistoryPage(page.value); + return true; }).pipe( Effect.ensuring( SubscriptionRef.update(state, (latest) => @@ -1115,8 +1117,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make type EnvironmentThreadStateSubscription = SubscriptionRef.SubscriptionRef & { - readonly loadPreviousMessages: Effect.Effect; - readonly loadNextMessages: Effect.Effect; + readonly loadPreviousMessages: Effect.Effect; + readonly loadNextMessages: Effect.Effect; readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; }; @@ -1185,7 +1187,7 @@ export function createEnvironmentThreadStateAtoms( threadId: input.threadId, }), ); - return subscription?.loadPreviousMessages ?? Effect.void; + return subscription?.loadPreviousMessages ?? Effect.succeed(false); }), ), scheduler, @@ -1205,7 +1207,7 @@ export function createEnvironmentThreadStateAtoms( threadId: input.threadId, }), ); - return subscription?.loadNextMessages ?? Effect.void; + return subscription?.loadNextMessages ?? Effect.succeed(false); }), ), scheduler, From 797aac24094e49067f7820f7cb08db7a5877d079 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 10:11:20 +0300 Subject: [PATCH 08/31] fix scroll to end for progressive history --- FORK.md | 1 + apps/web/src/components/ChatView.tsx | 68 ++++++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 3 + .../chat/useProgressiveTimelineHistory.ts | 13 ++++ packages/client-runtime/src/state/threads.ts | 51 ++++++++++++++ 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/FORK.md b/FORK.md index a148f0ab964..8e6a751124e 100644 --- a/FORK.md +++ b/FORK.md @@ -72,6 +72,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork share one fixed per-message scroll axis and load the nearest landmark window through a trailing throttle. When a viewport straddles the active segment boundary, the client extends that segment with the adjacent page and anchors a rendered message while the virtual spacer is replaced. + Scroll-to-end leaves historical navigation state and returns directly to the bounded live tail. Unloaded ranges keep their fixed virtual size while the active segment contributes its measured height. Segment changes use Legend List's data version instead of manually clearing its layout caches. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 943a0c62575..40e11c63b36 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1154,6 +1154,9 @@ function ChatViewContent(props: ChatViewProps) { const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { reportFailure: false, }); + const showLatestMessages = useAtomCommand(environmentThreads.showLatestMessages, { + reportFailure: false, + }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1253,6 +1256,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); @@ -3535,16 +3539,59 @@ 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?.hasMoreAfter !== true) { + void legendListRef.current?.scrollToEnd?.({ animated }); + return; + } + + setLatestMessagesRequest((request) => request + 1); + 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; + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (liveFollowUserScrollGenerationRef.current !== userScrollGeneration) { + return; + } + void legendListRef.current?.scrollToEnd?.({ animated: false }); + }); + }); + }); + }, + [activeThread?.messageHistory?.hasMoreAfter, environmentId, showLatestMessages, threadId], + ); useEffect(() => { let removeListeners: (() => void) | null = null; const frame = requestAnimationFrame(() => { @@ -5972,6 +6019,7 @@ function ChatViewContent(props: ChatViewProps) { environmentThreadState.history.kind === "ready" && environmentThreadState.history.loading === "after" } + latestMessagesRequest={latestMessagesRequest} isHistoryReady={environmentThreadState.status === "live"} historyOutline={ environmentThreadState.history.kind === "ready" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8af69355761..e12c962e955 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -194,6 +194,7 @@ interface MessagesTimelineProps { messageHistory?: OrchestrationThreadMessageHistory; isLoadingPreviousMessages?: boolean; isLoadingNextMessages?: boolean; + latestMessagesRequest?: number; isHistoryReady?: boolean; historyOutline?: OrchestrationThreadHistoryOutline | null; historyTargetMessageId?: MessageId | null; @@ -239,6 +240,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ messageHistory, isLoadingPreviousMessages = false, isLoadingNextMessages = false, + latestMessagesRequest = 0, isHistoryReady = false, historyOutline = null, historyTargetMessageId = null, @@ -366,6 +368,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, + latestMessagesRequest, listRef, messageHistory, minimapItems, diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts index 2cd07057fff..7076e1f3cc4 100644 --- a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -71,6 +71,7 @@ interface UseProgressiveTimelineHistoryInput { readonly isHistoryReady: boolean; readonly isLoadingNextMessages: boolean; readonly isLoadingPreviousMessages: boolean; + readonly latestMessagesRequest: number; readonly listRef: RefObject; readonly messageHistory: OrchestrationThreadMessageHistory | undefined; readonly minimapItems: ReadonlyArray; @@ -91,6 +92,7 @@ export function useProgressiveTimelineHistory({ isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, + latestMessagesRequest, listRef, messageHistory, minimapItems, @@ -123,6 +125,7 @@ export function useProgressiveTimelineHistory({ const historyBeforeSkeletonsRef = useRef(null); const historyAfterSkeletonsRef = useRef(null); const historyInitializedRef = useRef(false); + const handledLatestMessagesRequestRef = useRef(latestMessagesRequest); const [listHeaderSize, setListHeaderSize] = useState(0); const [layoutMeasurement, setLayoutMeasurement] = useState(null); const messageRowIndexById = useMemo(() => { @@ -629,6 +632,16 @@ export function useProgressiveTimelineHistory({ onManualNavigation(); }, [onManualNavigation, releaseScrollControl]); + useLayoutEffect(() => { + if (handledLatestMessagesRequestRef.current === latestMessagesRequest) { + return; + } + handledLatestMessagesRequestRef.current = latestMessagesRequest; + releaseScrollControl(); + adjacentHistoryRequestRef.current = null; + historyInitializedRef.current = false; + }, [latestMessagesRequest, releaseScrollControl]); + const handleListMetricsChange = useCallback(({ headerSize }: LegendListMetrics) => { setListHeaderSize((current) => (Math.abs(current - headerSize) <= 1 ? current : headerSize)); }, []); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 6b3753967e0..0f9ba71d497 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -492,7 +492,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + let latestHistoryWindowRequestId = 0; const loadPreviousMessages = Effect.gen(function* () { + const requestId = ++latestHistoryWindowRequestId; const current = yield* SubscriptionRef.get(state); const currentLiveThread = yield* Ref.get(liveThread); if ( @@ -533,6 +535,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ); if (Option.isSome(historyLookup.page)) { + if (requestId !== latestHistoryWindowRequestId) { + return false; + } const liveMessages = currentLiveThread.value.messages; const firstLiveMessage = liveMessages[0]; const currentWindow = current.history.window ?? { @@ -593,6 +598,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make 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) @@ -654,6 +660,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }); const loadNextMessages = Effect.gen(function* () { + const requestId = ++latestHistoryWindowRequestId; const current = yield* SubscriptionRef.get(state); const currentLiveThread = yield* Ref.get(liveThread); const window = current.history.kind === "ready" ? current.history.window : null; @@ -688,6 +695,9 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }), ); if (Option.isSome(historyLookup.page)) { + if (requestId !== latestHistoryWindowRequestId) { + return false; + } const nextWindow = boundThreadHistoryPage( mergeThreadHistoryPages({ older: window, @@ -729,6 +739,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make 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 || @@ -860,6 +871,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + 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: current.history.loading === "outline" ? "outline" : 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()); @@ -1112,6 +1144,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make loadPreviousMessages, loadNextMessages, loadMessagesAround, + showLatestMessages, }); }); @@ -1120,6 +1153,7 @@ type EnvironmentThreadStateSubscription = readonly loadPreviousMessages: Effect.Effect; readonly loadNextMessages: Effect.Effect; readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; + readonly showLatestMessages: Effect.Effect; }; export function threadStateChanges( @@ -1216,6 +1250,23 @@ export function createEnvironmentThreadStateAtoms( 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" }, + }), loadMessagesAround: createEnvironmentCommand(runtime, { label: "environment-data:thread:load-messages-around", execute: (input: { readonly threadId: ThreadIdType; readonly messageId: MessageId }) => From f6f614b6b141bfae961cf8ad2316dfdd2257755d Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 10:33:57 +0300 Subject: [PATCH 09/31] fix delayed progressive history loading --- FORK.md | 2 +- .../chat/useProgressiveTimelineHistory.ts | 114 ++++++++++-------- 2 files changed, 62 insertions(+), 54 deletions(-) diff --git a/FORK.md b/FORK.md index 8e6a751124e..b3430df7d68 100644 --- a/FORK.md +++ b/FORK.md @@ -75,7 +75,7 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork Scroll-to-end leaves historical navigation state and returns directly to the bounded live tail. Unloaded ranges keep their fixed virtual size while the active segment contributes its measured height. Segment changes use Legend List's data version instead of manually clearing its layout - caches. + caches, then reprocess the current offset after the new header geometry settles. - 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. diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts index 7076e1f3cc4..36de96df0ff 100644 --- a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -20,6 +20,8 @@ const VIRTUAL_MESSAGE_SIZE = 120; const HISTORY_LOAD_THROTTLE_MS = 180; const HISTORY_SKELETON_PERIOD = VIRTUAL_MESSAGE_SIZE * 2; const MESSAGE_VIEWPORT_OFFSET = 24; +const USER_NAVIGATION_START_TIMEOUT_MS = 250; +const USER_NAVIGATION_SCROLL_TIMEOUT_MS = 120; type ViewportAnchor = | { @@ -113,7 +115,7 @@ export function useProgressiveTimelineHistory({ const expectedScrollTopRef = useRef(null); const pointerActiveRef = useRef(false); const userNavigationRef = useRef(false); - const userNavigationResetFrameRef = useRef(null); + const userNavigationResetTimeoutRef = useRef(null); const loadTimeoutRef = useRef(null); const lastLoadAtRef = useRef(0); const reconcileTimeoutRef = useRef(null); @@ -126,6 +128,7 @@ export function useProgressiveTimelineHistory({ const historyAfterSkeletonsRef = useRef(null); const historyInitializedRef = useRef(false); const handledLatestMessagesRequestRef = useRef(latestMessagesRequest); + const reprocessedHistoryWindowRef = useRef(null); const [listHeaderSize, setListHeaderSize] = useState(0); const [layoutMeasurement, setLayoutMeasurement] = useState(null); const messageRowIndexById = useMemo(() => { @@ -349,7 +352,11 @@ export function useProgressiveTimelineHistory({ ); const reconcileAnchor = useCallback(() => { - if (messageHistory === undefined || timelineViewportElement === null) { + if ( + messageHistory === undefined || + historyWindowKey === null || + timelineViewportElement === null + ) { return false; } if (pointerActiveRef.current) { @@ -370,13 +377,27 @@ export function useProgressiveTimelineHistory({ if ( pendingRequest?.kind === "logical" && Math.abs(pendingRequest.anchorMessageIndex - anchor.messageIndex) > 0.5 + ) { + pendingRequestRef.current = null; + onHistoryTargetReady?.(); + return true; + } + if ( + pendingRequest?.kind === "logical" && + !minimapItems.some((item) => item.id === pendingRequest.messageId && item.rowIndex !== null) ) { return false; } - return ( - pendingRequest?.kind !== "logical" || - minimapItems.some((item) => item.id === pendingRequest.messageId && item.rowIndex !== null) - ); + if (pendingRequest?.kind === "logical") { + resolvedLogicalAnchorRef.current = { + anchorMessageIndex: anchor.messageIndex, + messageId: pendingRequest.messageId, + windowKey: historyWindowKey, + }; + pendingRequestRef.current = null; + onHistoryTargetReady?.(); + } + return true; } const target = timelineViewportElement.querySelector( @@ -465,6 +486,7 @@ export function useProgressiveTimelineHistory({ clearAlignmentWait, historyBeforeSize, historyTargetMessageId, + historyWindowKey, listHeaderSize, listRef, messageRowIndexById, @@ -614,21 +636,23 @@ export function useProgressiveTimelineHistory({ anchorRef.current = null; pendingRequestRef.current = null; resolvedLogicalAnchorRef.current = null; + userNavigationRef.current = false; + if (userNavigationResetTimeoutRef.current !== null) { + window.clearTimeout(userNavigationResetTimeoutRef.current); + userNavigationResetTimeoutRef.current = null; + } onHistoryTargetReady?.(); }, [clearAlignmentWait, listRef, onHistoryTargetReady]); const beginUserNavigation = useCallback(() => { releaseScrollControl(); userNavigationRef.current = true; - if (userNavigationResetFrameRef.current !== null) { - window.cancelAnimationFrame(userNavigationResetFrameRef.current); - } - userNavigationResetFrameRef.current = window.requestAnimationFrame(() => { - userNavigationResetFrameRef.current = null; + userNavigationResetTimeoutRef.current = window.setTimeout(() => { + userNavigationResetTimeoutRef.current = null; if (!pointerActiveRef.current) { userNavigationRef.current = false; } - }); + }, USER_NAVIGATION_START_TIMEOUT_MS); onManualNavigation(); }, [onManualNavigation, releaseScrollControl]); @@ -680,7 +704,13 @@ export function useProgressiveTimelineHistory({ onManualNavigation(); scheduleHistoryLoad(); if (!pointerActiveRef.current) { - userNavigationRef.current = false; + if (userNavigationResetTimeoutRef.current !== null) { + window.clearTimeout(userNavigationResetTimeoutRef.current); + } + userNavigationResetTimeoutRef.current = window.setTimeout(() => { + userNavigationResetTimeoutRef.current = null; + userNavigationRef.current = false; + }, USER_NAVIGATION_SCROLL_TIMEOUT_MS); } } @@ -831,6 +861,11 @@ export function useProgressiveTimelineHistory({ captureLogicalAnchor(); scheduleHistoryLoad(); queueReconciliation(); + userNavigationRef.current = false; + if (userNavigationResetTimeoutRef.current !== null) { + window.clearTimeout(userNavigationResetTimeoutRef.current); + userNavigationResetTimeoutRef.current = null; + } }, [captureLogicalAnchor, queueReconciliation, scheduleHistoryLoad]); const selectHistoryTarget = useCallback( @@ -1007,6 +1042,10 @@ export function useProgressiveTimelineHistory({ } scheduleHistoryLoad(); if (Math.abs(listHeaderSize - historyBeforeSize) <= 1) { + if (reprocessedHistoryWindowRef.current !== historyWindowKey) { + reprocessedHistoryWindowRef.current = historyWindowKey; + list.reportContentInset(null); + } queueReconciliation(); } }, [ @@ -1022,45 +1061,14 @@ export function useProgressiveTimelineHistory({ useEffect(() => { if ( - messageHistory === undefined || - historyWindowKey === null || - historyLoadInProgress || - pendingRequestRef.current?.kind !== "logical" + messageHistory !== undefined && + historyWindowKey !== null && + !historyLoadInProgress && + pendingRequestRef.current?.kind === "logical" ) { - return; - } - const anchor = anchorRef.current; - const pendingRequest = pendingRequestRef.current; - if (anchor?.kind !== "logical" || pendingRequest?.kind !== "logical") { - return; - } - if (Math.abs(pendingRequest.anchorMessageIndex - anchor.messageIndex) > 0.5) { - pendingRequestRef.current = null; - onHistoryTargetReady?.(); - return; + queueReconciliation(); } - const timeout = window.setTimeout(() => { - if (!reconcileAnchor()) { - return; - } - resolvedLogicalAnchorRef.current = { - anchorMessageIndex: anchor.messageIndex, - messageId: pendingRequest.messageId, - windowKey: historyWindowKey, - }; - pendingRequestRef.current = null; - onHistoryTargetReady?.(); - scheduleHistoryLoad(); - }, 0); - return () => window.clearTimeout(timeout); - }, [ - historyLoadInProgress, - historyWindowKey, - messageHistory, - onHistoryTargetReady, - reconcileAnchor, - scheduleHistoryLoad, - ]); + }, [historyLoadInProgress, historyWindowKey, messageHistory, queueReconciliation]); useEffect(() => { if (!historyRequestInProgress) { @@ -1109,9 +1117,9 @@ export function useProgressiveTimelineHistory({ window.clearTimeout(reconcileTimeoutRef.current); reconcileTimeoutRef.current = null; } - if (userNavigationResetFrameRef.current !== null) { - window.cancelAnimationFrame(userNavigationResetFrameRef.current); - userNavigationResetFrameRef.current = null; + if (userNavigationResetTimeoutRef.current !== null) { + window.clearTimeout(userNavigationResetTimeoutRef.current); + userNavigationResetTimeoutRef.current = null; } }, [clearAlignmentWait], From b9d3d6b06b8673acf0abc89ae97ffc96288224de Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 13:04:14 +0300 Subject: [PATCH 10/31] make progressive history scrolling deterministic --- FORK.md | 22 +- apps/web/package.json | 1 + apps/web/src/components/ChatView.tsx | 39 +- .../src/components/chat/MessagesTimeline.tsx | 254 +++--- .../progressiveTimelineHistory.logic.test.ts | 262 ++++++ .../chat/progressiveTimelineHistory.logic.ts | 243 +++++ .../chat/useProgressiveTimelineHistory.ts | 857 +++++++----------- packages/client-runtime/src/state/threads.ts | 31 + pnpm-lock.yaml | 20 + 9 files changed, 1075 insertions(+), 654 deletions(-) create mode 100644 apps/web/src/components/chat/progressiveTimelineHistory.logic.test.ts create mode 100644 apps/web/src/components/chat/progressiveTimelineHistory.logic.ts diff --git a/FORK.md b/FORK.md index b3430df7d68..0000676a537 100644 --- a/FORK.md +++ b/FORK.md @@ -70,12 +70,12 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork windows retain every message, cap work telemetry per segment, and only display activity for turns represented by the active message window. Viewport navigation, minimap jumps, and unloaded spacers share one fixed per-message scroll axis and load the nearest landmark window through a trailing - throttle. When a viewport straddles the active segment boundary, the client extends that segment - with the adjacent page and anchors a rendered message while the virtual spacer is replaced. - Scroll-to-end leaves historical navigation state and returns directly to the bounded live tail. - Unloaded ranges keep their fixed virtual size while the active segment contributes its measured - height. Segment changes use Legend List's data version instead of manually clearing its layout - caches, then reprocess the current offset after the new header geometry settles. + throttle. The historical canvas stays fixed while bounded segments change, regardless of their + measured height. History uses one stable native scroll element with an externally-scrolled + virtualizer, so segment changes never replace the scrollbar or render the complete bounded segment. + A loaded segment is placed around the current logical viewport anchor without changing the physical + scroll offset. The live tail uses its real rendered height. Scroll-to-end leaves historical + navigation state and returns directly to that bounded 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. @@ -83,11 +83,11 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork loader locked. Minimap jumps position virtual history immediately while the segment loads, then use the loaded row position for a smooth exact correction. Any keyboard, pointer, touch, or wheel input cancels that motion and removes the concrete target before the browser scrolls. The resulting real - scroll selects the next logical segment without restoring an older viewport position. Holding the - native scrollbar thumb suppresses target alignment but never pauses throttled data loading. - Synchronous offset writes avoid delayed programmatic scrolls taking control back from the user. - Overlapping explicit jumps may fetch concurrently, but only the latest request can replace the - active segment. + scroll selects the next logical segment without restoring an older viewport position. Only actual + manual offset changes schedule history loads. Holding the native scrollbar thumb keeps that + ownership and throttled loading active until release. Synchronous offset writes avoid delayed + programmatic scrolls taking control back from the user. Overlapping explicit jumps may fetch + concurrently, but only the latest request can replace the active segment. - Desktop context-menu style is configurable. - 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/web/package.json b/apps/web/package.json index c345a43351b..c9ad14cd065 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@t3tools/shared": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.14.8", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 40e11c63b36..ccff1abad89 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1148,10 +1148,7 @@ function ChatViewContent(props: ChatViewProps) { const loadMessagesAround = useAtomCommand(environmentThreads.loadMessagesAround, { reportFailure: false, }); - const loadPreviousMessages = useAtomCommand(environmentThreads.loadPreviousMessages, { - reportFailure: false, - }); - const loadNextMessages = useAtomCommand(environmentThreads.loadNextMessages, { + const cancelMessagesAround = useAtomCommand(environmentThreads.cancelMessagesAround, { reportFailure: false, }); const showLatestMessages = useAtomCommand(environmentThreads.showLatestMessages, { @@ -1460,20 +1457,6 @@ function ChatViewContent(props: ChatViewProps) { 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 ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -3482,6 +3465,21 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } }, []); + const handleTimelineManualNavigation = useCallback(() => { + cancelTimelineLiveFollowForUserNavigation(); + if (environmentThreadState.history.kind === "ready") { + void cancelMessagesAround({ + environmentId, + input: { threadId }, + }); + } + }, [ + cancelMessagesAround, + cancelTimelineLiveFollowForUserNavigation, + environmentId, + environmentThreadState.history.kind, + threadId, + ]); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, ); @@ -6003,7 +6001,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onManualNavigation={handleTimelineManualNavigation} hideEmptyPlaceholder={isDraftHeroState} topFadeEnabled={!hasTimelineTopBanner} {...(activeThread.messageHistory === undefined @@ -6020,7 +6018,6 @@ function ChatViewContent(props: ChatViewProps) { environmentThreadState.history.loading === "after" } latestMessagesRequest={latestMessagesRequest} - isHistoryReady={environmentThreadState.status === "live"} historyOutline={ environmentThreadState.history.kind === "ready" ? environmentThreadState.history.outline @@ -6029,8 +6026,6 @@ function ChatViewContent(props: ChatViewProps) { 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 */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e12c962e955..58f6b2dcdb6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -28,6 +28,7 @@ import { import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; +import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, @@ -112,10 +113,8 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -import { - useProgressiveTimelineHistory, - type TimelineHistoryNavigationTarget, -} from "./useProgressiveTimelineHistory"; +import { type TimelineHistoryNavigationTarget } from "./progressiveTimelineHistory.logic"; +import { useProgressiveTimelineHistory } from "./useProgressiveTimelineHistory"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -195,13 +194,10 @@ interface MessagesTimelineProps { isLoadingPreviousMessages?: boolean; isLoadingNextMessages?: boolean; latestMessagesRequest?: number; - isHistoryReady?: boolean; historyOutline?: OrchestrationThreadHistoryOutline | null; historyTargetMessageId?: MessageId | null; onSelectHistoryMessage?: (messageId: MessageId) => void; onHistoryTargetReady?: () => void; - onLoadPreviousMessages?: () => Promise; - onLoadNextMessages?: () => Promise; } // --------------------------------------------------------------------------- @@ -241,13 +237,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isLoadingPreviousMessages = false, isLoadingNextMessages = false, latestMessagesRequest = 0, - isHistoryReady = false, historyOutline = null, historyTargetMessageId = null, onSelectHistoryMessage, onHistoryTargetReady, - onLoadPreviousMessages, - onLoadNextMessages, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -360,12 +353,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); + const [historyScrollElement, setHistoryScrollElement] = useState(null); + const historyVirtualizerRef = useRef | null>(null); + const handleHistoryScrollToOffset = useCallback( + (offset: number, behavior: ScrollBehavior) => + historyVirtualizerRef.current?.scrollToOffset(offset, { behavior }), + [], + ); + const [historyLayoutMeasurement, setHistoryLayoutMeasurement] = useState<{ + readonly windowKey: string; + readonly loadedSize: number; + } | null>(null); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); + const historyWindowKey = + messageHistory === undefined + ? null + : `${messageHistory.startIndex}:${messageHistory.endIndex}:${messageHistory.totalMessages}`; const progressiveHistory = useProgressiveTimelineHistory({ historyOutline, + historyLayoutMeasurement, + historyScrollElement, historyTargetMessageId, - isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, latestMessagesRequest, @@ -374,14 +383,48 @@ export const MessagesTimeline = memo(function MessagesTimeline({ minimapItems, minimapStripMap, onHistoryTargetReady, + onHistoryScrollToOffset: handleHistoryScrollToOffset, onIsAtEndChange, - onLoadNextMessages, - onLoadPreviousMessages, onManualNavigation, onSelectHistoryMessage, rows, timelineViewportElement, }); + const historyVirtualizer = useVirtualizer({ + count: messageHistory === undefined ? 0 : rows.length, + enabled: messageHistory !== undefined, + estimateSize: () => 90, + getItemKey: (index) => rows[index]!.id, + getScrollElement: () => historyScrollElement, + overscan: 5, + paddingEnd: progressiveHistory.historyAfterSize + contentInsetEndAdjustment, + paddingStart: progressiveHistory.historyBeforeSize, + useAnimationFrameWithResizeObserver: true, + }); + historyVirtualizerRef.current = historyVirtualizer; + historyVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; + const measuredHistoryLoadedSize = Math.max( + 0, + historyVirtualizer.getTotalSize() - + progressiveHistory.historyBeforeSize - + progressiveHistory.historyAfterSize - + contentInsetEndAdjustment, + ); + useLayoutEffect(() => { + if (historyWindowKey === null) { + setHistoryLayoutMeasurement(null); + return; + } + setHistoryLayoutMeasurement((current) => + current?.windowKey === historyWindowKey && + Math.abs(current.loadedSize - measuredHistoryLoadedSize) <= 1 + ? current + : { + windowKey: historyWindowKey, + loadedSize: measuredHistoryLoadedSize, + }, + ); + }, [historyWindowKey, measuredHistoryLoadedSize]); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -506,70 +549,82 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onTouchMoveCapture={progressiveHistory.beginUserNavigation} onWheelCapture={progressiveHistory.beginUserNavigation} > - - ref={listRef} - data={rows} - {...(messageHistory === undefined - ? {} - : { - dataVersion: `${messageHistory.startIndex}:${messageHistory.endIndex}:${Math.round(progressiveHistory.historyBeforeSize)}`, - })} - keyExtractor={keyExtractor} - getItemType={getItemType} - renderItem={renderItem} - estimatedItemSize={90} - estimatedHeaderSize={ - progressiveHistory.historyBeforeSize > 0 - ? progressiveHistory.historyBeforeSize - : topFadeEnabled - ? 48 - : 16 - } - initialScrollAtEnd={messageHistory === undefined} - {...(messageHistory === undefined - ? {} - : { initialScrollOffset: progressiveHistory.historyBeforeSize })} - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} - contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={ - anchoredEndSpace || messageHistory?.hasMoreAfter === true - ? false - : { - animated: false, - on: { - dataChange: true, - itemLayout: true, - layout: true, - }, - } - } - maintainVisibleContentPosition={ - messageHistory === undefined && historyTargetMessageId === null - ? { - data: true, - size: true, - } - : false - } - onScroll={progressiveHistory.handleScroll} - onMetricsChange={progressiveHistory.onListMetricsChange} - {...(messageHistory === undefined - ? {} - : { - contentContainerStyle: { - height: progressiveHistory.contentHeight + contentInsetEndAdjustment, - overflow: "hidden", - }, - })} - 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={ - messageHistory !== undefined ? ( + {messageHistory === undefined ? ( + + ref={listRef} + data={rows} + keyExtractor={keyExtractor} + getItemType={getItemType} + renderItem={renderItem} + estimatedItemSize={90} + estimatedHeaderSize={topFadeEnabled ? 48 : 16} + initialScrollAtEnd + {...(anchoredEndSpace ? { anchoredEndSpace } : {})} + contentInsetEndAdjustment={contentInsetEndAdjustment} + maintainScrollAtEnd={ + anchoredEndSpace + ? false + : { + animated: false, + on: { + dataChange: true, + itemLayout: true, + layout: true, + }, + } + } + maintainVisibleContentPosition={ + historyTargetMessageId === null + ? { + data: true, + size: false, + } + : false + } + 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={ + isLoadingPreviousMessages ? ( +
+ + Loading earlier messages... + +
+ ) : ( +
+ ) + } + ListFooterComponent={ + isLoadingNextMessages ? ( +
+ Loading newer messages... +
+ ) : ( + TIMELINE_LIST_FOOTER + ) + } + /> + ) : ( +
+
{progressiveHistory.virtualHistoryBeforeSize > 0 ? ( @@ -578,23 +633,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ /> ) : null}
- ) : isLoadingPreviousMessages ? ( -
- Loading earlier messages... -
- ) : ( -
- ) - } - {...(messageHistory === undefined - ? {} - : { ListHeaderComponentStyle: { height: progressiveHistory.historyBeforeSize } })} - ListFooterComponent={ - messageHistory !== undefined ? ( + {historyVirtualizer.getVirtualItems().map((virtualRow) => { + const item = rows[virtualRow.index]!; + return ( +
+ {renderItem({ item })} +
+ ); + })}
{progressiveHistory.virtualHistoryAfterSize > 0 ? ( ) : null}
- ) : isLoadingNextMessages ? ( -
- Loading newer messages... -
- ) : ( - TIMELINE_LIST_FOOTER - ) - } - {...(messageHistory === undefined - ? {} - : { ListFooterComponentStyle: { height: progressiveHistory.historyAfterSize } })} - /> +
+
+ )} { + it("maps unloaded and loaded viewport positions onto one logical message axis", () => { + const input = { + historyAfterSize: 192_000, + historyBeforeSize: 24_000, + loadedSize: 20_000, + messageHistory, + scrollLength: 600, + }; + + expect(resolveProgressiveTimelineMessageIndex({ ...input, scrollTop: 12_000 })).toBeCloseTo( + 51.25, + ); + expect(resolveProgressiveTimelineMessageIndex({ ...input, scrollTop: 34_000 })).toBeCloseTo( + 151.5, + ); + expect(resolveProgressiveTimelineMessageIndex({ ...input, scrollTop: 50_000 })).toBeCloseTo( + 226.25, + ); + }); + + it("keeps a stable historical canvas and lets the live tail end at real content", () => { + const firstHistoricalWindow = resolveProgressiveTimelineLayout({ + anchor: null, + loadedSize: 18_000, + messageHistory, + preserveHistoricalCanvas: true, + }); + const nextHistoricalWindow = resolveProgressiveTimelineLayout({ + anchor: null, + loadedSize: 12_000, + messageHistory: { + ...messageHistory, + startIndex: 500, + endIndex: 600, + }, + preserveHistoricalCanvas: true, + }); + + expect(firstHistoricalWindow).toEqual({ + contentSize: 240_000, + historyAfterSize: 192_000, + historyBeforeSize: 24_000, + virtualHistoryAfterSize: 192_000, + virtualHistoryBeforeSize: 24_000, + }); + expect(nextHistoricalWindow.contentSize).toBe(firstHistoricalWindow.contentSize); + expect( + resolveProgressiveTimelineLayout({ + anchor: { + messageIndex: 550, + viewportPosition: 120_000, + }, + loadedSize: 12_000, + messageHistory: { + ...messageHistory, + startIndex: 500, + endIndex: 600, + }, + preserveHistoricalCanvas: true, + }), + ).toEqual({ + contentSize: 240_000, + historyAfterSize: 114_000, + historyBeforeSize: 114_000, + virtualHistoryAfterSize: 96_000, + virtualHistoryBeforeSize: 120_000, + }); + expect( + resolveProgressiveTimelineLayout({ + anchor: null, + loadedSize: 18_000, + messageHistory: { + ...messageHistory, + startIndex: 900, + endIndex: 1_000, + hasMoreAfter: false, + }, + preserveHistoricalCanvas: false, + }), + ).toEqual({ + contentSize: 234_000, + historyAfterSize: 0, + historyBeforeSize: 216_000, + virtualHistoryAfterSize: 0, + virtualHistoryBeforeSize: 216_000, + }); + expect( + resolveProgressiveTimelineLayout({ + anchor: null, + loadedSize: 30_000, + messageHistory: { + ...messageHistory, + startIndex: 900, + endIndex: 950, + }, + preserveHistoricalCanvas: true, + }), + ).toEqual({ + contentSize: 258_000, + historyAfterSize: 12_000, + historyBeforeSize: 216_000, + virtualHistoryAfterSize: 12_000, + virtualHistoryBeforeSize: 216_000, + }); + }); + + it("only captures scroll events owned by manual navigation", () => { + expect( + shouldCaptureProgressiveTimelineScroll({ + didScroll: true, + isExpectedScroll: false, + isManualScroll: true, + programmaticScroll: null, + }), + ).toBe(true); + expect( + shouldCaptureProgressiveTimelineScroll({ + didScroll: true, + isExpectedScroll: true, + isManualScroll: true, + programmaticScroll: null, + }), + ).toBe(false); + expect( + shouldCaptureProgressiveTimelineScroll({ + didScroll: true, + isExpectedScroll: false, + isManualScroll: true, + programmaticScroll: "aligning", + }), + ).toBe(false); + expect( + shouldCaptureProgressiveTimelineScroll({ + didScroll: true, + isExpectedScroll: false, + isManualScroll: false, + programmaticScroll: null, + }), + ).toBe(false); + expect( + shouldCaptureProgressiveTimelineScroll({ + didScroll: false, + isExpectedScroll: false, + isManualScroll: true, + programmaticScroll: null, + }), + ).toBe(false); + }); + + it("recognizes only keys that can navigate the timeline", () => { + for (const key of ["PageUp", "PageDown", "Home", "End", "ArrowUp", "ArrowDown", " "]) { + expect(isProgressiveTimelineNavigationKey(key)).toBe(true); + } + expect(isProgressiveTimelineNavigationKey("a")).toBe(false); + expect(isProgressiveTimelineNavigationKey("Enter")).toBe(false); + }); + + it("selects the nearest bounded history landmark", () => { + expect( + resolveProgressiveTimelineHistoryTarget({ + anchorMessageIndex: 540, + historyOutline: outline, + loadedMessageIds: [], + messageHistory, + minimapItems: [], + }), + ).toBe(MessageId.make("landmark-500")); + }); + + it("selects a landmark beyond the active window when the viewport leaves it", () => { + expect( + resolveProgressiveTimelineHistoryTarget({ + anchorMessageIndex: 300, + historyOutline: outline, + loadedMessageIds: [MessageId.make("landmark-500")], + messageHistory: { + ...messageHistory, + startIndex: 500, + endIndex: 600, + }, + minimapItems: [ + { + id: MessageId.make("landmark-500"), + messageIndex: 500, + rowIndex: 0, + }, + ], + }), + ).toBe(MessageId.make("landmark-100")); + }); + + it("uses the nearest loaded target while the viewport remains inside the active window", () => { + const loadedMessageId = MessageId.make("loaded-160"); + + expect( + resolveProgressiveTimelineHistoryTarget({ + anchorMessageIndex: 160, + historyOutline: outline, + loadedMessageIds: [loadedMessageId], + messageHistory, + minimapItems: [ + { + id: loadedMessageId, + messageIndex: 160, + rowIndex: 4, + }, + ], + }), + ).toBe(loadedMessageId); + }); +}); diff --git a/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts b/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts new file mode 100644 index 00000000000..ddd3abdf033 --- /dev/null +++ b/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts @@ -0,0 +1,243 @@ +import { + type MessageId, + type OrchestrationThreadHistoryOutline, + type OrchestrationThreadMessageHistory, +} from "@t3tools/contracts"; + +export const PROGRESSIVE_TIMELINE_MESSAGE_SIZE = 240; + +export type ProgressiveTimelineProgrammaticScroll = "seeking" | "aligning"; + +export interface TimelineHistoryNavigationTarget { + readonly id: MessageId; + readonly messageIndex: number | null; + readonly rowIndex: number | null; +} + +export interface ProgressiveTimelineLayout { + readonly contentSize: number; + readonly historyAfterSize: number; + readonly historyBeforeSize: number; + readonly virtualHistoryAfterSize: number; + readonly virtualHistoryBeforeSize: number; +} + +export function resolveProgressiveTimelineLayout({ + anchor, + loadedSize, + messageHistory, + preserveHistoricalCanvas, +}: { + readonly anchor: { + readonly messageIndex: number; + readonly viewportPosition: number; + } | null; + readonly loadedSize: number; + readonly messageHistory: OrchestrationThreadMessageHistory; + readonly preserveHistoricalCanvas: boolean; +}): ProgressiveTimelineLayout { + const virtualHistoryBeforeSize = messageHistory.startIndex * PROGRESSIVE_TIMELINE_MESSAGE_SIZE; + const virtualHistoryAfterSize = + Math.max(0, messageHistory.totalMessages - messageHistory.endIndex) * + PROGRESSIVE_TIMELINE_MESSAGE_SIZE; + const renderedContentSize = virtualHistoryBeforeSize + loadedSize + virtualHistoryAfterSize; + const contentSize = preserveHistoricalCanvas + ? Math.max( + messageHistory.totalMessages * PROGRESSIVE_TIMELINE_MESSAGE_SIZE, + renderedContentSize, + ) + : renderedContentSize; + const canPlaceAtAnchor = + preserveHistoricalCanvas && + anchor !== null && + anchor.messageIndex >= messageHistory.startIndex && + anchor.messageIndex < messageHistory.endIndex; + const historyBeforeSize = canPlaceAtAnchor + ? Math.max( + 0, + Math.min( + contentSize - loadedSize, + anchor.viewportPosition - + ((anchor.messageIndex - messageHistory.startIndex) / + Math.max(1, messageHistory.endIndex - messageHistory.startIndex)) * + loadedSize, + ), + ) + : virtualHistoryBeforeSize; + const historyAfterSize = canPlaceAtAnchor + ? Math.max(0, contentSize - historyBeforeSize - loadedSize) + : virtualHistoryAfterSize; + return { + contentSize, + historyAfterSize, + historyBeforeSize, + virtualHistoryAfterSize, + virtualHistoryBeforeSize, + }; +} + +export function resolveProgressiveTimelineMessageIndex({ + historyAfterSize, + historyBeforeSize, + loadedSize, + messageHistory, + scrollLength, + scrollTop, +}: { + readonly historyAfterSize: number; + readonly historyBeforeSize: number; + readonly loadedSize: number; + readonly messageHistory: OrchestrationThreadMessageHistory; + readonly scrollLength: number; + readonly scrollTop: number; +}): number { + const viewportPosition = scrollTop + scrollLength / 2; + const messageIndex = + viewportPosition <= historyBeforeSize + ? (viewportPosition / Math.max(1, historyBeforeSize)) * messageHistory.startIndex + : viewportPosition >= historyBeforeSize + loadedSize + ? messageHistory.endIndex + + ((viewportPosition - historyBeforeSize - loadedSize) / Math.max(1, historyAfterSize)) * + (messageHistory.totalMessages - messageHistory.endIndex) + : messageHistory.startIndex + + ((viewportPosition - historyBeforeSize) / Math.max(1, loadedSize)) * + (messageHistory.endIndex - messageHistory.startIndex); + return Math.max(0, Math.min(messageHistory.totalMessages - 1, messageIndex)); +} + +export function shouldCaptureProgressiveTimelineScroll({ + didScroll, + isExpectedScroll, + isManualScroll, + programmaticScroll, +}: { + readonly didScroll: boolean; + readonly isExpectedScroll: boolean; + readonly isManualScroll: boolean; + readonly programmaticScroll: ProgressiveTimelineProgrammaticScroll | null; +}): boolean { + return didScroll && isManualScroll && programmaticScroll === null && !isExpectedScroll; +} + +export function isProgressiveTimelineNavigationKey(key: string): boolean { + switch (key) { + case "PageUp": + case "PageDown": + case "Home": + case "End": + case "ArrowUp": + case "ArrowDown": + case " ": + return true; + default: + return false; + } +} + +export function resolveProgressiveTimelineHistoryTarget({ + anchorMessageIndex, + historyOutline, + loadedMessageIds, + messageHistory, + minimapItems, +}: { + readonly anchorMessageIndex: number; + readonly historyOutline: OrchestrationThreadHistoryOutline; + readonly loadedMessageIds: ReadonlyArray; + readonly messageHistory: OrchestrationThreadMessageHistory; + readonly minimapItems: ReadonlyArray; +}): MessageId | null { + const [firstLandmark, ...remainingLandmarks] = historyOutline.landmarks; + if (firstLandmark === undefined) { + return null; + } + + let target = firstLandmark; + let targetMessageIndex = + target.messageIndex ?? + (target.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + for (const landmark of remainingLandmarks) { + const landmarkMessageIndex = + landmark.messageIndex ?? + (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + if ( + Math.abs(landmarkMessageIndex - anchorMessageIndex) < + Math.abs(targetMessageIndex - anchorMessageIndex) + ) { + target = landmark; + targetMessageIndex = landmarkMessageIndex; + } + } + + if ( + anchorMessageIndex < messageHistory.startIndex || + anchorMessageIndex >= messageHistory.endIndex + ) { + let boundedTarget: (typeof historyOutline.landmarks)[number] | null = null; + let boundedTargetMessageIndex = 0; + for (const landmark of historyOutline.landmarks) { + const landmarkMessageIndex = + landmark.messageIndex ?? + (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * + Math.max(0, messageHistory.totalMessages - 1); + if ( + (anchorMessageIndex < messageHistory.startIndex && + landmarkMessageIndex >= messageHistory.startIndex) || + (anchorMessageIndex >= messageHistory.endIndex && + landmarkMessageIndex < messageHistory.endIndex) + ) { + continue; + } + if ( + boundedTarget === null || + Math.abs(landmarkMessageIndex - anchorMessageIndex) < + Math.abs(boundedTargetMessageIndex - anchorMessageIndex) + ) { + boundedTarget = landmark; + boundedTargetMessageIndex = landmarkMessageIndex; + } + } + if (boundedTarget !== null) { + target = boundedTarget; + } + } + + if (minimapItems.some((item) => item.id === target.messageId && item.rowIndex !== null)) { + return target.messageId; + } + + if ( + anchorMessageIndex >= messageHistory.startIndex && + anchorMessageIndex < messageHistory.endIndex + ) { + let loadedTarget: TimelineHistoryNavigationTarget | null = null; + let loadedTargetDistance = Number.POSITIVE_INFINITY; + for (const item of minimapItems) { + if (item.rowIndex === null || item.messageIndex === null) { + continue; + } + const distance = Math.abs(item.messageIndex - anchorMessageIndex); + if (distance < loadedTargetDistance) { + loadedTarget = item; + loadedTargetDistance = distance; + } + } + if (loadedTarget !== null) { + return loadedTarget.id; + } + + return ( + loadedMessageIds[ + Math.round( + ((anchorMessageIndex - messageHistory.startIndex) / + Math.max(1, messageHistory.endIndex - messageHistory.startIndex - 1)) * + Math.max(0, loadedMessageIds.length - 1), + ) + ] ?? null + ); + } + + return target.messageId; +} diff --git a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts index 36de96df0ff..043b2673801 100644 --- a/apps/web/src/components/chat/useProgressiveTimelineHistory.ts +++ b/apps/web/src/components/chat/useProgressiveTimelineHistory.ts @@ -3,7 +3,7 @@ import { type OrchestrationThreadHistoryOutline, type OrchestrationThreadMessageHistory, } from "@t3tools/contracts"; -import { type LegendListMetrics, type LegendListRef } from "@legendapp/list/react"; +import { type LegendListRef } from "@legendapp/list/react"; import { useCallback, useEffect, @@ -11,17 +11,25 @@ import { useMemo, useRef, useState, - type PointerEvent, type RefObject, } from "react"; import { resolveTimelineIsAtEnd, type MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { + isProgressiveTimelineNavigationKey, + PROGRESSIVE_TIMELINE_MESSAGE_SIZE, + resolveProgressiveTimelineHistoryTarget, + resolveProgressiveTimelineLayout, + resolveProgressiveTimelineMessageIndex, + shouldCaptureProgressiveTimelineScroll, + type ProgressiveTimelineProgrammaticScroll, + type TimelineHistoryNavigationTarget, +} from "./progressiveTimelineHistory.logic"; -const VIRTUAL_MESSAGE_SIZE = 120; const HISTORY_LOAD_THROTTLE_MS = 180; -const HISTORY_SKELETON_PERIOD = VIRTUAL_MESSAGE_SIZE * 2; +const HISTORY_SKELETON_PERIOD = PROGRESSIVE_TIMELINE_MESSAGE_SIZE * 2; +const INITIAL_TAIL_POSITION_DELAY_MS = 200; +const MANUAL_SCROLL_END_DELAY_MS = 160; const MESSAGE_VIEWPORT_OFFSET = 24; -const USER_NAVIGATION_START_TIMEOUT_MS = 250; -const USER_NAVIGATION_SCROLL_TIMEOUT_MS = 120; type ViewportAnchor = | { @@ -31,6 +39,7 @@ type ViewportAnchor = | { readonly kind: "message"; readonly messageId: MessageId; + readonly messageIndex: number | null; readonly viewportOffset: number; }; @@ -56,21 +65,11 @@ interface ResolvedLogicalAnchor { readonly windowKey: string; } -interface AdjacentHistoryRequest { - readonly direction: "before" | "after"; - readonly windowKey: string; -} - -export interface TimelineHistoryNavigationTarget { - readonly id: MessageId; - readonly messageIndex: number | null; - readonly rowIndex: number | null; -} - interface UseProgressiveTimelineHistoryInput { readonly historyOutline: OrchestrationThreadHistoryOutline | null; + readonly historyLayoutMeasurement: HistoryLayoutMeasurement | null; + readonly historyScrollElement: HTMLDivElement | null; readonly historyTargetMessageId: MessageId | null; - readonly isHistoryReady: boolean; readonly isLoadingNextMessages: boolean; readonly isLoadingPreviousMessages: boolean; readonly latestMessagesRequest: number; @@ -79,9 +78,8 @@ interface UseProgressiveTimelineHistoryInput { readonly minimapItems: ReadonlyArray; readonly minimapStripMap: Map; readonly onHistoryTargetReady: (() => void) | undefined; + readonly onHistoryScrollToOffset: (offset: number, behavior: ScrollBehavior) => void; readonly onIsAtEndChange: (isAtEnd: boolean) => void; - readonly onLoadNextMessages: (() => Promise) | undefined; - readonly onLoadPreviousMessages: (() => Promise) | undefined; readonly onManualNavigation: () => void; readonly onSelectHistoryMessage: ((messageId: MessageId) => void) | undefined; readonly rows: ReadonlyArray; @@ -90,8 +88,9 @@ interface UseProgressiveTimelineHistoryInput { export function useProgressiveTimelineHistory({ historyOutline, + historyLayoutMeasurement, + historyScrollElement, historyTargetMessageId, - isHistoryReady, isLoadingNextMessages, isLoadingPreviousMessages, latestMessagesRequest, @@ -100,9 +99,8 @@ export function useProgressiveTimelineHistory({ minimapItems, minimapStripMap, onHistoryTargetReady, + onHistoryScrollToOffset, onIsAtEndChange, - onLoadNextMessages, - onLoadPreviousMessages, onManualNavigation, onSelectHistoryMessage, rows, @@ -111,37 +109,36 @@ export function useProgressiveTimelineHistory({ const anchorRef = useRef(null); const pendingRequestRef = useRef(null); const resolvedLogicalAnchorRef = useRef(null); - const programmaticScrollRef = useRef<"seeking" | "aligning" | null>(null); + const programmaticScrollRef = useRef(null); const expectedScrollTopRef = useRef(null); - const pointerActiveRef = useRef(false); - const userNavigationRef = useRef(false); - const userNavigationResetTimeoutRef = useRef(null); + const scrollOffsetRef = useRef(null); + const manualScrollRef = useRef(false); + const pointerNavigationRef = useRef(false); + const manualScrollEndTimeoutRef = useRef(null); const loadTimeoutRef = useRef(null); const lastLoadAtRef = useRef(0); const reconcileTimeoutRef = useRef(null); const reconcileAnchorRef = useRef<() => void>(() => {}); - const adjacentHistoryRequestRef = useRef(null); const alignmentCleanupRef = useRef<(() => void) | null>(null); const historyBeforeSpacerRef = useRef(null); const historyAfterSpacerRef = useRef(null); const historyBeforeSkeletonsRef = useRef(null); const historyAfterSkeletonsRef = useRef(null); const historyInitializedRef = useRef(false); + const latestPositionPendingRef = useRef(false); + const initialTailPositionPendingRef = useRef(false); + const initialTailPositionTimeoutRef = useRef(null); const handledLatestMessagesRequestRef = useRef(latestMessagesRequest); - const reprocessedHistoryWindowRef = useRef(null); - const [listHeaderSize, setListHeaderSize] = useState(0); - const [layoutMeasurement, setLayoutMeasurement] = useState(null); - const messageRowIndexById = useMemo(() => { - const rowIndexByMessageId = new Map(); - for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { - const row = rows[rowIndex]; - if (row?.kind === "message") { - rowIndexByMessageId.set(row.message.id, rowIndex); + const [historicalNavigation, setHistoricalNavigation] = useState(false); + const loadedMessageIds = useMemo(() => { + const messageIds: MessageId[] = []; + for (const row of rows) { + if (row.kind === "message") { + messageIds.push(row.message.id); } } - return rowIndexByMessageId; + return messageIds; }, [rows]); - const loadedMessageIds = useMemo(() => [...messageRowIndexById.keys()], [messageRowIndexById]); const historyWindowKey = messageHistory === undefined @@ -150,18 +147,46 @@ export function useProgressiveTimelineHistory({ const estimatedLoadedSize = messageHistory === undefined ? 0 - : (messageHistory.endIndex - messageHistory.startIndex) * VIRTUAL_MESSAGE_SIZE; + : (messageHistory.endIndex - messageHistory.startIndex) * PROGRESSIVE_TIMELINE_MESSAGE_SIZE; const loadedSize = - layoutMeasurement?.windowKey === historyWindowKey - ? layoutMeasurement.loadedSize + historyLayoutMeasurement?.windowKey === historyWindowKey + ? historyLayoutMeasurement.loadedSize : estimatedLoadedSize; - const virtualHistoryBeforeSize = (messageHistory?.startIndex ?? 0) * VIRTUAL_MESSAGE_SIZE; - const historyBeforeSize = virtualHistoryBeforeSize; - const virtualHistoryAfterSize = - Math.max(0, (messageHistory?.totalMessages ?? 0) - (messageHistory?.endIndex ?? 0)) * - VIRTUAL_MESSAGE_SIZE; - const historyAfterSize = virtualHistoryAfterSize; - const contentHeight = historyBeforeSize + loadedSize + historyAfterSize; + const currentAnchor = anchorRef.current; + const layoutAnchor = + currentAnchor === null || scrollOffsetRef.current === null || historyScrollElement === null + ? null + : currentAnchor.kind === "logical" + ? { + messageIndex: currentAnchor.messageIndex, + viewportPosition: scrollOffsetRef.current + historyScrollElement.clientHeight / 2, + } + : currentAnchor.messageIndex === null + ? null + : { + messageIndex: currentAnchor.messageIndex, + viewportPosition: scrollOffsetRef.current + currentAnchor.viewportOffset, + }; + const { + contentSize: contentHeight, + historyAfterSize, + historyBeforeSize, + virtualHistoryAfterSize, + virtualHistoryBeforeSize, + } = messageHistory === undefined + ? { + contentSize: 0, + historyAfterSize: 0, + historyBeforeSize: 0, + virtualHistoryAfterSize: 0, + virtualHistoryBeforeSize: 0, + } + : resolveProgressiveTimelineLayout({ + anchor: layoutAnchor, + loadedSize, + messageHistory, + preserveHistoricalCanvas: historicalNavigation || messageHistory.hasMoreAfter, + }); const historyRequestInProgress = isLoadingPreviousMessages || isLoadingNextMessages || historyTargetMessageId !== null; const historyLoadInProgress = isLoadingPreviousMessages || isLoadingNextMessages; @@ -171,78 +196,6 @@ export function useProgressiveTimelineHistory({ alignmentCleanupRef.current = null; }, []); - const captureLogicalAnchor = useCallback(() => { - if (messageHistory === undefined) { - return; - } - const scrollNode = listRef.current?.getScrollableNode(); - if (scrollNode === undefined) { - return; - } - const viewportOffset = scrollNode.clientHeight / 2; - const viewportPosition = scrollNode.scrollTop + viewportOffset; - const messageIndex = - viewportPosition <= historyBeforeSize - ? viewportPosition / VIRTUAL_MESSAGE_SIZE - : viewportPosition >= historyBeforeSize + loadedSize - ? messageHistory.endIndex + - (viewportPosition - historyBeforeSize - loadedSize) / VIRTUAL_MESSAGE_SIZE - : messageHistory.startIndex + - ((viewportPosition - historyBeforeSize) / Math.max(1, loadedSize)) * - (messageHistory.endIndex - messageHistory.startIndex); - resolvedLogicalAnchorRef.current = null; - anchorRef.current = { - kind: "logical", - messageIndex: Math.max(0, Math.min(messageHistory.totalMessages - 1, messageIndex)), - }; - }, [historyBeforeSize, listRef, loadedSize, messageHistory]); - - const captureRenderedMessageAnchor = useCallback(() => { - if (timelineViewportElement === null) { - return false; - } - const viewportRect = timelineViewportElement.getBoundingClientRect(); - const viewportCenter = viewportRect.top + viewportRect.height / 2; - let closest: - | { - readonly messageId: MessageId; - readonly viewportOffset: number; - readonly distance: number; - } - | undefined; - for (const messageId of loadedMessageIds) { - const element = timelineViewportElement.querySelector( - `[data-message-id="${CSS.escape(messageId)}"]`, - ); - if (element === null) { - continue; - } - const rect = element.getBoundingClientRect(); - if (rect.bottom <= viewportRect.top || rect.top >= viewportRect.bottom) { - continue; - } - const distance = Math.abs(rect.top + rect.height / 2 - viewportCenter); - if (closest === undefined || distance < closest.distance) { - closest = { - messageId, - viewportOffset: rect.top - viewportRect.top, - distance, - }; - } - } - if (closest === undefined) { - return false; - } - pendingRequestRef.current = null; - resolvedLogicalAnchorRef.current = null; - anchorRef.current = { - kind: "message", - messageId: closest.messageId, - viewportOffset: closest.viewportOffset, - }; - return true; - }, [loadedMessageIds, timelineViewportElement]); - const queueReconciliation = useCallback(() => { if (reconcileTimeoutRef.current !== null) { return; @@ -309,11 +262,13 @@ export function useProgressiveTimelineHistory({ const setScrollTop = useCallback( (scrollNode: HTMLElement, scrollTop: number) => { + scrollOffsetRef.current = scrollTop; expectedScrollTopRef.current = scrollTop; positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); - scrollNode.scrollTo({ behavior: "auto", top: scrollTop }); + onHistoryScrollToOffset(scrollTop, "auto"); + scrollNode.dispatchEvent(new Event("scroll")); }, - [positionHistorySkeletons], + [onHistoryScrollToOffset, positionHistorySkeletons], ); const smoothScroll = useCallback( @@ -346,11 +301,32 @@ export function useProgressiveTimelineHistory({ alignmentCleanupRef.current = cleanup; positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); scrollNode.addEventListener("scrollend", finish, { once: true }); - scrollNode.scrollTo({ behavior: "smooth", top: scrollTop }); + onHistoryScrollToOffset(scrollTop, "smooth"); }, - [clearAlignmentWait, positionHistorySkeletons], + [clearAlignmentWait, onHistoryScrollToOffset, positionHistorySkeletons], ); + const scheduleInitialTailPosition = useCallback(() => { + if (initialTailPositionTimeoutRef.current !== null) { + window.clearTimeout(initialTailPositionTimeoutRef.current); + } + initialTailPositionTimeoutRef.current = window.setTimeout(() => { + initialTailPositionTimeoutRef.current = null; + if (!initialTailPositionPendingRef.current) { + return; + } + initialTailPositionPendingRef.current = false; + const scrollNode = historyScrollElement; + if (scrollNode === null) { + return; + } + const scrollTop = Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); + scrollOffsetRef.current = scrollTop; + onHistoryScrollToOffset(scrollTop, "auto"); + scrollNode.dispatchEvent(new Event("scroll")); + }, INITIAL_TAIL_POSITION_DELAY_MS); + }, [historyScrollElement, onHistoryScrollToOffset]); + const reconcileAnchor = useCallback(() => { if ( messageHistory === undefined || @@ -359,19 +335,11 @@ export function useProgressiveTimelineHistory({ ) { return false; } - if (pointerActiveRef.current) { - return false; - } - const list = listRef.current; - const scrollNode = list?.getScrollableNode(); + const scrollNode = historyScrollElement; const anchor = anchorRef.current; - if (list === null || list === undefined || scrollNode === undefined || anchor === null) { + if (scrollNode === null || anchor === null) { return false; } - if (Math.abs(listHeaderSize - historyBeforeSize) > 1) { - return false; - } - if (anchor.kind === "logical") { const pendingRequest = pendingRequestRef.current; if ( @@ -404,40 +372,6 @@ export function useProgressiveTimelineHistory({ `[data-message-id="${CSS.escape(anchor.messageId)}"]`, ); if (target === null) { - const targetRowIndex = messageRowIndexById.get(anchor.messageId); - const targetPosition = - targetRowIndex === null || targetRowIndex === undefined - ? undefined - : list.getState().positionAtIndex(targetRowIndex); - if ( - targetPosition === undefined || - !Number.isFinite(targetPosition) || - programmaticScrollRef.current === "aligning" - ) { - return false; - } - const scrollTop = Math.max( - 0, - Math.min( - scrollNode.scrollHeight - scrollNode.clientHeight, - historyBeforeSize + targetPosition - anchor.viewportOffset, - ), - ); - if (Math.abs(scrollNode.scrollTop - scrollTop) <= 2) { - pendingRequestRef.current = null; - if (historyTargetMessageId === anchor.messageId) { - onHistoryTargetReady?.(); - } - return true; - } - programmaticScrollRef.current = "aligning"; - smoothScroll(scrollNode, scrollTop, () => { - if (programmaticScrollRef.current !== "aligning") { - return; - } - programmaticScrollRef.current = null; - queueReconciliation(); - }); return false; } if (programmaticScrollRef.current === "seeking") { @@ -485,11 +419,9 @@ export function useProgressiveTimelineHistory({ }, [ clearAlignmentWait, historyBeforeSize, + historyScrollElement, historyTargetMessageId, historyWindowKey, - listHeaderSize, - listRef, - messageRowIndexById, messageHistory, minimapItems, onHistoryTargetReady, @@ -507,101 +439,49 @@ export function useProgressiveTimelineHistory({ messageHistory === undefined || historyOutline === null || onSelectHistoryMessage === undefined || - historyOutline.landmarks.length === 0 || (resolvedLogicalAnchorRef.current?.windowKey === historyWindowKey && Math.abs(resolvedLogicalAnchorRef.current.anchorMessageIndex - anchor.messageIndex) <= 0.01) ) { return; } - let target = historyOutline.landmarks[0]!; - let targetMessageIndex = - target.messageIndex ?? - (target.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * - Math.max(0, messageHistory.totalMessages - 1); - for (const landmark of historyOutline.landmarks) { - const landmarkMessageIndex = - landmark.messageIndex ?? - (landmark.ordinal / Math.max(1, historyOutline.totalUserMessages - 1)) * - Math.max(0, messageHistory.totalMessages - 1); - if ( - Math.abs(landmarkMessageIndex - anchor.messageIndex) < - Math.abs(targetMessageIndex - anchor.messageIndex) - ) { - target = landmark; - targetMessageIndex = landmarkMessageIndex; - } + const targetMessageId = resolveProgressiveTimelineHistoryTarget({ + anchorMessageIndex: anchor.messageIndex, + historyOutline, + loadedMessageIds, + messageHistory, + minimapItems, + }); + if (targetMessageId === null) { + return; } - const targetIsLoaded = minimapItems.some( - (item) => item.id === target.messageId && item.rowIndex !== null, - ); - if (targetIsLoaded) { + if ( + minimapItems.some((item) => item.id === targetMessageId && item.rowIndex !== null) || + (anchor.messageIndex >= messageHistory.startIndex && + anchor.messageIndex < messageHistory.endIndex) + ) { pendingRequestRef.current = { kind: "logical", anchorMessageIndex: anchor.messageIndex, - messageId: target.messageId, + messageId: targetMessageId, }; queueReconciliation(); return; } - if ( - anchor.messageIndex >= messageHistory.startIndex && - anchor.messageIndex < messageHistory.endIndex - ) { - let loadedTarget: TimelineHistoryNavigationTarget | null = null; - let loadedTargetDistance = Number.POSITIVE_INFINITY; - for (const item of minimapItems) { - if (item.rowIndex === null || item.messageIndex === null) { - continue; - } - const distance = Math.abs(item.messageIndex - anchor.messageIndex); - if (distance < loadedTargetDistance) { - loadedTarget = item; - loadedTargetDistance = distance; - } - } - if (loadedTarget !== null) { - pendingRequestRef.current = { - kind: "logical", - anchorMessageIndex: anchor.messageIndex, - messageId: loadedTarget.id, - }; - queueReconciliation(); - return; - } - const fallbackMessageId = - loadedMessageIds[ - Math.round( - ((anchor.messageIndex - messageHistory.startIndex) / - Math.max(1, messageHistory.endIndex - messageHistory.startIndex - 1)) * - Math.max(0, loadedMessageIds.length - 1), - ) - ]; - if (fallbackMessageId !== undefined) { - pendingRequestRef.current = { - kind: "logical", - anchorMessageIndex: anchor.messageIndex, - messageId: fallbackMessageId, - }; - queueReconciliation(); - } - return; - } const targetAlreadyRequested = - pendingRequestRef.current?.messageId === target.messageId && - historyTargetMessageId === target.messageId; + pendingRequestRef.current?.messageId === targetMessageId && + historyTargetMessageId === targetMessageId; pendingRequestRef.current = { kind: "logical", anchorMessageIndex: anchor.messageIndex, - messageId: target.messageId, + messageId: targetMessageId, }; if (targetAlreadyRequested) { return; } lastLoadAtRef.current = performance.now(); - onManualNavigation(); - onSelectHistoryMessage(target.messageId); + onSelectHistoryMessage(targetMessageId); }, [ historyOutline, historyTargetMessageId, @@ -609,7 +489,6 @@ export function useProgressiveTimelineHistory({ loadedMessageIds, messageHistory, minimapItems, - onManualNavigation, onSelectHistoryMessage, queueReconciliation, ]); @@ -626,65 +505,85 @@ export function useProgressiveTimelineHistory({ }, [requestHistoryForAnchor]); const releaseScrollControl = useCallback(() => { - const scrollNode = listRef.current?.getScrollableNode(); - if (programmaticScrollRef.current !== null && scrollNode !== undefined) { - scrollNode.scrollTo({ behavior: "auto", top: scrollNode.scrollTop }); + const scrollNode = historyScrollElement; + if (programmaticScrollRef.current !== null && scrollNode !== null) { + onHistoryScrollToOffset(scrollNode.scrollTop, "auto"); + scrollNode.dispatchEvent(new Event("scroll")); } clearAlignmentWait(); + manualScrollRef.current = false; programmaticScrollRef.current = null; expectedScrollTopRef.current = null; anchorRef.current = null; pendingRequestRef.current = null; resolvedLogicalAnchorRef.current = null; - userNavigationRef.current = false; - if (userNavigationResetTimeoutRef.current !== null) { - window.clearTimeout(userNavigationResetTimeoutRef.current); - userNavigationResetTimeoutRef.current = null; - } onHistoryTargetReady?.(); - }, [clearAlignmentWait, listRef, onHistoryTargetReady]); + }, [clearAlignmentWait, historyScrollElement, onHistoryScrollToOffset, onHistoryTargetReady]); const beginUserNavigation = useCallback(() => { - releaseScrollControl(); - userNavigationRef.current = true; - userNavigationResetTimeoutRef.current = window.setTimeout(() => { - userNavigationResetTimeoutRef.current = null; - if (!pointerActiveRef.current) { - userNavigationRef.current = false; + const takesScrollControl = !manualScrollRef.current || programmaticScrollRef.current !== null; + if (takesScrollControl) { + releaseScrollControl(); + initialTailPositionPendingRef.current = false; + if (initialTailPositionTimeoutRef.current !== null) { + window.clearTimeout(initialTailPositionTimeoutRef.current); + initialTailPositionTimeoutRef.current = null; } - }, USER_NAVIGATION_START_TIMEOUT_MS); - onManualNavigation(); + onManualNavigation(); + } + if (manualScrollEndTimeoutRef.current !== null) { + window.clearTimeout(manualScrollEndTimeoutRef.current); + manualScrollEndTimeoutRef.current = null; + } + manualScrollRef.current = true; }, [onManualNavigation, releaseScrollControl]); + const beginPointerNavigation = useCallback(() => { + pointerNavigationRef.current = true; + beginUserNavigation(); + }, [beginUserNavigation]); + useLayoutEffect(() => { if (handledLatestMessagesRequestRef.current === latestMessagesRequest) { return; } handledLatestMessagesRequestRef.current = latestMessagesRequest; releaseScrollControl(); - adjacentHistoryRequestRef.current = null; + initialTailPositionPendingRef.current = false; + if (initialTailPositionTimeoutRef.current !== null) { + window.clearTimeout(initialTailPositionTimeoutRef.current); + initialTailPositionTimeoutRef.current = null; + } + latestPositionPendingRef.current = true; historyInitializedRef.current = false; + scrollOffsetRef.current = null; + setHistoricalNavigation(false); }, [latestMessagesRequest, releaseScrollControl]); - const handleListMetricsChange = useCallback(({ headerSize }: LegendListMetrics) => { - setListHeaderSize((current) => (Math.abs(current - headerSize) <= 1 ? current : headerSize)); - }, []); - const handleScroll = useCallback(() => { - const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const state = messageHistory === undefined ? listRef.current?.getState?.() : undefined; + const scrollNode = + messageHistory === undefined ? listRef.current?.getScrollableNode() : historyScrollElement; + const isAtEnd = + messageHistory === undefined + ? resolveTimelineIsAtEnd(state) + : scrollNode === null || scrollNode === undefined + ? undefined + : scrollNode.scrollTop + scrollNode.clientHeight >= scrollNode.scrollHeight - 2; if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } - if (state === undefined) { + if (messageHistory === undefined || scrollNode === null || scrollNode === undefined) { return; } - const scrollNode = listRef.current?.getScrollableNode(); - const scrollTop = scrollNode?.scrollTop ?? state.scroll ?? 0; - const scrollLength = scrollNode?.clientHeight ?? state.scrollLength ?? 0; + const scrollTop = scrollNode.scrollTop; + const scrollLength = scrollNode.clientHeight; const scrollBottom = scrollTop + scrollLength; - positionHistorySkeletons(scrollTop, scrollNode?.scrollHeight ?? state.contentLength ?? 0); + const previousScrollTop = scrollOffsetRef.current; + scrollOffsetRef.current = scrollTop; + const renderedContentHeight = historyBeforeSize + loadedSize + historyAfterSize; + positionHistorySkeletons(scrollTop, scrollNode.scrollHeight); const expectedScrollTop = expectedScrollTopRef.current; const isExpectedScroll = @@ -696,84 +595,47 @@ export function useProgressiveTimelineHistory({ if ( messageHistory !== undefined && historyInitializedRef.current && - programmaticScrollRef.current === null && - !isExpectedScroll && - (userNavigationRef.current || pointerActiveRef.current) + shouldCaptureProgressiveTimelineScroll({ + didScroll: previousScrollTop === null || Math.abs(scrollTop - previousScrollTop) > 0.5, + isExpectedScroll, + isManualScroll: manualScrollRef.current, + programmaticScroll: programmaticScrollRef.current, + }) ) { - captureLogicalAnchor(); - onManualNavigation(); - scheduleHistoryLoad(); - if (!pointerActiveRef.current) { - if (userNavigationResetTimeoutRef.current !== null) { - window.clearTimeout(userNavigationResetTimeoutRef.current); - } - userNavigationResetTimeoutRef.current = window.setTimeout(() => { - userNavigationResetTimeoutRef.current = null; - userNavigationRef.current = false; - }, USER_NAVIGATION_SCROLL_TIMEOUT_MS); + if (!pointerNavigationRef.current) { + setHistoricalNavigation(true); } - } - - if ( - messageHistory !== undefined && - isHistoryReady && - historyWindowKey !== null && - scrollTop < historyBeforeSize && - scrollBottom >= historyBeforeSize && - messageHistory.hasMoreBefore && - onLoadPreviousMessages !== undefined && - !isLoadingPreviousMessages && - (adjacentHistoryRequestRef.current?.direction !== "before" || - adjacentHistoryRequestRef.current.windowKey !== historyWindowKey) - ) { - captureRenderedMessageAnchor(); - const request: AdjacentHistoryRequest = { - direction: "before", - windowKey: historyWindowKey, - }; - adjacentHistoryRequestRef.current = request; - void onLoadPreviousMessages().then((loaded) => { - const currentRequest = adjacentHistoryRequestRef.current; - if ( - loaded || - currentRequest?.direction !== request.direction || - currentRequest.windowKey !== request.windowKey - ) { - return; + if (manualScrollEndTimeoutRef.current !== null) { + window.clearTimeout(manualScrollEndTimeoutRef.current); + } + manualScrollEndTimeoutRef.current = window.setTimeout(() => { + manualScrollEndTimeoutRef.current = null; + if (!pointerNavigationRef.current) { + manualScrollRef.current = false; } - adjacentHistoryRequestRef.current = null; - }); + }, MANUAL_SCROLL_END_DELAY_MS); + resolvedLogicalAnchorRef.current = null; + anchorRef.current = { + kind: "logical", + messageIndex: resolveProgressiveTimelineMessageIndex({ + historyAfterSize, + historyBeforeSize, + loadedSize, + messageHistory, + scrollLength, + scrollTop, + }), + }; + scheduleHistoryLoad(); } - const loadedHistoryEnd = historyBeforeSize + loadedSize; if ( - messageHistory !== undefined && - isHistoryReady && - historyWindowKey !== null && - scrollTop < loadedHistoryEnd && - scrollBottom >= loadedHistoryEnd && - messageHistory.hasMoreAfter && - onLoadNextMessages !== undefined && - !isLoadingNextMessages && - (adjacentHistoryRequestRef.current?.direction !== "after" || - adjacentHistoryRequestRef.current.windowKey !== historyWindowKey) + historicalNavigation && + messageHistory?.hasMoreAfter === false && + previousScrollTop !== null && + scrollTop >= previousScrollTop && + scrollBottom >= renderedContentHeight - 1 ) { - captureRenderedMessageAnchor(); - const request: AdjacentHistoryRequest = { - direction: "after", - windowKey: historyWindowKey, - }; - adjacentHistoryRequestRef.current = request; - void onLoadNextMessages().then((loaded) => { - const currentRequest = adjacentHistoryRequestRef.current; - if ( - loaded || - currentRequest?.direction !== request.direction || - currentRequest.windowKey !== request.windowKey - ) { - return; - } - adjacentHistoryRequestRef.current = null; - }); + setHistoricalNavigation(false); } for (const item of minimapItems) { @@ -781,95 +643,41 @@ export function useProgressiveTimelineHistory({ if (strip === undefined || item.rowIndex === null) { continue; } - const rowTopWithinWindow = state.positionAtIndex?.(item.rowIndex); - const rowHeight = state.sizeAtIndex?.(item.rowIndex); - const rowTop = - rowTopWithinWindow === undefined || !Number.isFinite(rowTopWithinWindow) - ? null - : historyBeforeSize + rowTopWithinWindow; - const resolvedRowHeight = - rowHeight === undefined || !Number.isFinite(rowHeight) ? null : rowHeight; + const row = scrollNode.querySelector( + `[data-message-id="${CSS.escape(item.id)}"]`, + ); + const viewportRect = scrollNode.getBoundingClientRect(); + const rowRect = row?.getBoundingClientRect(); const inView = - rowTop !== null && - rowTop < scrollBottom && - rowTop + Math.max(1, resolvedRowHeight ?? 1) > scrollTop; + rowRect !== undefined && + rowRect.bottom > viewportRect.top && + rowRect.top < viewportRect.bottom; strip.dataset.inView = inView ? "true" : "false"; } }, [ + historyAfterSize, historyBeforeSize, - historyWindowKey, - isHistoryReady, - isLoadingNextMessages, - isLoadingPreviousMessages, + historicalNavigation, listRef, loadedSize, messageHistory, minimapItems, minimapStripMap, - onLoadNextMessages, - onLoadPreviousMessages, - onManualNavigation, onIsAtEndChange, positionHistorySkeletons, - captureRenderedMessageAnchor, scheduleHistoryLoad, + historyScrollElement, + timelineViewportElement, ]); - useEffect(() => { - const frame = window.requestAnimationFrame(handleScroll); - return () => window.cancelAnimationFrame(frame); - }, [handleScroll]); - - const beginScrollbarPointerNavigation = useCallback( - (event: globalThis.PointerEvent | PointerEvent) => { - const scrollNode = listRef.current?.getScrollableNode(); - if (scrollNode === undefined || event.button !== 0 || pointerActiveRef.current) { - return; - } - const rect = scrollNode.getBoundingClientRect(); - const sideGutterWidth = Math.max(0, (scrollNode.offsetWidth - scrollNode.clientWidth) / 2); - if ( - event.target !== scrollNode && - !( - event.clientY >= rect.top && - event.clientY <= rect.bottom && - (event.clientX <= rect.left + sideGutterWidth || - event.clientX >= rect.right - sideGutterWidth) - ) - ) { - return; - } - pointerActiveRef.current = true; - beginUserNavigation(); - }, - [beginUserNavigation, listRef], - ); - - const beginPointerNavigation = useCallback( - (event: PointerEvent) => { - beginUserNavigation(); - beginScrollbarPointerNavigation(event); - }, - [beginScrollbarPointerNavigation, beginUserNavigation], - ); - - const finishScrollbarPointerNavigation = useCallback(() => { - if (!pointerActiveRef.current) { - return; - } - pointerActiveRef.current = false; - captureLogicalAnchor(); - scheduleHistoryLoad(); - queueReconciliation(); - userNavigationRef.current = false; - if (userNavigationResetTimeoutRef.current !== null) { - window.clearTimeout(userNavigationResetTimeoutRef.current); - userNavigationResetTimeoutRef.current = null; - } - }, [captureLogicalAnchor, queueReconciliation, scheduleHistoryLoad]); - const selectHistoryTarget = useCallback( (item: TimelineHistoryNavigationTarget) => { + initialTailPositionPendingRef.current = false; + if (initialTailPositionTimeoutRef.current !== null) { + window.clearTimeout(initialTailPositionTimeoutRef.current); + initialTailPositionTimeoutRef.current = null; + } + setHistoricalNavigation(true); onManualNavigation(); if (messageHistory === undefined) { if (item.rowIndex !== null) { @@ -883,7 +691,7 @@ export function useProgressiveTimelineHistory({ } clearAlignmentWait(); - userNavigationRef.current = false; + manualScrollRef.current = false; expectedScrollTopRef.current = null; if (loadTimeoutRef.current !== null) { window.clearTimeout(loadTimeoutRef.current); @@ -895,6 +703,7 @@ export function useProgressiveTimelineHistory({ anchorRef.current = { kind: "message", messageId: item.id, + messageIndex: item.messageIndex, viewportOffset: MESSAGE_VIEWPORT_OFFSET, }; pendingRequestRef.current = { @@ -903,9 +712,8 @@ export function useProgressiveTimelineHistory({ }; programmaticScrollRef.current = "seeking"; - const list = listRef.current; - const scrollNode = list?.getScrollableNode(); - if (list === null || list === undefined || scrollNode === undefined) { + const scrollNode = historyScrollElement; + if (scrollNode === null) { programmaticScrollRef.current = null; return; } @@ -916,7 +724,7 @@ export function useProgressiveTimelineHistory({ 0, Math.min( scrollNode.scrollHeight - scrollNode.clientHeight, - item.messageIndex * VIRTUAL_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, + item.messageIndex * PROGRESSIVE_TIMELINE_MESSAGE_SIZE - MESSAGE_VIEWPORT_OFFSET, ), ); if (item.rowIndex === null || supersedesHistoryRequest) { @@ -946,11 +754,20 @@ export function useProgressiveTimelineHistory({ setScrollTop(scrollNode, estimatedScrollTop); finishSeek(); } else if (item.rowIndex !== null) { - void list.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: MESSAGE_VIEWPORT_OFFSET, - }); + const target = scrollNode.querySelector( + `[data-message-id="${CSS.escape(item.id)}"]`, + ); + if (target !== null) { + smoothScroll( + scrollNode, + scrollNode.scrollTop + + target.getBoundingClientRect().top - + scrollNode.getBoundingClientRect().top - + MESSAGE_VIEWPORT_OFFSET, + finishSeek, + ); + return; + } programmaticScrollRef.current = null; queueReconciliation(); } @@ -958,6 +775,7 @@ export function useProgressiveTimelineHistory({ [ clearAlignmentWait, historyTargetMessageId, + historyScrollElement, listRef, messageHistory, onHistoryTargetReady, @@ -965,98 +783,75 @@ export function useProgressiveTimelineHistory({ onSelectHistoryMessage, queueReconciliation, setScrollTop, + smoothScroll, + timelineViewportElement, ], ); useLayoutEffect(() => { - if (messageHistory === undefined || historyWindowKey === null) { - return; - } - const beforeSpacer = historyBeforeSpacerRef.current; - const afterSpacer = historyAfterSpacerRef.current; - const loadedRowsContainer = afterSpacer?.parentElement?.previousElementSibling; if ( - beforeSpacer === null || - afterSpacer === null || - loadedRowsContainer === null || - loadedRowsContainer === undefined + messageHistory === undefined || + historyWindowKey === null || + historyLayoutMeasurement?.windowKey !== historyWindowKey ) { return; } - - const measure = () => { - const loadedSize = Math.max( - 0, - afterSpacer.getBoundingClientRect().top - beforeSpacer.getBoundingClientRect().bottom, - ); - setLayoutMeasurement((current) => - current?.windowKey === historyWindowKey && Math.abs(current.loadedSize - loadedSize) <= 1 - ? current - : { - windowKey: historyWindowKey, - loadedSize, - }, - ); - queueReconciliation(); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(loadedRowsContainer); - observer.observe(beforeSpacer); - observer.observe(afterSpacer); - return () => { - observer.disconnect(); - }; - }, [historyWindowKey, messageHistory, queueReconciliation, rows.length]); - - useLayoutEffect(() => { - if (messageHistory === undefined || historyWindowKey === null) { + const scrollNode = historyScrollElement; + if (scrollNode === null) { return; } - const list = listRef.current; - const scrollNode = list?.getScrollableNode(); - if (list === null || list === undefined || scrollNode === undefined) { + if (latestPositionPendingRef.current) { + if (messageHistory.hasMoreAfter) { + return; + } + latestPositionPendingRef.current = false; + historyInitializedRef.current = true; + anchorRef.current = { + kind: "logical", + messageIndex: messageHistory.totalMessages - 1, + }; + initialTailPositionPendingRef.current = true; + scheduleInitialTailPosition(); + queueReconciliation(); return; } if (!historyInitializedRef.current) { - const initialScrollTop = messageHistory.hasMoreAfter - ? Math.min( - historyBeforeSize, - Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight), - ) - : Math.max(0, scrollNode.scrollHeight - scrollNode.clientHeight); + historyInitializedRef.current = true; anchorRef.current = { kind: "logical", - messageIndex: Math.max( - 0, - Math.min( - messageHistory.totalMessages - 1, - (initialScrollTop + scrollNode.clientHeight / 2) / VIRTUAL_MESSAGE_SIZE, - ), - ), + messageIndex: messageHistory.totalMessages - 1, }; - if (Math.abs(scrollNode.scrollTop - initialScrollTop) > 1) { - setScrollTop(scrollNode, initialScrollTop); + if (messageHistory.hasMoreAfter) { + anchorRef.current = { + kind: "logical", + messageIndex: resolveProgressiveTimelineMessageIndex({ + historyAfterSize, + historyBeforeSize, + loadedSize, + messageHistory, + scrollLength: scrollNode.clientHeight, + scrollTop: scrollNode.scrollTop, + }), + }; + } else { + initialTailPositionPendingRef.current = true; + scheduleInitialTailPosition(); } - historyInitializedRef.current = true; } - scheduleHistoryLoad(); - if (Math.abs(listHeaderSize - historyBeforeSize) <= 1) { - if (reprocessedHistoryWindowRef.current !== historyWindowKey) { - reprocessedHistoryWindowRef.current = historyWindowKey; - list.reportContentInset(null); - } - queueReconciliation(); + if (initialTailPositionPendingRef.current) { + scheduleInitialTailPosition(); } + queueReconciliation(); }, [ + historyAfterSize, historyBeforeSize, + historyScrollElement, historyWindowKey, - listHeaderSize, - listRef, + historyLayoutMeasurement, + loadedSize, messageHistory, queueReconciliation, - scheduleHistoryLoad, - setScrollTop, + scheduleInitialTailPosition, ]); useEffect(() => { @@ -1070,12 +865,6 @@ export function useProgressiveTimelineHistory({ } }, [historyLoadInProgress, historyWindowKey, messageHistory, queueReconciliation]); - useEffect(() => { - if (!historyRequestInProgress) { - scheduleHistoryLoad(); - } - }, [historyRequestInProgress, scheduleHistoryLoad]); - useEffect(() => { if (anchorRef.current?.kind === "message" && pendingRequestRef.current?.kind === "message") { queueReconciliation(); @@ -1093,18 +882,48 @@ export function useProgressiveTimelineHistory({ }; }, [queueReconciliation, timelineViewportElement]); + const beginKeyboardNavigation = useCallback( + (event: KeyboardEvent) => { + if ( + !isProgressiveTimelineNavigationKey(event.key) || + (document.activeElement !== document.body && + !timelineViewportElement?.contains(document.activeElement)) + ) { + return; + } + beginUserNavigation(); + }, + [beginUserNavigation, timelineViewportElement], + ); + useEffect(() => { - window.addEventListener("keydown", beginUserNavigation, true); - window.addEventListener("pointerdown", beginScrollbarPointerNavigation, true); - window.addEventListener("pointerup", finishScrollbarPointerNavigation); - window.addEventListener("pointercancel", finishScrollbarPointerNavigation); + window.addEventListener("keydown", beginKeyboardNavigation, true); + const finishPointerNavigation = () => { + pointerNavigationRef.current = false; + manualScrollRef.current = false; + if (messageHistory !== undefined && historyScrollElement !== null) { + setHistoricalNavigation( + messageHistory.hasMoreAfter || + historyScrollElement.scrollTop + historyScrollElement.clientHeight < + historyBeforeSize + loadedSize + historyAfterSize - 1, + ); + } + }; + window.addEventListener("pointerup", finishPointerNavigation); + window.addEventListener("pointercancel", finishPointerNavigation); return () => { - window.removeEventListener("keydown", beginUserNavigation, true); - window.removeEventListener("pointerdown", beginScrollbarPointerNavigation, true); - window.removeEventListener("pointerup", finishScrollbarPointerNavigation); - window.removeEventListener("pointercancel", finishScrollbarPointerNavigation); + window.removeEventListener("keydown", beginKeyboardNavigation, true); + window.removeEventListener("pointerup", finishPointerNavigation); + window.removeEventListener("pointercancel", finishPointerNavigation); }; - }, [beginScrollbarPointerNavigation, beginUserNavigation, finishScrollbarPointerNavigation]); + }, [ + beginKeyboardNavigation, + historyAfterSize, + historyBeforeSize, + historyScrollElement, + loadedSize, + messageHistory, + ]); useEffect( () => () => { @@ -1117,9 +936,13 @@ export function useProgressiveTimelineHistory({ window.clearTimeout(reconcileTimeoutRef.current); reconcileTimeoutRef.current = null; } - if (userNavigationResetTimeoutRef.current !== null) { - window.clearTimeout(userNavigationResetTimeoutRef.current); - userNavigationResetTimeoutRef.current = null; + if (initialTailPositionTimeoutRef.current !== null) { + window.clearTimeout(initialTailPositionTimeoutRef.current); + initialTailPositionTimeoutRef.current = null; + } + if (manualScrollEndTimeoutRef.current !== null) { + window.clearTimeout(manualScrollEndTimeoutRef.current); + manualScrollEndTimeoutRef.current = null; } }, [clearAlignmentWait], @@ -1127,9 +950,8 @@ export function useProgressiveTimelineHistory({ return useMemo( () => ({ - beginPointerNavigation, - beginScrollbarPointerNavigation, beginUserNavigation, + beginPointerNavigation, contentHeight, handleScroll, historyAfterSize, @@ -1138,19 +960,16 @@ export function useProgressiveTimelineHistory({ historyBeforeSize, historyBeforeSkeletonsRef, historyBeforeSpacerRef, - onListMetricsChange: handleListMetricsChange, selectHistoryTarget, virtualHistoryAfterSize, virtualHistoryBeforeSize, }), [ - beginScrollbarPointerNavigation, - beginPointerNavigation, beginUserNavigation, + beginPointerNavigation, handleScroll, historyAfterSize, historyBeforeSize, - handleListMetricsChange, selectHistoryTarget, contentHeight, virtualHistoryAfterSize, diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 0f9ba71d497..2f73d5769f5 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -871,6 +871,18 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ); }); + 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; @@ -1144,6 +1156,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make loadPreviousMessages, loadNextMessages, loadMessagesAround, + cancelMessagesAround, showLatestMessages, }); }); @@ -1153,6 +1166,7 @@ type EnvironmentThreadStateSubscription = readonly loadPreviousMessages: Effect.Effect; readonly loadNextMessages: Effect.Effect; readonly loadMessagesAround: (messageId: MessageId) => Effect.Effect; + readonly cancelMessagesAround: Effect.Effect; readonly showLatestMessages: Effect.Effect; }; @@ -1267,6 +1281,23 @@ export function createEnvironmentThreadStateAtoms( 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 }) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a456fa4fe87..9f19eb2b8a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,6 +570,9 @@ importers: '@tanstack/react-router': specifier: ^1.160.2 version: 1.170.18(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.14.8 + version: 3.14.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@xterm/addon-fit': specifier: ^0.11.0 version: 0.11.0 @@ -4394,6 +4397,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.8': + resolution: {integrity: sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.171.15': resolution: {integrity: sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==} engines: {node: '>=20.19'} @@ -4433,6 +4442,9 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.17.6': + resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==} + '@tanstack/virtual-file-routes@1.162.0': resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} @@ -13937,6 +13949,12 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) + '@tanstack/react-virtual@3.14.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.17.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@tanstack/router-core@1.171.15': dependencies: '@tanstack/history': 1.162.0 @@ -13998,6 +14016,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.17.6': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@testing-library/dom@10.4.1': From 47e28f57d2332383bb928cd2407aad7e90205719 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 14:32:55 +0300 Subject: [PATCH 11/31] fix(web): remove history boundary gaps --- .../chat/progressiveTimelineHistory.logic.ts | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts b/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts index d2f66cbcdfd..930ef66c450 100644 --- a/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts +++ b/apps/web/src/components/chat/progressiveTimelineHistory.logic.ts @@ -48,37 +48,48 @@ export function resolveProgressiveTimelineLayout({ readonly messageHistory: OrchestrationThreadMessageHistory; readonly preserveHistoricalCanvas: boolean; }): ProgressiveTimelineLayout { + const hasUnloadedBefore = messageHistory.startIndex > 0; + const hasUnloadedAfter = messageHistory.endIndex < messageHistory.totalMessages; const virtualHistoryBeforeSize = messageHistory.startIndex * PROGRESSIVE_TIMELINE_MESSAGE_SIZE; const virtualHistoryAfterSize = Math.max(0, messageHistory.totalMessages - messageHistory.endIndex) * PROGRESSIVE_TIMELINE_MESSAGE_SIZE; const renderedContentSize = virtualHistoryBeforeSize + loadedSize + virtualHistoryAfterSize; - const contentSize = preserveHistoricalCanvas - ? Math.max( - messageHistory.totalMessages * PROGRESSIVE_TIMELINE_MESSAGE_SIZE, - renderedContentSize, - ) - : renderedContentSize; + const contentSize = + !hasUnloadedBefore && !hasUnloadedAfter + ? loadedSize + : preserveHistoricalCanvas + ? Math.max( + messageHistory.totalMessages * PROGRESSIVE_TIMELINE_MESSAGE_SIZE, + renderedContentSize, + ) + : renderedContentSize; const canPlaceAtAnchor = preserveHistoricalCanvas && anchor !== null && anchor.messageIndex >= messageHistory.startIndex && anchor.messageIndex < messageHistory.endIndex; - const historyBeforeSize = canPlaceAtAnchor - ? Math.max( - 0, - Math.min( - contentSize - loadedSize, - anchor.viewportPosition - - ((anchor.messageIndex - messageHistory.startIndex) / - Math.max(1, messageHistory.endIndex - messageHistory.startIndex)) * - loadedSize, - ), - ) - : virtualHistoryBeforeSize; - const historyAfterSize = canPlaceAtAnchor - ? Math.max(0, contentSize - historyBeforeSize - loadedSize) - : virtualHistoryAfterSize; + const historyBeforeSize = !hasUnloadedBefore + ? 0 + : !hasUnloadedAfter + ? Math.max(0, contentSize - loadedSize) + : canPlaceAtAnchor + ? Math.max( + 0, + Math.min( + contentSize - loadedSize, + anchor.viewportPosition - + ((anchor.messageIndex - messageHistory.startIndex) / + Math.max(1, messageHistory.endIndex - messageHistory.startIndex)) * + loadedSize, + ), + ) + : virtualHistoryBeforeSize; + const historyAfterSize = !hasUnloadedAfter + ? 0 + : canPlaceAtAnchor || !hasUnloadedBefore + ? Math.max(0, contentSize - historyBeforeSize - loadedSize) + : virtualHistoryAfterSize; return { contentSize, historyAfterSize, From fa46a3c6fb4989ae75bd850a96626eabf8724ae6 Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 29 Jul 2026 15:36:51 +0300 Subject: [PATCH 12/31] fix(web): stabilize progressive history navigation --- FORK.md | 28 +- apps/web/src/components/ChatView.tsx | 41 +- .../src/components/chat/MessagesTimeline.tsx | 298 ++-- .../progressiveTimelineHistory.logic.test.ts | 316 ----- .../chat/progressiveTimelineHistory.logic.ts | 299 ---- .../chat/useProgressiveTimelineHistory.ts | 1240 ++++++----------- packages/client-runtime/src/state/threads.ts | 1 + 7 files changed, 666 insertions(+), 1557 deletions(-) delete mode 100644 apps/web/src/components/chat/progressiveTimelineHistory.logic.test.ts delete mode 100644 apps/web/src/components/chat/progressiveTimelineHistory.logic.ts diff --git a/FORK.md b/FORK.md index f1cf8db71c5..acc50060bb3 100644 --- a/FORK.md +++ b/FORK.md @@ -86,29 +86,17 @@ This repository is a fork of `pingdotgg/t3code`. Keep this file focused on fork - Thread detail snapshots keep a bounded live tail. Historical browsing uses bounded, bidirectional keyset windows, while the web minimap samples landmarks across the full thread and - loads the selected segment on demand. The web timeline represents unloaded ranges as virtual - scroll space and swaps bounded segments in as the user approaches or jumps into them. Historical - windows retain every message, cap work telemetry per segment, and only display activity for turns - represented by the active message window. Viewport navigation, minimap jumps, and unloaded spacers - share one fixed per-message scroll axis and load the nearest landmark window through a trailing - throttle. The historical canvas stays fixed while bounded segments change, regardless of their - measured height. History uses one stable native scroll element with an externally-scrolled - virtualizer, so segment changes never replace the scrollbar or render the complete bounded segment. - A loaded segment is placed around the current logical viewport anchor without changing the physical - scroll offset. The live tail uses its real rendered height. Scroll-to-end leaves historical - navigation state and returns directly to that bounded tail. + loads the selected segment on demand. The native web scroller only represents the current bounded + segment; the minimap is the global conversation-indexed scrubber. Historical windows retain every + message, cap work telemetry per segment, and only display activity for turns represented by the + active message window. Reaching a segment edge loads the adjacent window and restores the visible + message at the same viewport offset. 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 instead of leaving the virtual - loader locked. Minimap jumps position virtual history immediately while the segment loads, then use - the loaded row position for a smooth exact correction. Any keyboard, pointer, touch, or wheel input - cancels that motion and removes the concrete target before the browser scrolls. The resulting real - scroll selects the next logical segment without restoring an older viewport position. Only actual - manual offset changes schedule history loads. Holding the native scrollbar thumb keeps that - ownership and throttled loading active until release. Synchronous offset writes avoid delayed - programmatic scrolls taking control back from the user. Overlapping explicit jumps may fetch - concurrently, but only the latest request can replace the active segment. +- 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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ccff1abad89..a28be975aba 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1148,6 +1148,12 @@ function ChatViewContent(props: ChatViewProps) { 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, }); @@ -1457,6 +1463,20 @@ function ChatViewContent(props: ChatViewProps) { 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 ?? serverThread?.session?.lastError ?? null) : localDraftError; @@ -3555,12 +3575,15 @@ function ChatViewContent(props: ChatViewProps) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - if (activeThread?.messageHistory?.hasMoreAfter !== true) { + if (activeThread?.messageHistory === undefined) { void legendListRef.current?.scrollToEnd?.({ animated }); return; } setLatestMessagesRequest((request) => request + 1); + if (!activeThread.messageHistory.hasMoreAfter) { + return; + } void showLatestMessages({ environmentId, input: { threadId }, @@ -3578,17 +3601,9 @@ function ChatViewContent(props: ChatViewProps) { } return; } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (liveFollowUserScrollGenerationRef.current !== userScrollGeneration) { - return; - } - void legendListRef.current?.scrollToEnd?.({ animated: false }); - }); - }); }); }, - [activeThread?.messageHistory?.hasMoreAfter, environmentId, showLatestMessages, threadId], + [activeThread?.messageHistory, environmentId, showLatestMessages, threadId], ); useEffect(() => { let removeListeners: (() => void) | null = null; @@ -6009,9 +6024,7 @@ function ChatViewContent(props: ChatViewProps) { : { messageHistory: activeThread.messageHistory })} isLoadingPreviousMessages={ environmentThreadState.history.kind === "ready" && - (environmentThreadState.history.loading === "before" || - environmentThreadState.history.loading === "around" || - environmentThreadState.history.loading === "outline") + environmentThreadState.history.loading === "before" } isLoadingNextMessages={ environmentThreadState.history.kind === "ready" && @@ -6026,6 +6039,8 @@ function ChatViewContent(props: ChatViewProps) { 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 */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 58f6b2dcdb6..5778d59f243 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -22,13 +22,13 @@ import { useState, type KeyboardEvent, type MouseEvent, + type PointerEvent, type ReactNode, - type RefObject, } from "react"; import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; -import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { deriveTimelineEntries, workEntryIndicatesToolFailure, @@ -113,8 +113,10 @@ import { } from "./userMessageTerminalContexts"; import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -import { type TimelineHistoryNavigationTarget } from "./progressiveTimelineHistory.logic"; -import { useProgressiveTimelineHistory } from "./useProgressiveTimelineHistory"; +import { + useProgressiveTimelineHistory, + type TimelineHistoryNavigationTarget, +} from "./useProgressiveTimelineHistory"; import { buildReviewCommentRenderablePatch, formatReviewCommentFence, @@ -156,6 +158,8 @@ const TimelineRowCtx = createContext(null!); const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const TIMELINE_HISTORY_LOADING_SIZE = 192; +const TIMELINE_SCRUB_THROTTLE_MS = 180; // --------------------------------------------------------------------------- // Props (public API) @@ -198,6 +202,8 @@ interface MessagesTimelineProps { historyTargetMessageId?: MessageId | null; onSelectHistoryMessage?: (messageId: MessageId) => void; onHistoryTargetReady?: () => void; + onLoadPreviousMessages?: () => Promise; + onLoadNextMessages?: () => Promise; } // --------------------------------------------------------------------------- @@ -241,6 +247,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ historyTargetMessageId = null, onSelectHistoryMessage, onHistoryTargetReady, + onLoadPreviousMessages, + onLoadNextMessages, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -354,77 +362,45 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [historyScrollElement, setHistoryScrollElement] = useState(null); - const historyVirtualizerRef = useRef | null>(null); - const handleHistoryScrollToOffset = useCallback( - (offset: number, behavior: ScrollBehavior) => - historyVirtualizerRef.current?.scrollToOffset(offset, { behavior }), - [], - ); - const [historyLayoutMeasurement, setHistoryLayoutMeasurement] = useState<{ - readonly windowKey: string; - readonly loadedSize: number; - } | null>(null); + const minimapPositionRef = useRef(null); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); - const historyWindowKey = - messageHistory === undefined - ? null - : `${messageHistory.startIndex}:${messageHistory.endIndex}:${messageHistory.totalMessages}`; + const historyHeaderSize = topFadeEnabled ? 48 : 16; + const historyBeforeSize = + historyHeaderSize + (isLoadingPreviousMessages ? TIMELINE_HISTORY_LOADING_SIZE : 0); + const historyAfterSize = isLoadingNextMessages ? TIMELINE_HISTORY_LOADING_SIZE : 0; + const historyVirtualizer = useVirtualizer({ + count: messageHistory === undefined ? 0 : rows.length, + enabled: messageHistory !== undefined, + estimateSize: () => 90, + getItemKey: (index) => rows[index]!.id, + getScrollElement: () => historyScrollElement, + overscan: 5, + paddingEnd: historyAfterSize + contentInsetEndAdjustment, + paddingStart: historyBeforeSize, + useAnimationFrameWithResizeObserver: true, + }); const progressiveHistory = useProgressiveTimelineHistory({ - historyOutline, - historyLayoutMeasurement, historyScrollElement, historyTargetMessageId, + historyVirtualizer, isLoadingNextMessages, isLoadingPreviousMessages, latestMessagesRequest, listRef, messageHistory, minimapItems, + minimapPositionRef, minimapStripMap, onHistoryTargetReady, - onHistoryScrollToOffset: handleHistoryScrollToOffset, onIsAtEndChange, + onLoadNextMessages, + onLoadPreviousMessages, onManualNavigation, onSelectHistoryMessage, + routeThreadKey, rows, - timelineViewportElement, }); - const historyVirtualizer = useVirtualizer({ - count: messageHistory === undefined ? 0 : rows.length, - enabled: messageHistory !== undefined, - estimateSize: () => 90, - getItemKey: (index) => rows[index]!.id, - getScrollElement: () => historyScrollElement, - overscan: 5, - paddingEnd: progressiveHistory.historyAfterSize + contentInsetEndAdjustment, - paddingStart: progressiveHistory.historyBeforeSize, - useAnimationFrameWithResizeObserver: true, - }); - historyVirtualizerRef.current = historyVirtualizer; - historyVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = () => false; - const measuredHistoryLoadedSize = Math.max( - 0, - historyVirtualizer.getTotalSize() - - progressiveHistory.historyBeforeSize - - progressiveHistory.historyAfterSize - - contentInsetEndAdjustment, - ); - useLayoutEffect(() => { - if (historyWindowKey === null) { - setHistoryLayoutMeasurement(null); - return; - } - setHistoryLayoutMeasurement((current) => - current?.windowKey === historyWindowKey && - Math.abs(current.loadedSize - measuredHistoryLoadedSize) <= 1 - ? current - : { - windowKey: historyWindowKey, - loadedSize: measuredHistoryLoadedSize, - }, - ); - }, [historyWindowKey, measuredHistoryLoadedSize]); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -611,7 +587,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
0 + ? "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden" + : "scrollbar-gutter-both", topFadeEnabled && "chat-timeline-scroll-fade", )} onScroll={progressiveHistory.handleScroll} @@ -619,18 +598,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
- {progressiveHistory.virtualHistoryBeforeSize > 0 ? ( - + {isLoadingPreviousMessages ? ( + ) : null}
{historyVirtualizer.getVirtualItems().map((virtualRow) => { @@ -647,20 +623,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
); })} -
- {progressiveHistory.virtualHistoryAfterSize > 0 ? ( - - ) : null} -
+ {isLoadingNextMessages ? ( +
+ +
+ ) : null}
)} @@ -669,6 +642,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ bottomInset={contentInsetEndAdjustment} hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} + positionRef={minimapPositionRef} + showPosition={messageHistory !== undefined} stripMap={minimapStripMap} onSelect={progressiveHistory.selectHistoryTarget} /> @@ -678,29 +653,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }); -function TimelineHistorySkeletons({ windowRef }: { windowRef: RefObject }) { +function TimelineHistorySkeletons({ position }: { position: "after-header" | "start" }) { return ( ); } @@ -796,6 +769,8 @@ function TimelineMinimap({ hasPersistentGutter, hitStripWidth, items, + positionRef, + showPosition, stripMap, onSelect, }: { @@ -803,10 +778,17 @@ function TimelineMinimap({ hasPersistentGutter: boolean; hitStripWidth: number; items: ReadonlyArray; + positionRef: React.RefObject; + showPosition: boolean; stripMap: Map; 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; @@ -825,7 +807,7 @@ function TimelineMinimap({ : "-50%"; const resolveActiveIndexFromPointer = useCallback( - (event: MouseEvent) => { + (event: MouseEvent | PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); return resolveTimelineMinimapIndexFromPointer({ itemCount: items.length, @@ -845,6 +827,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) => { @@ -855,6 +877,15 @@ function TimelineMinimap({ [items.length], ); + useEffect( + () => () => { + if (selectionTimeoutRef.current !== null) { + window.clearTimeout(selectionTimeoutRef.current); + } + }, + [], + ); + if (items.length < TIMELINE_MINIMAP_MIN_ITEMS) { return null; } @@ -882,17 +913,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; - } - const nextIndex = resolveActiveIndexFromPointer(event); - const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); - if (nextItem) { - onSelect(nextItem); + onBlur={() => { + if (!draggingRef.current) { + setActiveIndex(null); } - event.currentTarget.blur(); }} onFocus={() => setActiveIndex((current) => current ?? 0)} onKeyDown={(event) => { @@ -915,13 +939,59 @@ function TimelineMinimap({ } } }} - onMouseLeave={() => setActiveIndex(null)} + onMouseLeave={() => { + if (!draggingRef.current) { + setActiveIndex(null); + } + }} onMouseMove={updateActiveIndexFromPointer} - onMouseDown={(event) => { + onPointerCancel={(event) => { + draggingRef.current = false; + pendingSelectionRef.current = 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); + setActiveIndex(nextIndex); + const nextItem = nextIndex === null ? null : (items[nextIndex] ?? null); + if (nextItem !== null) { + selectScrubItem(nextItem, true); + } + event.currentTarget.releasePointerCapture(event.pointerId); }} style={{ height: resolveTimelineMinimapHeightStyle(items.length), @@ -930,6 +1000,14 @@ function TimelineMinimap({ type="button" >
+ {showPosition ? ( +