| 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,6 +358,31 @@ describe("sidebar list motion", () => { |
| 358 | expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); | 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 | it("respects reduced motion while keeping the next baseline fresh", () => { | 386 | it("respects reduced motion while keeping the next baseline fresh", () => { |
| 362 | const a = new TestRow("a"); | 387 | const a = new TestRow("a"); |
| 363 | const b = new TestRow("b"); | 388 | const b = new TestRow("b"); |
apps/web/src/components/Sidebar.motion.ts+20-1| ... | @@ -1,4 +1,10 @@ | ... | @@ -1,4 +1,10 @@ |
| 1 | const motionTiming = { duration: 150, easing: "ease-out" }; | 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 | type RowPosition = { top: number; left: number; width: number; height: number }; | 9 | type RowPosition = { top: number; left: number; width: number; height: number }; |
| 4 | 10 | ||
| ... | @@ -124,7 +130,20 @@ export function createSidebarListMotion(parent: HTMLUListElement) { | ... | @@ -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 | if (!shouldAnimate) clearFades(); | 147 | if (!shouldAnimate) clearFades(); |
| 129 | else { | 148 | else { |
| 130 | for (const [node, position] of positions!) { | 149 | for (const [node, position] of positions!) { |
apps/web/src/components/Sidebar.tsx+7-1| ... | @@ -502,7 +502,13 @@ function SortableThreadRow(props: { | ... | @@ -502,7 +502,13 @@ function SortableThreadRow(props: { |
| 502 | disabled: { draggable: props.disabled }, | 502 | disabled: { draggable: props.disabled }, |
| 503 | animateLayoutChanges: animateSidebarLayoutChanges, | 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 | // Unsent work shares one look: the new-thread draft rows and thread rows | 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 +6,7 @@ import { |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; | 11 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; | 12 | import * as Ref from "effect/Ref"; |
| ... | @@ -151,6 +152,94 @@ describe("environment shell synchronization", () => { | ... | @@ -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 | it.effect("requests a full socket snapshot when the HTTP refresh fails", () => | 243 | it.effect("requests a full socket snapshot when the HTTP refresh fails", () => |
| 155 | Effect.gen(function* () { | 244 | Effect.gen(function* () { |
| 156 | const cachedSnapshot: OrchestrationShellSnapshot = { | 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,47 +135,54 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 135 | ), | 135 | ), |
| 136 | ); | 136 | ); |
| 137 | 137 | ||
| 138 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( | 138 | // Apply each received batch with one state write. The RPC client's bounded |
| 139 | item: OrchestrationShellStreamItem, | 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") { | 144 | const initial = yield* SubscriptionRef.get(state); |
| 142 | yield* Ref.set(awaitingCompletion, false); | 145 | let waiting = yield* Ref.get(awaitingCompletion); |
| 143 | yield* SubscriptionRef.update(state, (current) => | 146 | let next = initial; |
| 144 | Option.isSome(current.snapshot) | 147 | let receivedSnapshot = false; |
| 145 | ? { ...current, status: "live" as const, error: Option.none() } | 148 | for (const item of items) { |
| 146 | : current, | 149 | if (item.kind === "synchronized") { |
| 147 | ); | 150 | waiting = false; |
| 148 | return; | 151 | if (Option.isSome(next.snapshot)) { |
| 149 | } | 152 | next = { ...next, status: "live", error: Option.none() }; |
| 150 | 153 | } | |
| 151 | const current = yield* SubscriptionRef.get(state); | 154 | continue; |
| 152 | const nextSnapshot = | 155 | } |
| 153 | item.kind === "snapshot" | 156 | const nextSnapshot = |
| 154 | ? item.snapshot | 157 | item.kind === "snapshot" |
| 155 | : Option.match(current.snapshot, { | 158 | ? item.snapshot |
| 156 | onNone: () => null, | 159 | : Option.match(next.snapshot, { |
| 157 | onSome: (snapshot) => | 160 | onNone: () => null, |
| 158 | item.sequence > snapshot.snapshotSequence | 161 | onSome: (snapshot) => |
| 159 | ? applyShellStreamEvent(snapshot, item) | 162 | item.sequence > snapshot.snapshotSequence |
| 160 | : snapshot, | 163 | ? applyShellStreamEvent(snapshot, item) |
| 161 | }); | 164 | : snapshot, |
| 162 | if (nextSnapshot === null) { | 165 | }); |
| 163 | return; | 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 | 174 | yield* Ref.set(awaitingCompletion, waiting); | |
| 166 | const waiting = yield* Ref.get(awaitingCompletion); | 175 | if (next === initial) return; |
| 167 | yield* SubscriptionRef.set(state, { | 176 | yield* SubscriptionRef.set(state, next); |
| 168 | snapshot: Option.some(nextSnapshot), | 177 | if (receivedSnapshot) { |
| 169 | status: waiting ? "synchronizing" : "live", | ||
| 170 | error: Option.none(), | ||
| 171 | }); | ||
| 172 | if (item.kind === "snapshot") { | ||
| 173 | const session = yield* Ref.get(activeSubscriptionSession); | 178 | const session = yield* Ref.get(activeSubscriptionSession); |
| 174 | if (session !== null) { | 179 | if (session !== null) { |
| 175 | yield* Ref.set(lastAuthoritativeSession, session); | 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 | const foregroundResubscriptions = Option.match(wakeups, { | 188 | const foregroundResubscriptions = Option.match(wakeups, { |
| ... | @@ -220,7 +227,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -220,7 +227,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 220 | ); | 227 | ); |
| 221 | const httpSnapshot = yield* snapshotLoader.load(prepared); | 228 | const httpSnapshot = yield* snapshotLoader.load(prepared); |
| 222 | if (Option.isSome(httpSnapshot)) { | 229 | if (Option.isSome(httpSnapshot)) { |
| 223 | yield* applyItem({ kind: "snapshot", snapshot: httpSnapshot.value }); | 230 | yield* applyItems([{ kind: "snapshot", snapshot: httpSnapshot.value }]); |
| 224 | canResume = true; | 231 | canResume = true; |
| 225 | current = yield* SubscriptionRef.get(state); | 232 | current = yield* SubscriptionRef.get(state); |
| 226 | } | 233 | } |
| ... | @@ -250,7 +257,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -250,7 +257,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 250 | retryExpectedFailureAfter: "250 millis", | 257 | retryExpectedFailureAfter: "250 millis", |
| 251 | resubscribe: foregroundResubscriptions, | 258 | resubscribe: foregroundResubscriptions, |
| 252 | }, | 259 | }, |
| 253 | ).pipe(Stream.runForEach(applyItem)), | 260 | ).pipe(Stream.runForEachArray(applyItems)), |
| 254 | ); | 261 | ); |
| 255 | yield* SubscriptionRef.changes(supervisor.state).pipe( | 262 | yield* SubscriptionRef.changes(supervisor.state).pipe( |
| 256 | Stream.runForEach((connectionState) => { | 263 | Stream.runForEach((connectionState) => { |