| author | |
| committer | |
| log | 569fea87a90f1d9a5d4d9b6c7c1c59801324a561 |
| tree | 8cc52ba16fc900048f6568aac3345ab76956a276 |
| parent | 33dadb5aeca1ac5201682daed6e1412d57e14287 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Cursor Agent <cursoragent@cursor.com>5 files changed, 315 insertions(+), 9 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+15| ... | @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ... | @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | createThreadJumpHintVisibilityController, | 4 | createThreadJumpHintVisibilityController, |
| 5 | getSidebarThreadIdsToPrewarm, | ||
| 5 | getVisibleSidebarThreadIds, | 6 | getVisibleSidebarThreadIds, |
| 6 | resolveAdjacentThreadId, | 7 | resolveAdjacentThreadId, |
| 7 | getFallbackThreadIdAfterDelete, | 8 | getFallbackThreadIdAfterDelete, |
| ... | @@ -121,6 +122,20 @@ describe("createThreadJumpHintVisibilityController", () => { | ... | @@ -121,6 +122,20 @@ describe("createThreadJumpHintVisibilityController", () => { |
| 121 | }); | 122 | }); |
| 122 | }); | 123 | }); |
| 123 | 124 | ||
| 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 | |||
| 124 | describe("shouldClearThreadSelectionOnMouseDown", () => { | 139 | describe("shouldClearThreadSelectionOnMouseDown", () => { |
| 125 | it("preserves selection for thread items", () => { | 140 | it("preserves selection for thread items", () => { |
| 126 | const child = { | 141 | const child = { |
apps/web/src/components/Sidebar.logic.ts+10| ... | @@ -12,6 +12,9 @@ import { isLatestTurnSettled } from "../session-logic"; | ... | @@ -12,6 +12,9 @@ import { isLatestTurnSettled } from "../session-logic"; |
| 12 | 12 | ||
| 13 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 13 | export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 14 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; | 14 | 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; | ||
| 15 | export type SidebarNewThreadEnvMode = "local" | "worktree"; | 18 | export type SidebarNewThreadEnvMode = "local" | "worktree"; |
| 16 | type SidebarProject = { | 19 | type SidebarProject = { |
| 17 | id: string; | 20 | id: string; |
| ... | @@ -243,6 +246,13 @@ export function getVisibleSidebarThreadIds<TThreadId>( | ... | @@ -243,6 +246,13 @@ export function getVisibleSidebarThreadIds<TThreadId>( |
| 243 | ); | 246 | ); |
| 244 | } | 247 | } |
| 245 | 248 | ||
| 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 | |||
| 246 | export function resolveAdjacentThreadId<T>(input: { | 256 | export function resolveAdjacentThreadId<T>(input: { |
| 247 | threadIds: readonly T[]; | 257 | threadIds: readonly T[]; |
| 248 | currentThreadId: T | null; | 258 | currentThreadId: T | null; |
apps/web/src/components/Sidebar.tsx+27| ... | @@ -43,6 +43,7 @@ import { | ... | @@ -43,6 +43,7 @@ import { |
| 43 | type GitStatusResult, | 43 | type GitStatusResult, |
| 44 | } from "@t3tools/contracts"; | 44 | } from "@t3tools/contracts"; |
| 45 | import { | 45 | import { |
| 46 | parseScopedThreadKey, | ||
| 46 | scopedProjectKey, | 47 | scopedProjectKey, |
| 47 | scopedThreadKey, | 48 | scopedThreadKey, |
| 48 | scopeProjectRef, | 49 | scopeProjectRef, |
| ... | @@ -81,6 +82,7 @@ import { useGitStatus } from "../lib/gitStatusState"; | ... | @@ -81,6 +82,7 @@ import { useGitStatus } from "../lib/gitStatusState"; |
| 81 | import { readLocalApi } from "../localApi"; | 82 | import { readLocalApi } from "../localApi"; |
| 82 | import { useComposerDraftStore } from "../composerDraftStore"; | 83 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 83 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 84 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 85 | import { retainThreadDetailSubscription } from "../environments/runtime/service"; | ||
| 84 | 86 | ||
| 85 | import { useThreadActions } from "../hooks/useThreadActions"; | 87 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 86 | import { | 88 | import { |
| ... | @@ -122,6 +124,7 @@ import { | ... | @@ -122,6 +124,7 @@ import { |
| 122 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 124 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| 123 | import { isNonEmpty as isNonEmptyString } from "effect/String"; | 125 | import { isNonEmpty as isNonEmptyString } from "effect/String"; |
| 124 | import { | 126 | import { |
| 127 | getSidebarThreadIdsToPrewarm, | ||
| 125 | resolveAdjacentThreadId, | 128 | resolveAdjacentThreadId, |
| 126 | isContextMenuPointerDown, | 129 | isContextMenuPointerDown, |
| 127 | resolveProjectStatusIndicator, | 130 | resolveProjectStatusIndicator, |
| ... | @@ -2878,6 +2881,30 @@ export default function Sidebar() { | ... | @@ -2878,6 +2881,30 @@ export default function Sidebar() { |
| 2878 | ? threadJumpLabelByKey | 2881 | ? threadJumpLabelByKey |
| 2879 | : EMPTY_THREAD_JUMP_LABELS; | 2882 | : EMPTY_THREAD_JUMP_LABELS; |
| 2880 | const orderedSidebarThreadKeys = visibleSidebarThreadKeys; | 2883 | 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]); | ||
| 2881 | 2908 | ||
| 2882 | useEffect(() => { | 2909 | useEffect(() => { |
| 2883 | const clearThreadJumpHints = () => { | 2910 | const clearThreadJumpHints = () => { |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+150-3| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import { QueryClient } from "@tanstack/react-query"; | 1 | 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"; | ||
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 9 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | 10 | ||
| 5 | const mockSubscribeThread = vi.fn(); | 11 | const mockSubscribeThread = vi.fn(); |
| ... | @@ -65,6 +71,74 @@ vi.mock("../../rpc/wsTransport", () => ({ | ... | @@ -65,6 +71,74 @@ vi.mock("../../rpc/wsTransport", () => ({ |
| 65 | WsTransport: MockWsTransport, | 71 | WsTransport: MockWsTransport, |
| 66 | })); | 72 | })); |
| 67 | 73 | ||
| 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 | |||
| 68 | describe("retainThreadDetailSubscription", () => { | 142 | describe("retainThreadDetailSubscription", () => { |
| 69 | beforeEach(() => { | 143 | beforeEach(() => { |
| 70 | vi.useFakeTimers(); | 144 | vi.useFakeTimers(); |
| ... | @@ -119,16 +193,89 @@ describe("retainThreadDetailSubscription", () => { | ... | @@ -119,16 +193,89 @@ describe("retainThreadDetailSubscription", () => { |
| 119 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); | 193 | expect(mockSubscribeThread).toHaveBeenCalledTimes(1); |
| 120 | 194 | ||
| 121 | releaseSecond(); | 195 | releaseSecond(); |
| 122 | await vi.advanceTimersByTimeAsync(2 * 60 * 1000 - 1); | 196 | await vi.advanceTimersByTimeAsync(2 * 60 * 1000); |
| 123 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); | 197 | expect(mockThreadUnsubscribe).not.toHaveBeenCalled(); |
| 124 | 198 | ||
| 125 | await vi.advanceTimersByTimeAsync(1); | 199 | await vi.advanceTimersByTimeAsync(28 * 60 * 1000); |
| 126 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); | 200 | expect(mockThreadUnsubscribe).toHaveBeenCalledTimes(1); |
| 127 | 201 | ||
| 128 | stop(); | 202 | stop(); |
| 129 | await resetEnvironmentServiceForTests(); | 203 | await resetEnvironmentServiceForTests(); |
| 130 | }); | 204 | }); |
| 131 | 205 | ||
| 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 | |||
| 132 | it("disposes cached thread detail subscriptions when the environment service resets", async () => { | 279 | it("disposes cached thread detail subscriptions when the environment service resets", async () => { |
| 133 | const { | 280 | const { |
| 134 | retainThreadDetailSubscription, | 281 | retainThreadDetailSubscription, |
apps/web/src/environments/runtime/service.ts+113-6| ... | @@ -54,6 +54,7 @@ import { createEnvironmentConnection, type EnvironmentConnection } from "./conne | ... | @@ -54,6 +54,7 @@ import { createEnvironmentConnection, type EnvironmentConnection } from "./conne |
| 54 | import { | 54 | import { |
| 55 | useStore, | 55 | useStore, |
| 56 | selectProjectsAcrossEnvironments, | 56 | selectProjectsAcrossEnvironments, |
| 57 | selectSidebarThreadSummaryByRef, | ||
| 57 | selectThreadByRef, | 58 | selectThreadByRef, |
| 58 | selectThreadsAcrossEnvironments, | 59 | selectThreadsAcrossEnvironments, |
| 59 | } from "~/store"; | 60 | } from "~/store"; |
| ... | @@ -86,8 +87,15 @@ const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry> | ... | @@ -86,8 +87,15 @@ const threadDetailSubscriptions = new Map<string, ThreadDetailSubscriptionEntry> |
| 86 | let activeService: EnvironmentServiceState | null = null; | 87 | let activeService: EnvironmentServiceState | null = null; |
| 87 | let needsProviderInvalidation = false; | 88 | let needsProviderInvalidation = false; |
| 88 | 89 | ||
| 89 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 2 * 60 * 1000; | 90 | // Thread detail subscription cache policy: |
| 90 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 8; | 91 | // - Active consumers keep a subscription retained via refCount. |
| 92 | // - Released subscriptions stay warm for a longer idle TTL to avoid churn | ||
| 93 | // while moving around the UI. | ||
| 94 | // - Threads with active work or pending user action are sticky and are never | ||
| 95 | // evicted while they remain non-idle. | ||
| 96 | // - Capacity eviction only targets idle cached subscriptions. | ||
| 97 | const THREAD_DETAIL_SUBSCRIPTION_IDLE_EVICTION_MS = 15 * 60 * 1000; | ||
| 98 | const MAX_CACHED_THREAD_DETAIL_SUBSCRIPTIONS = 32; | ||
| 91 | const NOOP = () => undefined; | 99 | const NOOP = () => undefined; |
| 92 | 100 | ||
| 93 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { | 101 | function getThreadDetailSubscriptionKey(environmentId: EnvironmentId, threadId: ThreadId): string { |
| ... | @@ -104,6 +112,55 @@ function clearThreadDetailSubscriptionEviction( | ... | @@ -104,6 +112,55 @@ function clearThreadDetailSubscriptionEviction( |
| 104 | return entry; | 112 | return entry; |
| 105 | } | 113 | } |
| 106 | 114 | ||
| 115 | function isNonIdleThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | ||
| 116 | const threadRef = scopeThreadRef(entry.environmentId, entry.threadId); | ||
| 117 | const state = useStore.getState(); | ||
| 118 | const sidebarThread = selectSidebarThreadSummaryByRef(state, threadRef); | ||
| 119 | |||
| 120 | // Prefer shell/sidebar state first because it carries the coarse thread | ||
| 121 | // readiness flags used throughout the UI (pending approvals/input/plan). | ||
| 122 | if (sidebarThread) { | ||
| 123 | if ( | ||
| 124 | sidebarThread.hasPendingApprovals || | ||
| 125 | sidebarThread.hasPendingUserInput || | ||
| 126 | sidebarThread.hasActionableProposedPlan | ||
| 127 | ) { | ||
| 128 | return true; | ||
| 129 | } | ||
| 130 | |||
| 131 | const orchestrationStatus = sidebarThread.session?.orchestrationStatus; | ||
| 132 | if ( | ||
| 133 | orchestrationStatus && | ||
| 134 | orchestrationStatus !== "idle" && | ||
| 135 | orchestrationStatus !== "stopped" | ||
| 136 | ) { | ||
| 137 | return true; | ||
| 138 | } | ||
| 139 | |||
| 140 | if (sidebarThread.latestTurn?.state === "running") { | ||
| 141 | return true; | ||
| 142 | } | ||
| 143 | } | ||
| 144 | |||
| 145 | const thread = selectThreadByRef(state, threadRef); | ||
| 146 | if (!thread) { | ||
| 147 | return false; | ||
| 148 | } | ||
| 149 | |||
| 150 | const orchestrationStatus = thread.session?.orchestrationStatus; | ||
| 151 | return ( | ||
| 152 | Boolean( | ||
| 153 | orchestrationStatus && orchestrationStatus !== "idle" && orchestrationStatus !== "stopped", | ||
| 154 | ) || | ||
| 155 | thread.latestTurn?.state === "running" || | ||
| 156 | thread.pendingSourceProposedPlan !== undefined | ||
| 157 | ); | ||
| 158 | } | ||
| 159 | |||
| 160 | function shouldEvictThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | ||
| 161 | return entry.refCount === 0 && !isNonIdleThreadDetailSubscription(entry); | ||
| 162 | } | ||
| 163 | |||
| 107 | function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { | 164 | function attachThreadDetailSubscription(entry: ThreadDetailSubscriptionEntry): boolean { |
| 108 | if (entry.unsubscribeConnectionListener !== null) { | 165 | if (entry.unsubscribeConnectionListener !== null) { |
| 109 | entry.unsubscribeConnectionListener(); | 166 | entry.unsubscribeConnectionListener(); |
| ... | @@ -181,11 +238,20 @@ function reconcileThreadDetailSubscriptionsForEnvironment( | ... | @@ -181,11 +238,20 @@ function reconcileThreadDetailSubscriptionsForEnvironment( |
| 181 | 238 | ||
| 182 | function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { | 239 | function scheduleThreadDetailSubscriptionEviction(entry: ThreadDetailSubscriptionEntry): void { |
| 183 | clearThreadDetailSubscriptionEviction(entry); | 240 | clearThreadDetailSubscriptionEviction(entry); |
| 241 | if (!shouldEvictThreadDetailSubscription(entry)) { | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | |||
| 184 | entry.evictionTimeoutId = setTimeout(() => { | 245 | entry.evictionTimeoutId = setTimeout(() => { |
| 185 | const currentEntry = threadDetailSubscriptions.get( | 246 | const currentEntry = threadDetailSubscriptions.get( |
| 186 | getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), | 247 | getThreadDetailSubscriptionKey(entry.environmentId, entry.threadId), |
| 187 | ); | 248 | ); |
| 188 | if (!currentEntry || currentEntry.refCount > 0) { | 249 | if (!currentEntry) { |
| 250 | return; | ||
| 251 | } | ||
| 252 | |||
| 253 | currentEntry.evictionTimeoutId = null; | ||
| 254 | if (!shouldEvictThreadDetailSubscription(currentEntry)) { | ||
| 189 | return; | 255 | return; |
| 190 | } | 256 | } |
| 191 | disposeThreadDetailSubscriptionByKey( | 257 | disposeThreadDetailSubscriptionByKey( |
| ... | @@ -200,7 +266,7 @@ function evictIdleThreadDetailSubscriptionsToCapacity(): void { | ... | @@ -200,7 +266,7 @@ function evictIdleThreadDetailSubscriptionsToCapacity(): void { |
| 200 | } | 266 | } |
| 201 | 267 | ||
| 202 | const idleEntries = [...threadDetailSubscriptions.entries()] | 268 | const idleEntries = [...threadDetailSubscriptions.entries()] |
| 203 | .filter(([, entry]) => entry.refCount === 0) | 269 | .filter(([, entry]) => shouldEvictThreadDetailSubscription(entry)) |
| 204 | .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); | 270 | .toSorted(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt); |
| 205 | 271 | ||
| 206 | for (const [key] of idleEntries) { | 272 | for (const [key] of idleEntries) { |
| ... | @@ -211,6 +277,42 @@ function evictIdleThreadDetailSubscriptionsToCapacity(): void { | ... | @@ -211,6 +277,42 @@ function evictIdleThreadDetailSubscriptionsToCapacity(): void { |
| 211 | } | 277 | } |
| 212 | } | 278 | } |
| 213 | 279 | ||
| 280 | function reconcileThreadDetailSubscriptionEvictionState( | ||
| 281 | entry: ThreadDetailSubscriptionEntry, | ||
| 282 | ): void { | ||
| 283 | clearThreadDetailSubscriptionEviction(entry); | ||
| 284 | if (!shouldEvictThreadDetailSubscription(entry)) { | ||
| 285 | return; | ||
| 286 | } | ||
| 287 | |||
| 288 | scheduleThreadDetailSubscriptionEviction(entry); | ||
| 289 | } | ||
| 290 | |||
| 291 | function reconcileThreadDetailSubscriptionEvictionForThread( | ||
| 292 | environmentId: EnvironmentId, | ||
| 293 | threadId: ThreadId, | ||
| 294 | ): void { | ||
| 295 | const entry = threadDetailSubscriptions.get( | ||
| 296 | getThreadDetailSubscriptionKey(environmentId, threadId), | ||
| 297 | ); | ||
| 298 | if (!entry) { | ||
| 299 | return; | ||
| 300 | } | ||
| 301 | |||
| 302 | reconcileThreadDetailSubscriptionEvictionState(entry); | ||
| 303 | } | ||
| 304 | |||
| 305 | function reconcileThreadDetailSubscriptionEvictionForEnvironment( | ||
| 306 | environmentId: EnvironmentId, | ||
| 307 | ): void { | ||
| 308 | for (const entry of threadDetailSubscriptions.values()) { | ||
| 309 | if (entry.environmentId === environmentId) { | ||
| 310 | reconcileThreadDetailSubscriptionEvictionState(entry); | ||
| 311 | } | ||
| 312 | } | ||
| 313 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 314 | } | ||
| 315 | |||
| 214 | export function retainThreadDetailSubscription( | 316 | export function retainThreadDetailSubscription( |
| 215 | environmentId: EnvironmentId, | 317 | environmentId: EnvironmentId, |
| 216 | threadId: ThreadId, | 318 | threadId: ThreadId, |
| ... | @@ -233,7 +335,7 @@ export function retainThreadDetailSubscription( | ... | @@ -233,7 +335,7 @@ export function retainThreadDetailSubscription( |
| 233 | existing.refCount = Math.max(0, existing.refCount - 1); | 335 | existing.refCount = Math.max(0, existing.refCount - 1); |
| 234 | existing.lastAccessedAt = Date.now(); | 336 | existing.lastAccessedAt = Date.now(); |
| 235 | if (existing.refCount === 0) { | 337 | if (existing.refCount === 0) { |
| 236 | scheduleThreadDetailSubscriptionEviction(existing); | 338 | reconcileThreadDetailSubscriptionEvictionState(existing); |
| 237 | evictIdleThreadDetailSubscriptionsToCapacity(); | 339 | evictIdleThreadDetailSubscriptionsToCapacity(); |
| 238 | } | 340 | } |
| 239 | }; | 341 | }; |
| ... | @@ -263,7 +365,7 @@ export function retainThreadDetailSubscription( | ... | @@ -263,7 +365,7 @@ export function retainThreadDetailSubscription( |
| 263 | entry.refCount = Math.max(0, entry.refCount - 1); | 365 | entry.refCount = Math.max(0, entry.refCount - 1); |
| 264 | entry.lastAccessedAt = Date.now(); | 366 | entry.lastAccessedAt = Date.now(); |
| 265 | if (entry.refCount === 0) { | 367 | if (entry.refCount === 0) { |
| 266 | scheduleThreadDetailSubscriptionEviction(entry); | 368 | reconcileThreadDetailSubscriptionEvictionState(entry); |
| 267 | evictIdleThreadDetailSubscriptionsToCapacity(); | 369 | evictIdleThreadDetailSubscriptionsToCapacity(); |
| 268 | } | 370 | } |
| 269 | }; | 371 | }; |
| ... | @@ -473,6 +575,8 @@ function applyRecoveredEventBatch( | ... | @@ -473,6 +575,8 @@ function applyRecoveredEventBatch( |
| 473 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { | 575 | for (const threadId of batchEffects.removeTerminalStateThreadIds) { |
| 474 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); | 576 | useTerminalStateStore.getState().removeTerminalState(scopeThreadRef(environmentId, threadId)); |
| 475 | } | 577 | } |
| 578 | |||
| 579 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | ||
| 476 | } | 580 | } |
| 477 | 581 | ||
| 478 | export function applyEnvironmentThreadDetailEvent( | 582 | export function applyEnvironmentThreadDetailEvent( |
| ... | @@ -507,6 +611,8 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En | ... | @@ -507,6 +611,8 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En |
| 507 | if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { | 611 | if (previousThread?.archivedAt === null && event.thread.archivedAt !== null && threadRef) { |
| 508 | useTerminalStateStore.getState().removeTerminalState(threadRef); | 612 | useTerminalStateStore.getState().removeTerminalState(threadRef); |
| 509 | } | 613 | } |
| 614 | reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); | ||
| 615 | evictIdleThreadDetailSubscriptionsToCapacity(); | ||
| 510 | return; | 616 | return; |
| 511 | case "thread-removed": | 617 | case "thread-removed": |
| 512 | if (threadRef) { | 618 | if (threadRef) { |
| ... | @@ -529,6 +635,7 @@ function createEnvironmentConnectionHandlers() { | ... | @@ -529,6 +635,7 @@ function createEnvironmentConnectionHandlers() { |
| 529 | environmentId, | 635 | environmentId, |
| 530 | snapshot.threads.map((thread) => thread.id), | 636 | snapshot.threads.map((thread) => thread.id), |
| 531 | ); | 637 | ); |
| 638 | reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId); | ||
| 532 | reconcileSnapshotDerivedState(); | 639 | reconcileSnapshotDerivedState(); |
| 533 | }, | 640 | }, |
| 534 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { | 641 | applyTerminalEvent: (event: TerminalEvent, environmentId: EnvironmentId) => { |