Skip to content

Commit e55bcce

Browse files
authored
Merge pull request #82 from tyulyukov/feature/timeline-cache
perf(timeline): cache derived timeline data
2 parents 2a4f7b9 + 2e5bd56 commit e55bcce

4 files changed

Lines changed: 121 additions & 29 deletions

File tree

apps/web/src/components/ChatView.tsx

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ import {
102102
import {
103103
type AppState,
104104
selectProjectsAcrossEnvironments,
105-
selectThreadsAcrossEnvironments,
105+
selectThreadShellsAcrossEnvironments,
106106
useStore,
107107
} from "../store";
108108
import {
@@ -114,6 +114,7 @@ import {
114114
useThreadById,
115115
} from "../storeSelectors";
116116
import { useUiStateStore } from "../uiStateStore";
117+
import { getThreadFromEnvironmentState } from "../threadDerivation";
117118
import {
118119
buildPlanImplementationThreadTitle,
119120
buildPlanImplementationPrompt,
@@ -365,10 +366,7 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog
365366
let previousEntries: ThreadPlanCatalogEntry[] = [];
366367

367368
return (state: AppState): ThreadPlanCatalogEntry[] => {
368-
const allThreads = selectThreadsAcrossEnvironments(state);
369-
const nextThreads = threadIds.map((threadId) =>
370-
allThreads.find((thread: Thread) => thread.id === threadId),
371-
);
369+
const nextThreads = threadIds.map((threadId) => getThreadAcrossEnvironments(state, threadId));
372370
const cachedThreads = previousThreads;
373371
if (
374372
cachedThreads &&
@@ -389,6 +387,37 @@ function useThreadPlanCatalog(threadIds: readonly ThreadId[]): ThreadPlanCatalog
389387
return useStore(selector);
390388
}
391389

390+
function getThreadAcrossEnvironments(state: AppState, threadId: ThreadId): Thread | undefined {
391+
for (const environmentState of Object.values(state.environmentStateById)) {
392+
if (!environmentState.threadShellById[threadId]) {
393+
continue;
394+
}
395+
return getThreadFromEnvironmentState(environmentState, threadId);
396+
}
397+
return undefined;
398+
}
399+
400+
function selectRateLimitActivitiesAcrossEnvironments(
401+
state: AppState,
402+
): OrchestrationThreadActivity[] {
403+
const activities: OrchestrationThreadActivity[] = [];
404+
for (const environmentState of Object.values(state.environmentStateById)) {
405+
for (const [threadId, activityIds] of Object.entries(
406+
environmentState.activityIdsByThreadId,
407+
) as Array<[ThreadId, string[]]>) {
408+
const activityById = environmentState.activityByThreadId[threadId];
409+
if (!activityById) continue;
410+
for (const activityId of activityIds) {
411+
const activity = activityById[activityId];
412+
if (activity?.kind === "account.rate-limits.updated") {
413+
activities.push(activity);
414+
}
415+
}
416+
}
417+
}
418+
return activities;
419+
}
420+
392421
function formatOutgoingPrompt(params: {
393422
provider: ProviderKind;
394423
model: string | null;
@@ -987,7 +1016,7 @@ export default function ChatView({
9871016
);
9881017
const serverThreadIds = useStore(
9891018
useShallow((state: AppState) =>
990-
selectThreadsAcrossEnvironments(state).map((thread) => thread.id),
1019+
selectThreadShellsAcrossEnvironments(state).map((thread) => thread.id),
9911020
),
9921021
);
9931022
const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey);
@@ -1133,19 +1162,7 @@ export default function ChatView({
11331162
() => deriveLatestContextWindowSnapshot(activeThread?.activities ?? []),
11341163
[activeThread?.activities],
11351164
);
1136-
const providerUsageActivities = useStore(
1137-
useShallow((state: AppState) => {
1138-
const activities: OrchestrationThreadActivity[] = [];
1139-
for (const thread of selectThreadsAcrossEnvironments(state)) {
1140-
for (const activity of thread.activities) {
1141-
if (activity.kind === "account.rate-limits.updated") {
1142-
activities.push(activity);
1143-
}
1144-
}
1145-
}
1146-
return activities;
1147-
}),
1148-
);
1165+
const providerUsageActivities = useStore(useShallow(selectRateLimitActivitiesAcrossEnvironments));
11491166
useEffect(() => {
11501167
setMountedTerminalThreadIds((currentThreadIds) => {
11511168
const nextThreadIds = reconcileMountedTerminalThreadIds({
@@ -2051,8 +2068,8 @@ export default function ChatView({
20512068
if (!targetThreadId) return;
20522069
const nextError = sanitizeThreadErrorMessage(error);
20532070
if (
2054-
selectThreadsAcrossEnvironments(useStore.getState()).some(
2055-
(thread: Thread) => thread.id === targetThreadId,
2071+
selectThreadShellsAcrossEnvironments(useStore.getState()).some(
2072+
(thread) => thread.id === targetThreadId,
20562073
)
20572074
) {
20582075
setStoreThreadError(targetThreadId, nextError);
@@ -4888,7 +4905,6 @@ export default function ChatView({
48884905
onClickCapture={onMessagesClickCapture}
48894906
>
48904907
<MessagesTimeline
4891-
key={activeThread.id}
48924908
threadId={activeThread.id}
48934909
provider={activeTimelineProvider}
48944910
hasMessages={timelineEntries.length > 0}

apps/web/src/components/chat/MessagesTimeline.logic.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,12 @@ export function deriveMessagesTimelineRows(input: {
247247
isWorking: boolean;
248248
activeTurnStartedAt: string | null;
249249
}): MessagesTimelineRow[] {
250+
const cacheKey = `${input.completionDividerBeforeEntryId ?? ""}\x1f${input.isWorking ? "1" : "0"}\x1f${input.activeTurnStartedAt ?? ""}`;
251+
const cachedByKey = timelineRowsCache.get(input.timelineEntries);
252+
const cached = cachedByKey?.get(cacheKey);
253+
if (cached) {
254+
return cached;
255+
}
250256
const nextRows: MessagesTimelineRow[] = [];
251257
const durationStartByMessageId = computeMessageDurationStart(
252258
input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
@@ -483,9 +489,19 @@ export function deriveMessagesTimelineRows(input: {
483489
});
484490
}
485491

492+
const nextCachedByKey = cachedByKey ?? new Map<string, MessagesTimelineRow[]>();
493+
nextCachedByKey.set(cacheKey, nextRows);
494+
if (!cachedByKey) {
495+
timelineRowsCache.set(input.timelineEntries, nextCachedByKey);
496+
}
486497
return nextRows;
487498
}
488499

500+
const timelineRowsCache = new WeakMap<
501+
ReadonlyArray<TimelineEntry>,
502+
Map<string, MessagesTimelineRow[]>
503+
>();
504+
489505
export function estimateMessagesTimelineRowHeight(
490506
row: MessagesTimelineRow,
491507
input: {

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({
349349
}, [rows, threadId, isHydrating]);
350350

351351
const hasFinishedInitialScrollRef = useRef(false);
352+
const hasAutoScrolledOnMountRef = useRef(false);
352353
const isAtEndRef = useRef(true);
354+
const initialScrollThreadIdRef = useRef(threadId);
355+
if (initialScrollThreadIdRef.current !== threadId) {
356+
initialScrollThreadIdRef.current = threadId;
357+
hasFinishedInitialScrollRef.current = false;
358+
hasAutoScrolledOnMountRef.current = false;
359+
isAtEndRef.current = true;
360+
}
353361
const updateIsAtEnd = useCallback(
354362
(isAtEnd: boolean) => {
355363
isAtEndRef.current = isAtEnd;
@@ -368,7 +376,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
368376
syncIsAtEnd();
369377
}, [syncIsAtEnd]);
370378

371-
const hasAutoScrolledOnMountRef = useRef(false);
372379
useEffect(() => {
373380
if (rows.length === 0) return;
374381
if (hasAutoScrolledOnMountRef.current) return;
@@ -557,6 +564,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
557564
<TimelineRowCtx.Provider value={sharedState}>
558565
<div className="@container/chat relative h-full">
559566
<LegendList<MessagesTimelineRow>
567+
key={threadId}
560568
ref={listRef}
561569
data={rows}
562570
keyExtractor={keyExtractor}

apps/web/src/session-logic.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,16 @@ export function deriveWorkLogEntries(
578578
): WorkLogEntry[] {
579579
const isSessionRunning = options?.isSessionRunning ?? false;
580580
const provider = options?.provider;
581-
const ordered = [...activities].toSorted(compareActivitiesByOrder);
581+
const cacheKey = `${latestTurnId ?? ""}\x1f${isSessionRunning ? "1" : "0"}\x1f${provider ?? ""}`;
582+
const cachedByKey = workLogEntriesCache.get(activities);
583+
const cached = cachedByKey?.get(cacheKey);
584+
if (cached) {
585+
return cached;
586+
}
587+
const relevantActivities = latestTurnId
588+
? activities.filter((activity) => activity.turnId === latestTurnId)
589+
: activities;
590+
const ordered = [...relevantActivities].toSorted(compareActivitiesByOrder);
582591

583592
const previousPlanStepsByActivityId = new Map<string, PlanStep[] | null>();
584593
let runningPreviousPlanSteps: PlanStep[] | null = null;
@@ -599,7 +608,6 @@ export function deriveWorkLogEntries(
599608
const collabToolDataUnkeyed: SubagentCollabToolData[] = [];
600609
const collabToolItemIds = new Set<string>();
601610
for (const activity of ordered) {
602-
if (latestTurnId && activity.turnId !== latestTurnId) continue;
603611
if (activity.kind === "tool.started") {
604612
const payload = asRecord(activity.payload);
605613
if (payload?.itemType === "collab_agent_tool_call") {
@@ -628,7 +636,6 @@ export function deriveWorkLogEntries(
628636
const nestedTaskIds = new Set<string>();
629637
for (const activity of ordered) {
630638
if (activity.kind !== "task.started") continue;
631-
if (latestTurnId && activity.turnId !== latestTurnId) continue;
632639
const payload = asRecord(activity.payload);
633640
const taskId = asTrimmedString(payload?.taskId);
634641
if (!taskId) continue;
@@ -641,7 +648,6 @@ export function deriveWorkLogEntries(
641648
}
642649

643650
const filtered = ordered
644-
.filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true))
645651
.filter(
646652
(activity) =>
647653
activity.kind !== "tool.started" ||
@@ -756,11 +762,22 @@ export function deriveWorkLogEntries(
756762
entries.push(toDerivedWorkLogEntry(activity, previousPlanSteps));
757763
}
758764

759-
return deduplicateToolLifecycleEntries(collapseDerivedWorkLogEntries(entries)).map(
765+
const result = deduplicateToolLifecycleEntries(collapseDerivedWorkLogEntries(entries)).map(
760766
({ activityKind: _activityKind, collapseKey: _collapseKey, ...entry }) => entry,
761767
);
768+
const nextCachedByKey = cachedByKey ?? new Map<string, WorkLogEntry[]>();
769+
nextCachedByKey.set(cacheKey, result);
770+
if (!cachedByKey) {
771+
workLogEntriesCache.set(activities, nextCachedByKey);
772+
}
773+
return result;
762774
}
763775

776+
const workLogEntriesCache = new WeakMap<
777+
ReadonlyArray<OrchestrationThreadActivity>,
778+
Map<string, WorkLogEntry[]>
779+
>();
780+
764781
function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): boolean {
765782
if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") {
766783
return false;
@@ -1066,6 +1083,12 @@ function toDerivedWorkLogEntry(
10661083
activity: OrchestrationThreadActivity,
10671084
previousPlanSteps: PlanStep[] | null = null,
10681085
): DerivedWorkLogEntry {
1086+
if (previousPlanSteps === null) {
1087+
const cached = derivedWorkLogEntryCache.get(activity);
1088+
if (cached) {
1089+
return cached;
1090+
}
1091+
}
10691092
const payload =
10701093
activity.payload && typeof activity.payload === "object"
10711094
? (activity.payload as Record<string, unknown>)
@@ -1189,9 +1212,14 @@ function toDerivedWorkLogEntry(
11891212
if (collapseKey) {
11901213
entry.collapseKey = collapseKey;
11911214
}
1215+
if (previousPlanSteps === null) {
1216+
derivedWorkLogEntryCache.set(activity, entry);
1217+
}
11921218
return entry;
11931219
}
11941220

1221+
const derivedWorkLogEntryCache = new WeakMap<OrchestrationThreadActivity, DerivedWorkLogEntry>();
1222+
11951223
function collapseDerivedWorkLogEntries(
11961224
entries: ReadonlyArray<DerivedWorkLogEntry>,
11971225
): DerivedWorkLogEntry[] {
@@ -1963,6 +1991,12 @@ export function deriveTimelineEntries(
19631991
proposedPlans: ProposedPlan[],
19641992
workEntries: WorkLogEntry[],
19651993
): TimelineEntry[] {
1994+
const cachedByProposedPlans = timelineEntriesCache.get(messages);
1995+
const cachedByWorkEntries = cachedByProposedPlans?.get(proposedPlans);
1996+
const cached = cachedByWorkEntries?.get(workEntries);
1997+
if (cached) {
1998+
return cached;
1999+
}
19662000
const messageRows: TimelineEntry[] = messages.map((message) => ({
19672001
id: message.id,
19682002
kind: "message",
@@ -1981,11 +2015,29 @@ export function deriveTimelineEntries(
19812015
createdAt: entry.createdAt,
19822016
entry,
19832017
}));
1984-
return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
2018+
const result = [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) =>
19852019
a.createdAt.localeCompare(b.createdAt),
19862020
);
2021+
const nextCachedByProposedPlans =
2022+
cachedByProposedPlans ??
2023+
new WeakMap<ProposedPlan[], WeakMap<WorkLogEntry[], TimelineEntry[]>>();
2024+
const nextCachedByWorkEntries =
2025+
cachedByWorkEntries ?? new WeakMap<WorkLogEntry[], TimelineEntry[]>();
2026+
nextCachedByWorkEntries.set(workEntries, result);
2027+
if (!cachedByWorkEntries) {
2028+
nextCachedByProposedPlans.set(proposedPlans, nextCachedByWorkEntries);
2029+
}
2030+
if (!cachedByProposedPlans) {
2031+
timelineEntriesCache.set(messages, nextCachedByProposedPlans);
2032+
}
2033+
return result;
19872034
}
19882035

2036+
const timelineEntriesCache = new WeakMap<
2037+
ChatMessage[],
2038+
WeakMap<ProposedPlan[], WeakMap<WorkLogEntry[], TimelineEntry[]>>
2039+
>();
2040+
19892041
export function inferCheckpointTurnCountByTurnId(
19902042
summaries: TurnDiffSummary[],
19912043
): Record<TurnId, number> {

0 commit comments

Comments
 (0)