Skip to content

Commit 569fea8

Browse files
Warm sidebar thread detail subscriptions (#2001)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 33dadb5 commit 569fea8

5 files changed

Lines changed: 315 additions & 9 deletions

File tree

apps/web/src/components/Sidebar.logic.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

33
import {
44
createThreadJumpHintVisibilityController,
5+
getSidebarThreadIdsToPrewarm,
56
getVisibleSidebarThreadIds,
67
resolveAdjacentThreadId,
78
getFallbackThreadIdAfterDelete,
@@ -121,6 +122,20 @@ describe("createThreadJumpHintVisibilityController", () => {
121122
});
122123
});
123124

125+
describe("getSidebarThreadIdsToPrewarm", () => {
126+
it("returns only the first visible thread ids up to the prewarm limit", () => {
127+
expect(getSidebarThreadIdsToPrewarm(["t1", "t2", "t3"], 2)).toEqual(["t1", "t2"]);
128+
});
129+
130+
it("returns all visible thread ids when they fit within the limit", () => {
131+
expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 10)).toEqual(["t1", "t2"]);
132+
});
133+
134+
it("returns no thread ids when the limit is zero", () => {
135+
expect(getSidebarThreadIdsToPrewarm(["t1", "t2"], 0)).toEqual([]);
136+
});
137+
});
138+
124139
describe("shouldClearThreadSelectionOnMouseDown", () => {
125140
it("preserves selection for thread items", () => {
126141
const child = {

apps/web/src/components/Sidebar.logic.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { isLatestTurnSettled } from "../session-logic";
1212

1313
export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]";
1414
export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100;
15+
// Visible sidebar rows are prewarmed into the thread-detail cache so opening a
16+
// nearby thread usually reuses an already-hot subscription.
17+
export const SIDEBAR_THREAD_PREWARM_LIMIT = 10;
1518
export type SidebarNewThreadEnvMode = "local" | "worktree";
1619
type SidebarProject = {
1720
id: string;
@@ -243,6 +246,13 @@ export function getVisibleSidebarThreadIds<TThreadId>(
243246
);
244247
}
245248

249+
export function getSidebarThreadIdsToPrewarm<TThreadId>(
250+
visibleThreadIds: readonly TThreadId[],
251+
limit = SIDEBAR_THREAD_PREWARM_LIMIT,
252+
): TThreadId[] {
253+
return visibleThreadIds.slice(0, Math.max(0, limit));
254+
}
255+
246256
export function resolveAdjacentThreadId<T>(input: {
247257
threadIds: readonly T[];
248258
currentThreadId: T | null;

apps/web/src/components/Sidebar.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
type GitStatusResult,
4444
} from "@t3tools/contracts";
4545
import {
46+
parseScopedThreadKey,
4647
scopedProjectKey,
4748
scopedThreadKey,
4849
scopeProjectRef,
@@ -81,6 +82,7 @@ import { useGitStatus } from "../lib/gitStatusState";
8182
import { readLocalApi } from "../localApi";
8283
import { useComposerDraftStore } from "../composerDraftStore";
8384
import { useNewThreadHandler } from "../hooks/useHandleNewThread";
85+
import { retainThreadDetailSubscription } from "../environments/runtime/service";
8486

8587
import { useThreadActions } from "../hooks/useThreadActions";
8688
import {
@@ -122,6 +124,7 @@ import {
122124
import { useThreadSelectionStore } from "../threadSelectionStore";
123125
import { isNonEmpty as isNonEmptyString } from "effect/String";
124126
import {
127+
getSidebarThreadIdsToPrewarm,
125128
resolveAdjacentThreadId,
126129
isContextMenuPointerDown,
127130
resolveProjectStatusIndicator,
@@ -2878,6 +2881,30 @@ export default function Sidebar() {
28782881
? threadJumpLabelByKey
28792882
: EMPTY_THREAD_JUMP_LABELS;
28802883
const orderedSidebarThreadKeys = visibleSidebarThreadKeys;
2884+
const prewarmedSidebarThreadKeys = useMemo(
2885+
() => getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys),
2886+
[visibleSidebarThreadKeys],
2887+
);
2888+
const prewarmedSidebarThreadRefs = useMemo(
2889+
() =>
2890+
prewarmedSidebarThreadKeys.flatMap((threadKey) => {
2891+
const ref = parseScopedThreadKey(threadKey);
2892+
return ref ? [ref] : [];
2893+
}),
2894+
[prewarmedSidebarThreadKeys],
2895+
);
2896+
2897+
useEffect(() => {
2898+
const releases = prewarmedSidebarThreadRefs.map((ref) =>
2899+
retainThreadDetailSubscription(ref.environmentId, ref.threadId),
2900+
);
2901+
2902+
return () => {
2903+
for (const release of releases) {
2904+
release();
2905+
}
2906+
};
2907+
}, [prewarmedSidebarThreadRefs]);
28812908

28822909
useEffect(() => {
28832910
const clearThreadJumpHints = () => {

apps/web/src/environments/runtime/service.threadSubscriptions.test.ts

Lines changed: 150 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { QueryClient } from "@tanstack/react-query";
2-
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2+
import {
3+
EnvironmentId,
4+
ProjectId,
5+
ThreadId,
6+
TurnId,
7+
type OrchestrationShellSnapshot,
8+
} from "@t3tools/contracts";
39
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
410

511
const mockSubscribeThread = vi.fn();
@@ -65,6 +71,74 @@ vi.mock("../../rpc/wsTransport", () => ({
6571
WsTransport: MockWsTransport,
6672
}));
6773

74+
function makeThreadShellSnapshot(params: {
75+
readonly threadId: ThreadId;
76+
readonly sessionStatus?:
77+
| "idle"
78+
| "starting"
79+
| "running"
80+
| "ready"
81+
| "interrupted"
82+
| "stopped"
83+
| "error";
84+
readonly hasPendingApprovals?: boolean;
85+
readonly hasPendingUserInput?: boolean;
86+
readonly hasActionableProposedPlan?: boolean;
87+
}): OrchestrationShellSnapshot {
88+
const projectId = ProjectId.make("project-1");
89+
const turnId = TurnId.make("turn-1");
90+
91+
return {
92+
snapshotSequence: 1,
93+
projects: [],
94+
updatedAt: "2026-04-13T00:00:00.000Z",
95+
threads: [
96+
{
97+
id: params.threadId,
98+
projectId,
99+
title: "Thread",
100+
modelSelection: {
101+
provider: "codex",
102+
model: "gpt-5-codex",
103+
},
104+
runtimeMode: "full-access",
105+
interactionMode: "default",
106+
branch: null,
107+
worktreePath: null,
108+
latestTurn:
109+
params.sessionStatus === "running"
110+
? {
111+
turnId,
112+
state: "running",
113+
requestedAt: "2026-04-13T00:00:00.000Z",
114+
startedAt: "2026-04-13T00:00:01.000Z",
115+
completedAt: null,
116+
assistantMessageId: null,
117+
}
118+
: null,
119+
createdAt: "2026-04-13T00:00:00.000Z",
120+
updatedAt: "2026-04-13T00:00:00.000Z",
121+
archivedAt: null,
122+
session: params.sessionStatus
123+
? {
124+
threadId: params.threadId,
125+
status: params.sessionStatus,
126+
providerName: "codex",
127+
runtimeMode: "full-access",
128+
activeTurnId: params.sessionStatus === "running" ? turnId : null,
129+
lastError: null,
130+
updatedAt: "2026-04-13T00:00:00.000Z",
131+
}
132+
: null,
133+
latestUserMessageAt: null,
134+
hasPendingApprovals: params.hasPendingApprovals ?? false,
135+
hasPendingUserInput: params.hasPendingUserInput ?? false,
136+
hasActionableProposedPlan: params.hasActionableProposedPlan ?? false,
137+
},
138+
],
139+
};
140+
}
141+
68142
describe("retainThreadDetailSubscription", () => {
69143
beforeEach(() => {
70144
vi.useFakeTimers();
@@ -119,16 +193,89 @@ describe("retainThreadDetailSubscription", () => {
119193
expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
120194

121195
releaseSecond();
122-
await vi.advanceTimersByTimeAsync(2 * 60 * 1000 - 1);
196+
await vi.advanceTimersByTimeAsync(2 * 60 * 1000);
123197
expect(mockThreadUnsubscribe).not.toHaveBeenCalled();
124198

125-
await vi.advanceTimersByTimeAsync(1);
199+
await vi.advanceTimersByTimeAsync(28 * 60 * 1000);
126200
expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1);
127201

128202
stop();
129203
await resetEnvironmentServiceForTests();
130204
});
131205

206+
it("keeps non-idle thread detail subscriptions attached until the thread becomes idle", async () => {
207+
const {
208+
retainThreadDetailSubscription,
209+
startEnvironmentConnectionService,
210+
resetEnvironmentServiceForTests,
211+
} = await import("./service");
212+
213+
const stop = startEnvironmentConnectionService(new QueryClient());
214+
const environmentId = EnvironmentId.make("env-1");
215+
const threadId = ThreadId.make("thread-active");
216+
217+
const connectionInput = mockCreateEnvironmentConnection.mock.calls[0]?.[0];
218+
expect(connectionInput).toBeDefined();
219+
220+
connectionInput.syncShellSnapshot(
221+
makeThreadShellSnapshot({
222+
threadId,
223+
sessionStatus: "ready",
224+
hasPendingApprovals: true,
225+
}),
226+
environmentId,
227+
);
228+
229+
const release = retainThreadDetailSubscription(environmentId, threadId);
230+
expect(mockSubscribeThread).toHaveBeenCalledTimes(1);
231+
232+
release();
233+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
234+
expect(mockThreadUnsubscribe).not.toHaveBeenCalled();
235+
236+
connectionInput.applyShellEvent(
237+
{
238+
kind: "thread-upserted",
239+
sequence: 2,
240+
thread: makeThreadShellSnapshot({
241+
threadId,
242+
sessionStatus: "idle",
243+
}).threads[0]!,
244+
},
245+
environmentId,
246+
);
247+
248+
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
249+
expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1);
250+
251+
stop();
252+
await resetEnvironmentServiceForTests();
253+
});
254+
255+
it("allows a larger idle cache before capacity eviction starts", async () => {
256+
const {
257+
retainThreadDetailSubscription,
258+
startEnvironmentConnectionService,
259+
resetEnvironmentServiceForTests,
260+
} = await import("./service");
261+
262+
const stop = startEnvironmentConnectionService(new QueryClient());
263+
const environmentId = EnvironmentId.make("env-1");
264+
265+
for (let index = 0; index < 12; index += 1) {
266+
const release = retainThreadDetailSubscription(
267+
environmentId,
268+
ThreadId.make(`thread-${index + 1}`),
269+
);
270+
release();
271+
}
272+
273+
expect(mockThreadUnsubscribe).not.toHaveBeenCalled();
274+
275+
stop();
276+
await resetEnvironmentServiceForTests();
277+
});
278+
132279
it("disposes cached thread detail subscriptions when the environment service resets", async () => {
133280
const {
134281
retainThreadDetailSubscription,

0 commit comments

Comments
 (0)