| author | |
| committer | |
| log | 252df7742047af8a1b859dc652b2f40b639d16b3 |
| tree | ef4367e7292b1bdc1f22858d167b8e9f68d02276 |
| parent | 5b0c923ea3413c038f32ac4c523ae96863443f9f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>5 files changed, 184 insertions(+), 38 deletions(-)
apps/web/src/components/Sidebar.motion.test.ts+25| ... | ... | @@ -358,6 +358,31 @@ describe("sidebar list motion", () => { |
| 358 | 358 | expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); |
| 359 | 359 | }); |
| 360 | 360 | |
| 361 | it("skips fades when a large list change would clone too many rows", () => { | |
| 362 | const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); | |
| 363 | const { motion, layout, parent } = fixture(rows); | |
| 364 | motion.update(true); | |
| 365 | layout([]); | |
| 366 | motion.update(true); | |
| 367 | expect(rows.every((row) => row.clones.length === 0)).toBe(true); | |
| 368 | expect(parent.children).toHaveLength(0); | |
| 369 | const entering = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); | |
| 370 | layout(entering); | |
| 371 | motion.update(true); | |
| 372 | expect(entering.every((row) => row.animate.mock.calls.length === 0)).toBe(true); | |
| 373 | }); | |
| 374 | ||
| 375 | it("still slides rows when one removal displaces a large list", () => { | |
| 376 | const rows = Array.from({ length: 60 }, (_, index) => new TestRow(`row-${index}`)); | |
| 377 | const { motion, layout } = fixture(rows); | |
| 378 | motion.update(true); | |
| 379 | const [removed, ...rest] = rows; | |
| 380 | layout(rest); | |
| 381 | motion.update(true); | |
| 382 | expect(removed!.clones).toHaveLength(1); | |
| 383 | expect(rest.every((row) => row.animations.length === 1)).toBe(true); | |
| 384 | }); | |
| 385 | ||
| 361 | 386 | it("respects reduced motion while keeping the next baseline fresh", () => { |
| 362 | 387 | const a = new TestRow("a"); |
| 363 | 388 | const b = new TestRow("b"); |
apps/web/src/components/Sidebar.motion.ts+20-1| ... | ... | @@ -1,4 +1,10 @@ |
| 1 | 1 | const motionTiming = { duration: 150, easing: "ease-out" }; |
| 2 | // A project filter change or a bulk snooze swaps a large part of the list at | |
| 3 | // once. Fades are the expensive part: every removed row gets a deep clone and | |
| 4 | // every clone and entering row gets its own animation, and the layout reads | |
| 5 | // in between force synchronous reflows. Translating displaced rows is cheap, | |
| 6 | // so only the fade count decides whether an update animates. | |
| 7 | const MAX_FADED_ROWS_PER_UPDATE = 40; | |
| 2 | 8 | |
| 3 | 9 | type RowPosition = { top: number; left: number; width: number; height: number }; |
| 4 | 10 | |
| ... | ... | @@ -124,7 +130,20 @@ export function createSidebarListMotion(parent: HTMLUListElement) { |
| 124 | 130 | }, |
| 125 | 131 | ]), |
| 126 | 132 | ); |
| 127 | const shouldAnimate = animate && positions !== null && !reducedMotion?.matches; | |
| 133 | let fadeCount = 0; | |
| 134 | if (positions !== null) { | |
| 135 | for (const [node, position] of positions) { | |
| 136 | if (!next.has(node) && position.height > 0) fadeCount++; | |
| 137 | } | |
| 138 | for (const [node, position] of next) { | |
| 139 | if (!positions.has(node) && position.height > 0) fadeCount++; | |
| 140 | } | |
| 141 | } | |
| 142 | const shouldAnimate = | |
| 143 | animate && | |
| 144 | positions !== null && | |
| 145 | !reducedMotion?.matches && | |
| 146 | fadeCount <= MAX_FADED_ROWS_PER_UPDATE; | |
| 128 | 147 | if (!shouldAnimate) clearFades(); |
| 129 | 148 | else { |
| 130 | 149 | for (const [node, position] of positions!) { |
apps/web/src/components/Sidebar.tsx+7-1| ... | ... | @@ -502,7 +502,13 @@ function SortableThreadRow(props: { |
| 502 | 502 | disabled: { draggable: props.disabled }, |
| 503 | 503 | animateLayoutChanges: animateSidebarLayoutChanges, |
| 504 | 504 | }); |
| 505 | return props.children({ listeners, setNodeRef, transform, transition, isDragging }); | |
| 505 | // dnd-kit memoizes each field but not the bag, so the memoized row would | |
| 506 | // rerender on every shell update without this. | |
| 507 | const bag = useMemo( | |
| 508 | () => ({ listeners, setNodeRef, transform, transition, isDragging }), | |
| 509 | [listeners, setNodeRef, transform, transition, isDragging], | |
| 510 | ); | |
| 511 | return props.children(bag); | |
| 506 | 512 | } |
| 507 | 513 | |
| 508 | 514 | // Unsent work shares one look: the new-thread draft rows and thread rows |
packages/client-runtime/src/state/shell-sync.test.ts+89| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 9 | 10 | import * as Option from "effect/Option"; |
| 10 | 11 | import * as Queue from "effect/Queue"; |
| 11 | 12 | import * as Ref from "effect/Ref"; |
| ... | ... | @@ -151,6 +152,94 @@ describe("environment shell synchronization", () => { |
| 151 | 152 | }), |
| 152 | 153 | ); |
| 153 | 154 | |
| 155 | it.live.each([ | |
| 156 | { bufferSize: Infinity, expectedSequences: [51] }, | |
| 157 | // RpcClient defaults to a 16-event buffer, which splits larger server chunks. | |
| 158 | { bufferSize: 16, expectedSequences: [17, 33, 49, 51] }, | |
| 159 | ])("batches live events with a $bufferSize event buffer", ({ bufferSize, expectedSequences }) => | |
| 160 | Effect.gen(function* () { | |
| 161 | const events = yield* Queue.bounded<OrchestrationShellStreamItem>(bufferSize); | |
| 162 | const client = { | |
| 163 | [ORCHESTRATION_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | |
| 164 | } as unknown as WsRpcProtocolClient; | |
| 165 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | |
| 166 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | |
| 167 | Option.some(session(client)), | |
| 168 | ); | |
| 169 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | |
| 170 | target: TARGET, | |
| 171 | state: supervisorState, | |
| 172 | session: activeSession, | |
| 173 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | |
| 174 | connect: Effect.void, | |
| 175 | disconnect: Effect.void, | |
| 176 | retryNow: Effect.void, | |
| 177 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | |
| 178 | const cache = Persistence.EnvironmentCacheStore.of({ | |
| 179 | loadShell: () => Effect.succeed(Option.none()), | |
| 180 | saveShell: () => Effect.void, | |
| 181 | loadThread: () => Effect.succeed(Option.none()), | |
| 182 | saveThread: () => Effect.void, | |
| 183 | removeThread: () => Effect.void, | |
| 184 | loadServerConfig: () => Effect.succeed(Option.none()), | |
| 185 | saveServerConfig: () => Effect.void, | |
| 186 | loadVcsRefs: () => Effect.succeed(Option.none()), | |
| 187 | saveVcsRefs: () => Effect.void, | |
| 188 | removeVcsRefs: () => Effect.void, | |
| 189 | clearVcsRefs: () => Effect.void, | |
| 190 | clear: () => Effect.void, | |
| 191 | }); | |
| 192 | const shellState = yield* makeEnvironmentShellState().pipe( | |
| 193 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 194 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | |
| 195 | Effect.provideService( | |
| 196 | ShellSnapshotLoader, | |
| 197 | ShellSnapshotLoader.of({ load: () => Effect.succeed(Option.none()) }), | |
| 198 | ), | |
| 199 | ); | |
| 200 | yield* SubscriptionRef.set(supervisorState, { | |
| 201 | desired: true, | |
| 202 | network: "online", | |
| 203 | phase: "connected", | |
| 204 | stage: null, | |
| 205 | attempt: 1, | |
| 206 | generation: 1, | |
| 207 | lastFailure: null, | |
| 208 | retryAt: null, | |
| 209 | }); | |
| 210 | yield* Queue.offer(events, { kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT }); | |
| 211 | yield* Queue.offer(events, { kind: "synchronized" }); | |
| 212 | yield* SubscriptionRef.changes(shellState).pipe( | |
| 213 | Stream.filter((state) => state.status === "live"), | |
| 214 | Stream.runHead, | |
| 215 | ); | |
| 216 | ||
| 217 | // Observe before publishing so no batch can arrive before the subscription. | |
| 218 | const observed = yield* SubscriptionRef.changes(shellState).pipe( | |
| 219 | Stream.drop(1), | |
| 220 | Stream.takeUntil( | |
| 221 | (state) => Option.isSome(state.snapshot) && state.snapshot.value.threads.length === 50, | |
| 222 | ), | |
| 223 | Stream.runCollect, | |
| 224 | Effect.forkScoped({ startImmediately: true }), | |
| 225 | ); | |
| 226 | yield* Queue.offerAll( | |
| 227 | events, | |
| 228 | Array.from({ length: 50 }, (_, index) => ({ | |
| 229 | kind: "thread-upserted" as const, | |
| 230 | sequence: 2 + index, | |
| 231 | thread: { id: `thread-${index}` } as never, | |
| 232 | })), | |
| 233 | ); | |
| 234 | const states = yield* Fiber.join(observed); | |
| 235 | const snapshots = states.map((state) => Option.getOrThrow(state.snapshot)); | |
| 236 | expect(snapshots.map((snapshot) => snapshot.snapshotSequence)).toEqual(expectedSequences); | |
| 237 | expect(snapshots.at(-1)!.threads.map((thread) => thread.id)).toEqual( | |
| 238 | Array.from({ length: 50 }, (_, index) => `thread-${index}`), | |
| 239 | ); | |
| 240 | }).pipe(Effect.scoped), | |
| 241 | ); | |
| 242 | ||
| 154 | 243 | it.effect("requests a full socket snapshot when the HTTP refresh fails", () => |
| 155 | 244 | Effect.gen(function* () { |
| 156 | 245 | const cachedSnapshot: OrchestrationShellSnapshot = { |
packages/client-runtime/src/state/shell.ts+43-36| ... | ... | @@ -135,47 +135,54 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 135 | 135 | ), |
| 136 | 136 | ); |
| 137 | 137 | |
| 138 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( | |
| 139 | item: OrchestrationShellStreamItem, | |
| 138 | // Apply each received batch with one state write. The RPC client's bounded | |
| 139 | // buffer can split a server chunk, so a bulk action can still need several | |
| 140 | // writes, but each write includes every event in that batch. | |
| 141 | const applyItems = Effect.fn("EnvironmentShellState.applyItems")(function* ( | |
| 142 | items: ReadonlyArray<OrchestrationShellStreamItem>, | |
| 140 | 143 | ) { |
| 141 | if (item.kind === "synchronized") { | |
| 142 | yield* Ref.set(awaitingCompletion, false); | |
| 143 | yield* SubscriptionRef.update(state, (current) => | |
| 144 | Option.isSome(current.snapshot) | |
| 145 | ? { ...current, status: "live" as const, error: Option.none() } | |
| 146 | : current, | |
| 147 | ); | |
| 148 | return; | |
| 149 | } | |
| 150 | ||
| 151 | const current = yield* SubscriptionRef.get(state); | |
| 152 | const nextSnapshot = | |
| 153 | item.kind === "snapshot" | |
| 154 | ? item.snapshot | |
| 155 | : Option.match(current.snapshot, { | |
| 156 | onNone: () => null, | |
| 157 | onSome: (snapshot) => | |
| 158 | item.sequence > snapshot.snapshotSequence | |
| 159 | ? applyShellStreamEvent(snapshot, item) | |
| 160 | : snapshot, | |
| 161 | }); | |
| 162 | if (nextSnapshot === null) { | |
| 163 | return; | |
| 144 | const initial = yield* SubscriptionRef.get(state); | |
| 145 | let waiting = yield* Ref.get(awaitingCompletion); | |
| 146 | let next = initial; | |
| 147 | let receivedSnapshot = false; | |
| 148 | for (const item of items) { | |
| 149 | if (item.kind === "synchronized") { | |
| 150 | waiting = false; | |
| 151 | if (Option.isSome(next.snapshot)) { | |
| 152 | next = { ...next, status: "live", error: Option.none() }; | |
| 153 | } | |
| 154 | continue; | |
| 155 | } | |
| 156 | const nextSnapshot = | |
| 157 | item.kind === "snapshot" | |
| 158 | ? item.snapshot | |
| 159 | : Option.match(next.snapshot, { | |
| 160 | onNone: () => null, | |
| 161 | onSome: (snapshot) => | |
| 162 | item.sequence > snapshot.snapshotSequence | |
| 163 | ? applyShellStreamEvent(snapshot, item) | |
| 164 | : snapshot, | |
| 165 | }); | |
| 166 | if (nextSnapshot === null) continue; | |
| 167 | receivedSnapshot ||= item.kind === "snapshot"; | |
| 168 | next = { | |
| 169 | snapshot: Option.some(nextSnapshot), | |
| 170 | status: waiting ? "synchronizing" : "live", | |
| 171 | error: Option.none(), | |
| 172 | }; | |
| 164 | 173 | } |
| 165 | ||
| 166 | const waiting = yield* Ref.get(awaitingCompletion); | |
| 167 | yield* SubscriptionRef.set(state, { | |
| 168 | snapshot: Option.some(nextSnapshot), | |
| 169 | status: waiting ? "synchronizing" : "live", | |
| 170 | error: Option.none(), | |
| 171 | }); | |
| 172 | if (item.kind === "snapshot") { | |
| 174 | yield* Ref.set(awaitingCompletion, waiting); | |
| 175 | if (next === initial) return; | |
| 176 | yield* SubscriptionRef.set(state, next); | |
| 177 | if (receivedSnapshot) { | |
| 173 | 178 | const session = yield* Ref.get(activeSubscriptionSession); |
| 174 | 179 | if (session !== null) { |
| 175 | 180 | yield* Ref.set(lastAuthoritativeSession, session); |
| 176 | 181 | } |
| 177 | 182 | } |
| 178 | yield* Queue.offer(persistence, nextSnapshot); | |
| 183 | if (next.snapshot !== initial.snapshot && Option.isSome(next.snapshot)) { | |
| 184 | yield* Queue.offer(persistence, next.snapshot.value); | |
| 185 | } | |
| 179 | 186 | }); |
| 180 | 187 | |
| 181 | 188 | const foregroundResubscriptions = Option.match(wakeups, { |
| ... | ... | @@ -220,7 +227,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 220 | 227 | ); |
| 221 | 228 | const httpSnapshot = yield* snapshotLoader.load(prepared); |
| 222 | 229 | if (Option.isSome(httpSnapshot)) { |
| 223 | yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); | |
| 230 | yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); | |
| 224 | 231 | canResume = true; |
| 225 | 232 | current = yield* SubscriptionRef.get(state); |
| 226 | 233 | } |
| ... | ... | @@ -250,7 +257,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 250 | 257 | retryExpectedFailureAfter: "250 millis", |
| 251 | 258 | resubscribe: foregroundResubscriptions, |
| 252 | 259 | }, |
| 253 | ).pipe(Stream.runForEach(applyItem)), | |
| 260 | ).pipe(Stream.runForEachArray(applyItems)), | |
| 254 | 261 | ); |
| 255 | 262 | yield* SubscriptionRef.changes(supervisor.state).pipe( |
| 256 | 263 | Stream.runForEach((connectionState) => { |