| author | |
| committer | |
| log | ce4712d5b04fb998f79fe132245289191147e5d5 |
| tree | 188e6ccfd1fe9239a056400778cd098416820d20 |
| parent | 940e8233c227a186044078e99e45e1933eb525e4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Restore status feedback, code-view worker reuse, streaming highlighting recovery, safe outbox recovery, current HTML, and projected live-event budgets.
Verified with browser checks, iOS outbox recovery, 281 focused tests, and full CI. CI uses Shivam's HTTPS mirror fix.
Created with GPT-6 Astra (preview) in Codex.
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>31 files changed, 1511 insertions(+), 204 deletions(-)
.github/workflows/ci.yml+6-2| ... | ... | @@ -52,7 +52,9 @@ jobs: |
| 52 | 52 | run: vpr typecheck |
| 53 | 53 | |
| 54 | 54 | - name: Install browser secret helper build libraries |
| 55 | run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 55 | run: | | |
| 56 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources | |
| 57 | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 56 | 58 | |
| 57 | 59 | - name: Build desktop pipeline |
| 58 | 60 | run: vp run build:desktop |
| ... | ... | @@ -89,7 +91,9 @@ jobs: |
| 89 | 91 | run: vp run --filter @t3tools/desktop ensure:electron |
| 90 | 92 | |
| 91 | 93 | - name: Install browser secret helper build libraries |
| 92 | run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 94 | run: | | |
| 95 | sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources | |
| 96 | sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config | |
| 93 | 97 | |
| 94 | 98 | - name: Test |
| 95 | 99 | run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test |
apps/marketing/src/lib/homeMotion.test.ts created+226| ... | ... | @@ -0,0 +1,226 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { startHomeMotion } from "./homeMotion"; | |
| 4 | ||
| 5 | class ElementStub extends EventTarget { | |
| 6 | properties = new Map<string, string>(); | |
| 7 | style = { setProperty: (name: string, value: string) => this.properties.set(name, value) }; | |
| 8 | children: ElementStub[] = []; | |
| 9 | scrollLeft = 0; | |
| 10 | scrollWidth = 1_200; | |
| 11 | clientWidth = 400; | |
| 12 | matches = () => false; | |
| 13 | contains = (target: EventTarget | null) => | |
| 14 | target === this || (target instanceof ElementStub && this.children.includes(target)); | |
| 15 | querySelectorAll = () => this.children; | |
| 16 | getBoundingClientRect = vi.fn(() => ({ left: 0, top: 0, width: 400, height: 600 })); | |
| 17 | scrollTo = vi.fn((options: ScrollToOptions) => { | |
| 18 | this.scrollLeft = options.left ?? this.scrollLeft; | |
| 19 | }); | |
| 20 | } | |
| 21 | ||
| 22 | let observers: ObserverStub[] = []; | |
| 23 | class ObserverStub { | |
| 24 | constructor(private readonly callback: IntersectionObserverCallback) { | |
| 25 | observers.push(this); | |
| 26 | } | |
| 27 | observe = vi.fn(); | |
| 28 | disconnect = vi.fn(); | |
| 29 | report(target: ElementStub, isIntersecting: boolean) { | |
| 30 | this.callback( | |
| 31 | [{ target, isIntersecting } as unknown as IntersectionObserverEntry], | |
| 32 | this as unknown as IntersectionObserver, | |
| 33 | ); | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | |
| 38 | let viewport = new EventTarget(); | |
| 39 | let reduced = Object.assign(new EventTarget(), { matches: false }); | |
| 40 | let fine = Object.assign(new EventTarget(), { matches: true }); | |
| 41 | let frames = new Map<number, FrameRequestCallback>(); | |
| 42 | let dispose: (() => void) | undefined; | |
| 43 | ||
| 44 | beforeEach(() => { | |
| 45 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | |
| 46 | observers = []; | |
| 47 | frames = new Map(); | |
| 48 | page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null }); | |
| 49 | viewport = new EventTarget(); | |
| 50 | reduced = Object.assign(new EventTarget(), { matches: false }); | |
| 51 | fine = Object.assign(new EventTarget(), { matches: true }); | |
| 52 | vi.stubGlobal("document", page); | |
| 53 | vi.stubGlobal( | |
| 54 | "window", | |
| 55 | Object.assign(viewport, { | |
| 56 | matchMedia: (query: string) => (query.includes("reduced-motion") ? reduced : fine), | |
| 57 | }), | |
| 58 | ); | |
| 59 | vi.stubGlobal("Node", ElementStub); | |
| 60 | vi.stubGlobal("IntersectionObserver", ObserverStub); | |
| 61 | let frameId = 0; | |
| 62 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | |
| 63 | frames.set(++frameId, callback); | |
| 64 | return frameId; | |
| 65 | }); | |
| 66 | vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); | |
| 67 | }); | |
| 68 | ||
| 69 | afterEach(() => { | |
| 70 | dispose?.(); | |
| 71 | dispose = undefined; | |
| 72 | vi.useRealTimers(); | |
| 73 | vi.unstubAllGlobals(); | |
| 74 | }); | |
| 75 | ||
| 76 | function fixture() { | |
| 77 | const hero = new ElementStub(); | |
| 78 | const field = new ElementStub(); | |
| 79 | const mark = new ElementStub(); | |
| 80 | const otherMark = new ElementStub(); | |
| 81 | field.children = [mark, otherMark]; | |
| 82 | const endorsements = new ElementStub(); | |
| 83 | const caret = new ElementStub(); | |
| 84 | dispose = startHomeMotion({ hero, field, endorsements, caret } as unknown as Parameters< | |
| 85 | typeof startHomeMotion | |
| 86 | >[0]); | |
| 87 | return { hero, field, mark, otherMark, endorsements, caret, observer: observers[0]! }; | |
| 88 | } | |
| 89 | ||
| 90 | function movePointer(hero: ElementStub, x = 400, y = 600) { | |
| 91 | hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y })); | |
| 92 | } | |
| 93 | ||
| 94 | describe("homepage motion", () => { | |
| 95 | it("gates each mark and caret and batches pointer input into one frame", () => { | |
| 96 | const { hero, field, mark, otherMark, caret, observer } = fixture(); | |
| 97 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | |
| 98 | observer.report(mark, true); | |
| 99 | observer.report(caret, true); | |
| 100 | expect(mark.properties.get("--home-motion-state")).toBe("running"); | |
| 101 | expect(otherMark.properties.get("--home-motion-state")).toBe("paused"); | |
| 102 | expect(caret.properties.get("--home-motion-state")).toBe("running"); | |
| 103 | ||
| 104 | movePointer(hero, 100, 100); | |
| 105 | movePointer(hero); | |
| 106 | expect(frames.size).toBe(1); | |
| 107 | expect(hero.getBoundingClientRect).not.toHaveBeenCalled(); | |
| 108 | const [id, callback] = [...frames][0]!; | |
| 109 | frames.delete(id); | |
| 110 | callback(0); | |
| 111 | expect(field.properties.get("--px")).toBe("18.0px"); | |
| 112 | expect(field.properties.get("--py")).toBe("14.0px"); | |
| 113 | ||
| 114 | movePointer(hero); | |
| 115 | page.visibilityState = "hidden"; | |
| 116 | page.dispatchEvent(new Event("visibilitychange")); | |
| 117 | expect(frames.size).toBe(0); | |
| 118 | expect(field.properties.get("--px")).toBe("0px"); | |
| 119 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | |
| 120 | expect(caret.properties.get("--home-motion-state")).toBe("paused"); | |
| 121 | page.visibilityState = "visible"; | |
| 122 | page.dispatchEvent(new Event("visibilitychange")); | |
| 123 | reduced.matches = true; | |
| 124 | reduced.dispatchEvent(new Event("change")); | |
| 125 | movePointer(hero); | |
| 126 | expect(frames.size).toBe(0); | |
| 127 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | |
| 128 | reduced.matches = false; | |
| 129 | fine.matches = false; | |
| 130 | reduced.dispatchEvent(new Event("change")); | |
| 131 | movePointer(hero); | |
| 132 | expect(frames.size).toBe(0); | |
| 133 | expect(mark.properties.get("--home-motion-state")).toBe("running"); | |
| 134 | }); | |
| 135 | ||
| 136 | it("pages every eight seconds, reverses at the end, and has no timer without overflow", () => { | |
| 137 | const { endorsements, observer } = fixture(); | |
| 138 | expect(vi.getTimerCount()).toBe(0); | |
| 139 | observer.report(endorsements, true); | |
| 140 | vi.advanceTimersByTime(7_999); | |
| 141 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | |
| 142 | vi.advanceTimersByTime(16_001); | |
| 143 | expect(endorsements.scrollTo.mock.calls.map(([options]) => options.left)).toEqual([ | |
| 144 | 400, 800, 400, | |
| 145 | ]); | |
| 146 | expect( | |
| 147 | endorsements.scrollTo.mock.calls.every(([options]) => options.behavior === "smooth"), | |
| 148 | ).toBe(true); | |
| 149 | ||
| 150 | endorsements.clientWidth = endorsements.scrollWidth; | |
| 151 | viewport.dispatchEvent(new Event("resize")); | |
| 152 | expect(vi.getTimerCount()).toBe(0); | |
| 153 | expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); | |
| 154 | endorsements.clientWidth = 400; | |
| 155 | viewport.dispatchEvent(new Event("resize")); | |
| 156 | expect(vi.getTimerCount()).toBe(1); | |
| 157 | }); | |
| 158 | ||
| 159 | it("pauses paging for hover, focus, hidden content, and reduced motion", () => { | |
| 160 | const { endorsements, observer } = fixture(); | |
| 161 | observer.report(endorsements, true); | |
| 162 | const changeVisibility = (visible: boolean) => { | |
| 163 | page.visibilityState = visible ? "visible" : "hidden"; | |
| 164 | page.dispatchEvent(new Event("visibilitychange")); | |
| 165 | }; | |
| 166 | const changeMotion = (matches: boolean) => { | |
| 167 | reduced.matches = matches; | |
| 168 | reduced.dispatchEvent(new Event("change")); | |
| 169 | }; | |
| 170 | const pauses = [ | |
| 171 | [ | |
| 172 | () => endorsements.dispatchEvent(new Event("pointerenter")), | |
| 173 | () => endorsements.dispatchEvent(new Event("pointerleave")), | |
| 174 | ], | |
| 175 | [ | |
| 176 | () => endorsements.dispatchEvent(new Event("focusin")), | |
| 177 | () => | |
| 178 | endorsements.dispatchEvent(Object.assign(new Event("focusout"), { relatedTarget: null })), | |
| 179 | ], | |
| 180 | [() => changeVisibility(false), () => changeVisibility(true)], | |
| 181 | [() => observer.report(endorsements, false), () => observer.report(endorsements, true)], | |
| 182 | [() => changeMotion(true), () => changeMotion(false)], | |
| 183 | ] as const; | |
| 184 | for (const [pause, resume] of pauses) { | |
| 185 | pause(); | |
| 186 | expect(vi.getTimerCount()).toBe(0); | |
| 187 | vi.advanceTimersByTime(16_000); | |
| 188 | resume(); | |
| 189 | expect(vi.getTimerCount()).toBe(1); | |
| 190 | } | |
| 191 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | |
| 192 | vi.advanceTimersByTime(8_000); | |
| 193 | expect(endorsements.scrollTo).toHaveBeenCalledWith({ left: 400, behavior: "smooth" }); | |
| 194 | endorsements.dispatchEvent(new Event("pointerenter")); | |
| 195 | expect(endorsements.scrollTo).toHaveBeenLastCalledWith({ left: 400, behavior: "instant" }); | |
| 196 | expect(vi.getTimerCount()).toBe(0); | |
| 197 | }); | |
| 198 | ||
| 199 | it.each(["wheel", "pointerdown", "keydown"])("hands control to the user after %s", (event) => { | |
| 200 | const { endorsements, observer } = fixture(); | |
| 201 | observer.report(endorsements, true); | |
| 202 | endorsements.dispatchEvent(new Event(event)); | |
| 203 | observer.report(endorsements, false); | |
| 204 | observer.report(endorsements, true); | |
| 205 | endorsements.dispatchEvent(new Event("pointerleave")); | |
| 206 | viewport.dispatchEvent(new Event("resize")); | |
| 207 | vi.advanceTimersByTime(60_000); | |
| 208 | expect(vi.getTimerCount()).toBe(0); | |
| 209 | expect(endorsements.scrollTo).not.toHaveBeenCalled(); | |
| 210 | }); | |
| 211 | ||
| 212 | it("cancels pending work and ignores events after cleanup", () => { | |
| 213 | const { hero, mark, endorsements, observer } = fixture(); | |
| 214 | observer.report(mark, true); | |
| 215 | observer.report(endorsements, true); | |
| 216 | movePointer(hero); | |
| 217 | dispose?.(); | |
| 218 | observer.report(mark, true); | |
| 219 | movePointer(hero); | |
| 220 | reduced.dispatchEvent(new Event("change")); | |
| 221 | expect(observer.disconnect).toHaveBeenCalledTimes(1); | |
| 222 | expect(frames.size).toBe(0); | |
| 223 | expect(vi.getTimerCount()).toBe(0); | |
| 224 | expect(mark.properties.get("--home-motion-state")).toBe("paused"); | |
| 225 | }); | |
| 226 | }); |
apps/marketing/src/lib/homeMotion.ts created+176| ... | ... | @@ -0,0 +1,176 @@ |
| 1 | /** Runs homepage motion only while its content is visible. Manual scrolling stops paging. */ | |
| 2 | export function startHomeMotion({ | |
| 3 | hero, | |
| 4 | field, | |
| 5 | endorsements, | |
| 6 | caret, | |
| 7 | }: { | |
| 8 | hero: HTMLElement; | |
| 9 | field: HTMLElement; | |
| 10 | endorsements: HTMLElement; | |
| 11 | caret: HTMLElement; | |
| 12 | }) { | |
| 13 | if (typeof IntersectionObserver === "undefined") return () => {}; | |
| 14 | ||
| 15 | const marks = Array.from(field.querySelectorAll<HTMLElement>(".hero-float-mark")); | |
| 16 | const visible = new Set<Element>(); | |
| 17 | const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); | |
| 18 | const finePointer = window.matchMedia("(pointer: fine)"); | |
| 19 | const events = new AbortController(); | |
| 20 | const eventOptions = { signal: events.signal }; | |
| 21 | let disposed = false; | |
| 22 | let hovered = endorsements.matches(":hover"); | |
| 23 | let focused = endorsements.contains(document.activeElement); | |
| 24 | let userControlled = false; | |
| 25 | let direction = 1; | |
| 26 | let automaticScroll = false; | |
| 27 | let pageTimer: ReturnType<typeof setTimeout> | undefined; | |
| 28 | let pointerFrame: number | undefined; | |
| 29 | let pointer: { x: number; y: number } | null = null; | |
| 30 | ||
| 31 | const canMove = (element: Element) => | |
| 32 | !disposed && | |
| 33 | visible.has(element) && | |
| 34 | document.visibilityState === "visible" && | |
| 35 | !reducedMotion.matches; | |
| 36 | const canParallax = () => finePointer.matches && marks.some(canMove); | |
| 37 | const canPage = () => | |
| 38 | canMove(endorsements) && | |
| 39 | !hovered && | |
| 40 | !focused && | |
| 41 | !userControlled && | |
| 42 | endorsements.scrollWidth > endorsements.clientWidth; | |
| 43 | ||
| 44 | function resetPointer() { | |
| 45 | if (pointerFrame !== undefined) cancelAnimationFrame(pointerFrame); | |
| 46 | pointerFrame = undefined; | |
| 47 | pointer = null; | |
| 48 | field.style.setProperty("--px", "0px"); | |
| 49 | field.style.setProperty("--py", "0px"); | |
| 50 | } | |
| 51 | ||
| 52 | function updatePaging() { | |
| 53 | if (canPage()) { | |
| 54 | pageTimer ??= setTimeout(advancePage, 8_000); | |
| 55 | return; | |
| 56 | } | |
| 57 | if (pageTimer !== undefined) clearTimeout(pageTimer); | |
| 58 | pageTimer = undefined; | |
| 59 | if (automaticScroll) { | |
| 60 | automaticScroll = false; | |
| 61 | endorsements.scrollTo({ left: endorsements.scrollLeft, behavior: "instant" }); | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | function advancePage() { | |
| 66 | pageTimer = undefined; | |
| 67 | if (!canPage()) return; | |
| 68 | const end = endorsements.scrollWidth - endorsements.clientWidth; | |
| 69 | const current = endorsements.scrollLeft; | |
| 70 | if (current >= end - 1) direction = -1; | |
| 71 | else if (current <= 1) direction = 1; | |
| 72 | automaticScroll = true; | |
| 73 | endorsements.scrollTo({ | |
| 74 | left: Math.max(0, Math.min(end, current + direction * endorsements.clientWidth)), | |
| 75 | behavior: "smooth", | |
| 76 | }); | |
| 77 | updatePaging(); | |
| 78 | } | |
| 79 | ||
| 80 | function update() { | |
| 81 | for (const mark of marks) { | |
| 82 | mark.style.setProperty("--home-motion-state", canMove(mark) ? "running" : "paused"); | |
| 83 | } | |
| 84 | caret.style.setProperty("--home-motion-state", canMove(caret) ? "running" : "paused"); | |
| 85 | const parallax = canParallax(); | |
| 86 | field.style.setProperty("--parallax-duration", parallax ? "0.7s" : "0s"); | |
| 87 | if (!parallax) resetPointer(); | |
| 88 | updatePaging(); | |
| 89 | } | |
| 90 | ||
| 91 | const observer = new IntersectionObserver((entries) => { | |
| 92 | if (disposed) return; | |
| 93 | for (const entry of entries) { | |
| 94 | if (entry.isIntersecting) visible.add(entry.target); | |
| 95 | else visible.delete(entry.target); | |
| 96 | } | |
| 97 | update(); | |
| 98 | }); | |
| 99 | for (const element of [...marks, endorsements, caret]) observer.observe(element); | |
| 100 | ||
| 101 | hero.addEventListener( | |
| 102 | "pointermove", | |
| 103 | (event) => { | |
| 104 | if (!canParallax()) return; | |
| 105 | pointer = { x: event.clientX, y: event.clientY }; | |
| 106 | pointerFrame ??= requestAnimationFrame(() => { | |
| 107 | pointerFrame = undefined; | |
| 108 | if (!pointer || !canParallax()) return; | |
| 109 | const bounds = hero.getBoundingClientRect(); | |
| 110 | if (bounds.width === 0 || bounds.height === 0) return; | |
| 111 | field.style.setProperty( | |
| 112 | "--px", | |
| 113 | `${(((pointer.x - bounds.left) / bounds.width - 0.5) * 36).toFixed(1)}px`, | |
| 114 | ); | |
| 115 | field.style.setProperty( | |
| 116 | "--py", | |
| 117 | `${(((pointer.y - bounds.top) / bounds.height - 0.5) * 28).toFixed(1)}px`, | |
| 118 | ); | |
| 119 | }); | |
| 120 | }, | |
| 121 | eventOptions, | |
| 122 | ); | |
| 123 | hero.addEventListener("pointerleave", resetPointer, eventOptions); | |
| 124 | endorsements.addEventListener( | |
| 125 | "pointerenter", | |
| 126 | () => { | |
| 127 | hovered = true; | |
| 128 | updatePaging(); | |
| 129 | }, | |
| 130 | eventOptions, | |
| 131 | ); | |
| 132 | endorsements.addEventListener( | |
| 133 | "pointerleave", | |
| 134 | () => { | |
| 135 | hovered = false; | |
| 136 | updatePaging(); | |
| 137 | }, | |
| 138 | eventOptions, | |
| 139 | ); | |
| 140 | endorsements.addEventListener( | |
| 141 | "focusin", | |
| 142 | () => { | |
| 143 | focused = true; | |
| 144 | updatePaging(); | |
| 145 | }, | |
| 146 | eventOptions, | |
| 147 | ); | |
| 148 | endorsements.addEventListener( | |
| 149 | "focusout", | |
| 150 | (event) => { | |
| 151 | focused = event.relatedTarget instanceof Node && endorsements.contains(event.relatedTarget); | |
| 152 | updatePaging(); | |
| 153 | }, | |
| 154 | eventOptions, | |
| 155 | ); | |
| 156 | const takeControl = () => { | |
| 157 | userControlled = true; | |
| 158 | updatePaging(); | |
| 159 | }; | |
| 160 | endorsements.addEventListener("wheel", takeControl, { ...eventOptions, passive: true }); | |
| 161 | endorsements.addEventListener("pointerdown", takeControl, eventOptions); | |
| 162 | endorsements.addEventListener("keydown", takeControl, eventOptions); | |
| 163 | document.addEventListener("visibilitychange", update, eventOptions); | |
| 164 | window.addEventListener("resize", update, eventOptions); | |
| 165 | reducedMotion.addEventListener("change", update, eventOptions); | |
| 166 | finePointer.addEventListener("change", update, eventOptions); | |
| 167 | update(); | |
| 168 | ||
| 169 | return () => { | |
| 170 | if (disposed) return; | |
| 171 | disposed = true; | |
| 172 | events.abort(); | |
| 173 | observer.disconnect(); | |
| 174 | update(); | |
| 175 | }; | |
| 176 | } |
apps/marketing/src/pages/index.astro+39-8| ... | ... | @@ -355,6 +355,7 @@ const screenshot = await getImage({ |
| 355 | 355 | |
| 356 | 356 | <script> |
| 357 | 357 | import { fetchLatestRelease, RELEASES_URL, type ReleaseAsset } from "../lib/releases"; |
| 358 | import { startHomeMotion } from "../lib/homeMotion"; | |
| 358 | 359 | |
| 359 | 360 | type Platform = { os: "mac" | "win" | "linux"; label: string }; |
| 360 | 361 | |
| ... | ... | @@ -419,6 +420,14 @@ const screenshot = await getImage({ |
| 419 | 420 | } |
| 420 | 421 | |
| 421 | 422 | init(); |
| 423 | ||
| 424 | const hero = document.querySelector<HTMLElement>(".hero"); | |
| 425 | const field = document.querySelector<HTMLElement>(".hero-float"); | |
| 426 | const endorsements = document.querySelector<HTMLElement>(".endorsement-carousel"); | |
| 427 | const caret = document.querySelector<HTMLElement>(".caret"); | |
| 428 | if (hero && field && endorsements && caret) { | |
| 429 | startHomeMotion({ hero, field, endorsements, caret }); | |
| 430 | } | |
| 422 | 431 | </script> |
| 423 | 432 | |
| 424 | 433 | <style> |
| ... | ... | @@ -590,12 +599,14 @@ const screenshot = await getImage({ |
| 590 | 599 | :global([data-platform="linux"]) .dl-icon--linux { display: block; } |
| 591 | 600 | :global(html:not([data-platform])) .dl-icon--apple { display: block; } |
| 592 | 601 | |
| 593 | /* Harness marks stay in their final positions after the entry animation. */ | |
| 602 | /* Marks enter once, then drift while visible and lean toward the pointer. */ | |
| 594 | 603 | .hero-float { |
| 595 | 604 | position: absolute; |
| 596 | 605 | inset: 0; |
| 597 | 606 | pointer-events: none; |
| 598 | 607 | z-index: 1; |
| 608 | --px: 0px; | |
| 609 | --py: 0px; | |
| 599 | 610 | } |
| 600 | 611 | |
| 601 | 612 | .hero-float-mark { |
| ... | ... | @@ -605,6 +616,8 @@ const screenshot = await getImage({ |
| 605 | 616 | width: var(--s); |
| 606 | 617 | height: var(--s); |
| 607 | 618 | margin-left: calc(var(--s) / -2); |
| 619 | translate: calc(var(--px) * var(--depth)) calc(var(--py) * var(--depth)); | |
| 620 | transition: translate var(--parallax-duration, 0s) cubic-bezier(0.2, 0.7, 0.2, 1); | |
| 608 | 621 | } |
| 609 | 622 | |
| 610 | 623 | .hero-float-card { |
| ... | ... | @@ -618,7 +631,10 @@ const screenshot = await getImage({ |
| 618 | 631 | box-shadow: |
| 619 | 632 | 0 20px 48px -16px rgba(0, 0, 0, 0.65), |
| 620 | 633 | 0 2px 0 0 rgba(255, 255, 255, 0.04) inset; |
| 621 | animation: mark-in 1s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d) both; | |
| 634 | animation: | |
| 635 | mark-in 1s cubic-bezier(0.2, 0.7, 0.2, 1) var(--d) both, | |
| 636 | mark-drift var(--dur) ease-in-out calc(var(--d) + 1s) infinite; | |
| 637 | animation-play-state: running, var(--home-motion-state, paused); | |
| 622 | 638 | } |
| 623 | 639 | |
| 624 | 640 | .hero-float-card img { |
| ... | ... | @@ -637,16 +653,21 @@ const screenshot = await getImage({ |
| 637 | 653 | from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } |
| 638 | 654 | to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); } |
| 639 | 655 | } |
| 656 | @keyframes mark-drift { | |
| 657 | 0%, 100% { transform: translate(0, 0) rotate(var(--rot)); } | |
| 658 | 50% { transform: translate(var(--dx), var(--dy)) rotate(calc(var(--rot) + var(--wob))); } | |
| 659 | } | |
| 640 | 660 | |
| 641 | .hf-opencode { --x: max(-300px, -19vw); --y: 20px; --s: 64px; --rot: 6deg; --d: 620ms; --fx: -40px; --fy: -80px; } | |
| 642 | .hf-antigravity { --x: min(300px, 19vw); --y: 20px; --s: 64px; --rot: -5deg; --d: 580ms; --fx: 40px; --fy: -80px; } | |
| 643 | .hf-claude { --x: max(-560px, -38vw); --y: 128px; --s: 96px; --rot: -8deg; --d: 380ms; --fx: -160px; --fy: -40px; --tint: rgba(217, 119, 87, 0.22); } | |
| 644 | .hf-codex { --x: min(560px, 38vw); --y: 128px; --s: 96px; --rot: 7deg; --d: 460ms; --fx: 160px; --fy: -40px; } | |
| 645 | .hf-grok { --x: max(-440px, -30vw); --y: 440px; --s: 80px; --rot: 4deg; --d: 540ms; --fx: -120px; --fy: 80px; } | |
| 646 | .hf-cursor { --x: min(440px, 30vw); --y: 440px; --s: 80px; --rot: -5deg; --d: 700ms; --fx: 120px; --fy: 80px; } | |
| 661 | .hf-opencode { --x: max(-300px, -19vw); --y: 20px; --s: 64px; --rot: 6deg; --depth: 0.6; --d: 620ms; --dur: 8.5s; --dx: 2px; --dy: -8px; --wob: 3deg; --fx: -40px; --fy: -80px; } | |
| 662 | .hf-antigravity { --x: min(300px, 19vw); --y: 20px; --s: 64px; --rot: -5deg; --depth: 0.6; --d: 580ms; --dur: 9.8s; --dx: -2px; --dy: -9px; --wob: -3deg; --fx: 40px; --fy: -80px; } | |
| 663 | .hf-claude { --x: max(-560px, -38vw); --y: 128px; --s: 96px; --rot: -8deg; --depth: 1.2; --d: 380ms; --dur: 10.5s; --dx: 4px; --dy: -12px; --wob: -2deg; --fx: -160px; --fy: -40px; --tint: rgba(217, 119, 87, 0.22); } | |
| 664 | .hf-codex { --x: min(560px, 38vw); --y: 128px; --s: 96px; --rot: 7deg; --depth: 1.2; --d: 460ms; --dur: 9.2s; --dx: -4px; --dy: -10px; --wob: 2deg; --fx: 160px; --fy: -40px; } | |
| 665 | .hf-grok { --x: max(-440px, -30vw); --y: 440px; --s: 80px; --rot: 4deg; --depth: 0.9; --d: 540ms; --dur: 11.5s; --dx: 3px; --dy: -9px; --wob: -3deg; --fx: -120px; --fy: 80px; } | |
| 666 | .hf-cursor { --x: min(440px, 30vw); --y: 440px; --s: 80px; --rot: -5deg; --depth: 0.9; --d: 700ms; --dur: 7.8s; --dx: -3px; --dy: -11px; --wob: 3deg; --fx: 120px; --fy: 80px; } | |
| 647 | 667 | |
| 648 | 668 | @media (prefers-reduced-motion: reduce) { |
| 649 | 669 | .hero-float-card { animation: none; transform: rotate(var(--rot)); } |
| 670 | .hero-float-mark { translate: none; transition: none; } | |
| 650 | 671 | } |
| 651 | 672 | |
| 652 | 673 | @media (max-width: 1180px) { |
| ... | ... | @@ -988,6 +1009,16 @@ const screenshot = await getImage({ |
| 988 | 1009 | width: 7px; height: 14px; |
| 989 | 1010 | background: var(--fg); |
| 990 | 1011 | margin-left: 4px; |
| 1012 | animation: blink 1s steps(2) infinite; | |
| 1013 | animation-play-state: var(--home-motion-state, paused); | |
| 1014 | } | |
| 1015 | ||
| 1016 | @keyframes blink { | |
| 1017 | 50% { opacity: 0; } | |
| 1018 | } | |
| 1019 | ||
| 1020 | @media (prefers-reduced-motion: reduce) { | |
| 1021 | .caret { animation: none; } | |
| 991 | 1022 | } |
| 992 | 1023 | |
| 993 | 1024 | .open-pitch { |
apps/mobile/src/features/threads/use-project-actions.ts+1-6| ... | ... | @@ -156,13 +156,8 @@ export function useCreateProjectThread() { |
| 156 | 156 | ); |
| 157 | 157 | return AsyncResult.failure(result.cause); |
| 158 | 158 | } |
| 159 | // The started turn holds its own copy of the bytes; a failed delete is | |
| 160 | // surfaced without failing the started task. | |
| 161 | await prepared.releaseUploads().catch((error) => { | |
| 162 | console.warn("[project-thread] could not delete consumed pending uploads", error); | |
| 163 | }); | |
| 164 | 159 | setPendingConnectionError(null); |
| 165 | scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); | |
| 160 | scheduleUnusedComposerAttachmentCleanup(prepared.draftAttachments); | |
| 166 | 161 | |
| 167 | 162 | return mapAtomCommandResult(result, () => |
| 168 | 163 | scopeThreadRef(input.project.environmentId, threadId), |
apps/mobile/src/lib/attachmentUpload.ts+2-5| ... | ... | @@ -33,8 +33,8 @@ import { uuidv4 } from "./uuid"; |
| 33 | 33 | * This module owns the server side of a composer attachment's lifecycle. |
| 34 | 34 | * `prepareTurnAttachments` acquires pending uploads (verifying and reusing |
| 35 | 35 | * persisted ones), hands the uploaded ids back to the attachment's durable |
| 36 | * owner (queued outbox message or composer draft), and returns a release | |
| 37 | * handle for after the turn consumed the bytes. Nothing outside this module | |
| 36 | * owner (queued outbox message or composer draft), and leaves their cleanup | |
| 37 | * to that owner after it checks shared references. Nothing outside this module | |
| 38 | 38 | * mints or deletes pending uploads. The local-file side of the lifecycle is |
| 39 | 39 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which |
| 40 | 40 | * release files through `releaseUnusedComposerAttachmentFiles`. |
| ... | ... | @@ -149,8 +149,6 @@ export interface PreparedTurnAttachments { |
| 149 | 149 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 150 | 150 | /** Every pending upload backing this turn (reused and newly minted). */ |
| 151 | 151 | readonly pendingAttachmentIds: ReadonlyArray<string>; |
| 152 | /** Deletes all pending uploads once the delivered turn holds the bytes. */ | |
| 153 | readonly releaseUploads: () => Promise<void>; | |
| 154 | 152 | } |
| 155 | 153 | |
| 156 | 154 | export type PrepareTurnAttachmentsResult = |
| ... | ... | @@ -306,7 +304,6 @@ export async function prepareTurnAttachments(input: { |
| 306 | 304 | attachments, |
| 307 | 305 | draftAttachments, |
| 308 | 306 | pendingAttachmentIds, |
| 309 | releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), | |
| 310 | 307 | }); |
| 311 | 308 | |
| 312 | 309 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { |
apps/mobile/src/state/pending-task-editor-writes.test.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ vi.mock("./thread-outbox", async () => { |
| 19 | 19 | harness.manager = createThreadOutboxManager({ |
| 20 | 20 | registry: appAtomRegistry, |
| 21 | 21 | storage: { |
| 22 | load: async () => [], | |
| 22 | load: async () => ({ messages: [], errors: [] }), | |
| 23 | 23 | write: async (message) => { |
| 24 | 24 | const pending = harness.writeGates.shift(); |
| 25 | 25 | if (pending) { |
apps/mobile/src/state/thread-outbox-manager.ts+31-13| ... | ... | @@ -72,16 +72,26 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 72 | 72 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); |
| 73 | 73 | }; |
| 74 | 74 | |
| 75 | // Resolves true when hydration completed; false when the read failed (the | |
| 76 | // next call retries). Destructive callers (the attachment sweep) must not | |
| 77 | // treat a failed hydration as an empty queue. | |
| 75 | // Readable messages can be used after a partial load. Only a complete load | |
| 76 | // returns true, so cleanup cannot delete files owned by unreadable records. | |
| 77 | // A later call retries failed reads without replacing live message objects. | |
| 78 | 78 | const load = (): Promise<boolean> => { |
| 79 | 79 | if (loadPromise !== null) { |
| 80 | 80 | return loadPromise; |
| 81 | 81 | } |
| 82 | 82 | loadPromise = serialize(async () => { |
| 83 | const persistedMessages = await options.storage.load(); | |
| 84 | setMessages([...persistedMessages, ...currentMessages()]); | |
| 83 | const result = await options.storage.load(); | |
| 84 | const current = currentMessages(); | |
| 85 | const currentIds = new Set(current.map((message) => message.messageId)); | |
| 86 | const recovered = result.messages.filter( | |
| 87 | (message) => !currentIds.has(message.messageId) && !revisions.has(message.messageId), | |
| 88 | ); | |
| 89 | // Accepted edits and removals win over a later disk read. Retaining | |
| 90 | // current objects also keeps retries from restarting the drain. | |
| 91 | if (recovered.length > 0) setMessages([...recovered, ...current]); | |
| 92 | if (result.errors.length > 0) { | |
| 93 | throw new AggregateError(result.errors, "Some queued messages could not be read."); | |
| 94 | } | |
| 85 | 95 | return true; |
| 86 | 96 | }).catch((cause) => { |
| 87 | 97 | loadPromise = null; |
| ... | ... | @@ -275,15 +285,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 275 | 285 | // changes after this request must not enter the clear set. |
| 276 | 286 | const revisionsAtRequest = new Map(revisions); |
| 277 | 287 | return serialize(async () => { |
| 278 | const persisted = await options.storage.load().catch((cause) => { | |
| 279 | throw new ThreadOutboxManagerError({ | |
| 280 | operation: "clear-environment-load", | |
| 281 | environmentId, | |
| 282 | threadId: null, | |
| 283 | messageId: null, | |
| 284 | cause, | |
| 288 | const persisted = await options.storage | |
| 289 | .load() | |
| 290 | .then((result) => { | |
| 291 | if (result.errors.length > 0) { | |
| 292 | throw new AggregateError(result.errors, "Some queued messages could not be read."); | |
| 293 | } | |
| 294 | return result.messages; | |
| 295 | }) | |
| 296 | .catch((cause) => { | |
| 297 | throw new ThreadOutboxManagerError({ | |
| 298 | operation: "clear-environment-load", | |
| 299 | environmentId, | |
| 300 | threadId: null, | |
| 301 | messageId: null, | |
| 302 | cause, | |
| 303 | }); | |
| 285 | 304 | }); |
| 286 | }); | |
| 287 | 305 | const allMessages = flattenQueuedThreadMessages( |
| 288 | 306 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), |
| 289 | 307 | ); |
apps/mobile/src/state/thread-outbox-removal.test.ts+1-1| ... | ... | @@ -19,7 +19,7 @@ vi.mock("./thread-outbox", async () => { |
| 19 | 19 | harness.manager = createThreadOutboxManager({ |
| 20 | 20 | registry: appAtomRegistry, |
| 21 | 21 | storage: { |
| 22 | load: async () => [], | |
| 22 | load: async () => ({ messages: [], errors: [] }), | |
| 23 | 23 | write: async () => undefined, |
| 24 | 24 | remove: async () => undefined, |
| 25 | 25 | }, |
apps/mobile/src/state/thread-outbox-storage.ts+20-13| ... | ... | @@ -44,8 +44,13 @@ export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutb |
| 44 | 44 | } |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | export interface ThreadOutboxLoadResult { | |
| 48 | readonly messages: ReadonlyArray<QueuedThreadMessage>; | |
| 49 | readonly errors: ReadonlyArray<ThreadOutboxStorageError>; | |
| 50 | } | |
| 51 | ||
| 47 | 52 | export interface ThreadOutboxStorage { |
| 48 | readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>; | |
| 53 | readonly load: () => Promise<ThreadOutboxLoadResult>; | |
| 49 | 54 | readonly write: (message: QueuedThreadMessage) => Promise<void>; |
| 50 | 55 | readonly remove: (message: QueuedThreadMessage) => Promise<void>; |
| 51 | 56 | } |
| ... | ... | @@ -69,6 +74,7 @@ async function getMessageFile(messageId: MessageId) { |
| 69 | 74 | export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 70 | 75 | load: async () => { |
| 71 | 76 | const messages: QueuedThreadMessage[] = []; |
| 77 | const errors: ThreadOutboxStorageError[] = []; | |
| 72 | 78 | try { |
| 73 | 79 | const { File } = await import("expo-file-system"); |
| 74 | 80 | const directory = await getOutboxDirectory(); |
| ... | ... | @@ -80,20 +86,21 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 80 | 86 | try { |
| 81 | 87 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); |
| 82 | 88 | } catch (cause) { |
| 83 | // A partial queue hides attachment owners from cleanup. Keep all | |
| 84 | // records untouched until every persisted message can be read. | |
| 85 | throw new ThreadOutboxStorageError({ | |
| 86 | operation: "read-message", | |
| 87 | environmentId: null, | |
| 88 | threadId: null, | |
| 89 | messageId: null, | |
| 90 | fileName: entry.name, | |
| 91 | cause, | |
| 92 | }); | |
| 89 | // Recover readable messages without treating their attachment | |
| 90 | // owners as the complete inventory needed for cleanup. | |
| 91 | errors.push( | |
| 92 | new ThreadOutboxStorageError({ | |
| 93 | operation: "read-message", | |
| 94 | environmentId: null, | |
| 95 | threadId: null, | |
| 96 | messageId: null, | |
| 97 | fileName: entry.name, | |
| 98 | cause, | |
| 99 | }), | |
| 100 | ); | |
| 93 | 101 | } |
| 94 | 102 | } |
| 95 | 103 | } catch (cause) { |
| 96 | if (cause instanceof ThreadOutboxStorageError) throw cause; | |
| 97 | 104 | throw new ThreadOutboxStorageError({ |
| 98 | 105 | operation: "load", |
| 99 | 106 | environmentId: null, |
| ... | ... | @@ -103,7 +110,7 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 103 | 110 | cause, |
| 104 | 111 | }); |
| 105 | 112 | } |
| 106 | return messages; | |
| 113 | return { messages, errors }; | |
| 107 | 114 | }, |
| 108 | 115 | write: async (message) => { |
| 109 | 116 | const fileName = messageFileName(message.messageId); |
apps/mobile/src/state/thread-outbox.test.ts+160-33| ... | ... | @@ -13,8 +13,44 @@ import { onTestFinished, vi } from "vite-plus/test"; |
| 13 | 13 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); |
| 14 | 14 | |
| 15 | 15 | vi.mock("expo-file-system", () => { |
| 16 | class Directory { | |
| 17 | create() {} | |
| 18 | ||
| 19 | list() { | |
| 20 | return Array.from(outboxFiles.keys(), (name) => new File(name)); | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 16 | 24 | class File { |
| 17 | constructor(readonly name: string) {} | |
| 25 | readonly name: string; | |
| 26 | readonly parentDirectory = new Directory(); | |
| 27 | ||
| 28 | constructor(...parts: [string] | [Directory, string]) { | |
| 29 | this.name = parts.length === 1 ? parts[0] : parts[1]; | |
| 30 | } | |
| 31 | ||
| 32 | get exists() { | |
| 33 | return outboxFiles.has(this.name); | |
| 34 | } | |
| 35 | ||
| 36 | create() { | |
| 37 | outboxFiles.set(this.name, ""); | |
| 38 | } | |
| 39 | ||
| 40 | write(contents: string) { | |
| 41 | outboxFiles.set(this.name, contents); | |
| 42 | } | |
| 43 | ||
| 44 | moveSync(file: File) { | |
| 45 | const contents = outboxFiles.get(this.name); | |
| 46 | if (contents === undefined) throw new Error("Missing file"); | |
| 47 | outboxFiles.set(file.name, contents); | |
| 48 | outboxFiles.delete(this.name); | |
| 49 | } | |
| 50 | ||
| 51 | delete() { | |
| 52 | outboxFiles.delete(this.name); | |
| 53 | } | |
| 18 | 54 | |
| 19 | 55 | async text(): Promise<string> { |
| 20 | 56 | const contents = outboxFiles.get(this.name); |
| ... | ... | @@ -26,13 +62,7 @@ vi.mock("expo-file-system", () => { |
| 26 | 62 | |
| 27 | 63 | return { |
| 28 | 64 | File, |
| 29 | Directory: class { | |
| 30 | create() {} | |
| 31 | ||
| 32 | list() { | |
| 33 | return Array.from(outboxFiles.keys(), (name) => new File(name)); | |
| 34 | } | |
| 35 | }, | |
| 65 | Directory, | |
| 36 | 66 | Paths: { document: "/documents" }, |
| 37 | 67 | }; |
| 38 | 68 | }); |
| ... | ... | @@ -52,7 +82,12 @@ import { |
| 52 | 82 | type QueuedThreadMessage, |
| 53 | 83 | } from "./thread-outbox-model"; |
| 54 | 84 | import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; |
| 55 | import { expoThreadOutboxStorage, type ThreadOutboxStorage } from "./thread-outbox-storage"; | |
| 85 | import { | |
| 86 | expoThreadOutboxStorage, | |
| 87 | ThreadOutboxStorageError, | |
| 88 | type ThreadOutboxLoadResult, | |
| 89 | type ThreadOutboxStorage, | |
| 90 | } from "./thread-outbox-storage"; | |
| 56 | 91 | |
| 57 | 92 | function queuedMessage(input: { |
| 58 | 93 | readonly environmentId?: string; |
| ... | ... | @@ -73,7 +108,7 @@ function queuedMessage(input: { |
| 73 | 108 | |
| 74 | 109 | describe("thread outbox", () => { |
| 75 | 110 | it.each(["read", "json", "schema"] as const)( |
| 76 | "does not load a partial outbox after a record %s failure", | |
| 111 | "recovers usable messages without permitting cleanup after a record %s failure", | |
| 77 | 112 | async (failure) => { |
| 78 | 113 | onTestFinished(() => outboxFiles.clear()); |
| 79 | 114 | const first = queuedMessage({ |
| ... | ... | @@ -81,10 +116,11 @@ describe("thread outbox", () => { |
| 81 | 116 | createdAt: "2026-06-08T10:00:01.000Z", |
| 82 | 117 | }); |
| 83 | 118 | const second = queuedMessage({ |
| 119 | environmentId: "environment-2", | |
| 84 | 120 | messageId: "message-2", |
| 85 | 121 | createdAt: "2026-06-08T10:00:02.000Z", |
| 86 | 122 | }); |
| 87 | outboxFiles.set("message-1.json", JSON.stringify(encodeQueuedThreadMessage(first))); | |
| 123 | // Put the unreadable record first to check that later records still load. | |
| 88 | 124 | outboxFiles.set( |
| 89 | 125 | "message-2.json", |
| 90 | 126 | failure === "read" |
| ... | ... | @@ -93,14 +129,52 @@ describe("thread outbox", () => { |
| 93 | 129 | ? "{" |
| 94 | 130 | : JSON.stringify({ ...second, schemaVersion: 999 }), |
| 95 | 131 | ); |
| 132 | outboxFiles.set("message-1.json", JSON.stringify(encodeQueuedThreadMessage(first))); | |
| 133 | const unreadable = outboxFiles.get("message-2.json"); | |
| 134 | ||
| 135 | await expect(expoThreadOutboxStorage.load()).resolves.toMatchObject({ | |
| 136 | messages: [first], | |
| 137 | errors: [{ operation: "read-message", fileName: "message-2.json" }], | |
| 138 | }); | |
| 96 | 139 | |
| 97 | await expect(expoThreadOutboxStorage.load()).rejects.toMatchObject({ | |
| 98 | operation: "read-message", | |
| 99 | fileName: "message-2.json", | |
| 140 | const registry = AtomRegistry.make(); | |
| 141 | onTestFinished(() => registry.dispose()); | |
| 142 | const manager = createThreadOutboxManager({ | |
| 143 | registry, | |
| 144 | storage: expoThreadOutboxStorage, | |
| 145 | warn: () => {}, | |
| 146 | }); | |
| 147 | await expect(manager.load()).resolves.toBe(false); | |
| 148 | const recovered = registry.get(manager.queuedMessagesByThreadKeyAtom)[ | |
| 149 | "environment-1:thread-1" | |
| 150 | ]![0]!; | |
| 151 | expect(recovered).toEqual(first); | |
| 152 | await expect(manager.confirmQueued(recovered)).resolves.toBe(true); | |
| 153 | const edited = { ...recovered, text: "Edited after recovery" }; | |
| 154 | await expect(manager.update(edited)).resolves.toBe(true); | |
| 155 | const beforeRetry = registry.get(manager.queuedMessagesByThreadKeyAtom); | |
| 156 | await expect(manager.load()).resolves.toBe(false); | |
| 157 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toBe(beforeRetry); | |
| 158 | await expect(manager.clearEnvironment(first.environmentId)).rejects.toMatchObject({ | |
| 159 | operation: "clear-environment-load", | |
| 100 | 160 | }); |
| 161 | expect(outboxFiles.get("message-2.json")).toBe(unreadable); | |
| 162 | expect(outboxFiles.has("message-1.json")).toBe(true); | |
| 163 | ||
| 164 | // A delivered readable message can leave the queue while the failed | |
| 165 | // record stays intact. Its attachment cleanup has a separate guard. | |
| 166 | await expect(manager.remove(edited)).resolves.toBe(edited); | |
| 167 | expect(outboxFiles.has("message-1.json")).toBe(false); | |
| 101 | 168 | |
| 102 | 169 | outboxFiles.set("message-2.json", JSON.stringify(encodeQueuedThreadMessage(second))); |
| 103 | await expect(expoThreadOutboxStorage.load()).resolves.toEqual([first, second]); | |
| 170 | await expect(manager.load()).resolves.toBe(true); | |
| 171 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | |
| 172 | "environment-2:thread-1": [second], | |
| 173 | }); | |
| 174 | await expect(expoThreadOutboxStorage.load()).resolves.toEqual({ | |
| 175 | messages: [second], | |
| 176 | errors: [], | |
| 177 | }); | |
| 104 | 178 | }, |
| 105 | 179 | ); |
| 106 | 180 | |
| ... | ... | @@ -138,6 +212,59 @@ describe("thread outbox", () => { |
| 138 | 212 | }); |
| 139 | 213 | }); |
| 140 | 214 | |
| 215 | it("keeps in-session edits and removals when an incomplete load is retried", async () => { | |
| 216 | const registry = AtomRegistry.make(); | |
| 217 | onTestFinished(() => registry.dispose()); | |
| 218 | const message = queuedMessage({ | |
| 219 | messageId: "message-retried", | |
| 220 | createdAt: "2026-06-08T10:00:01.000Z", | |
| 221 | }); | |
| 222 | const started = Promise.withResolvers<void>(); | |
| 223 | const response = Promise.withResolvers<ThreadOutboxLoadResult>(); | |
| 224 | const load = vi.fn<ThreadOutboxStorage["load"]>(async () => ({ | |
| 225 | messages: [message], | |
| 226 | errors: [], | |
| 227 | })); | |
| 228 | load.mockImplementationOnce(() => { | |
| 229 | started.resolve(); | |
| 230 | return response.promise; | |
| 231 | }); | |
| 232 | const manager = createThreadOutboxManager({ | |
| 233 | registry, | |
| 234 | storage: { load, write: async () => {}, remove: async () => {} }, | |
| 235 | warn: () => {}, | |
| 236 | }); | |
| 237 | const loading = manager.load(); | |
| 238 | await started.promise; | |
| 239 | const edited = { ...message, text: "Accepted while storage was being read" }; | |
| 240 | const writing = manager.enqueue(edited); | |
| 241 | response.resolve({ | |
| 242 | messages: [message], | |
| 243 | errors: [ | |
| 244 | new ThreadOutboxStorageError({ | |
| 245 | operation: "read-message", | |
| 246 | environmentId: null, | |
| 247 | threadId: null, | |
| 248 | messageId: null, | |
| 249 | fileName: "unreadable.json", | |
| 250 | cause: new Error("unreadable record"), | |
| 251 | }), | |
| 252 | ], | |
| 253 | }); | |
| 254 | await expect(loading).resolves.toBe(false); | |
| 255 | await writing; | |
| 256 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({ | |
| 257 | "environment-1:thread-1": [edited], | |
| 258 | }); | |
| 259 | await expect(manager.confirmQueued(edited)).resolves.toBe(true); | |
| 260 | ||
| 261 | await manager.remove(edited); | |
| 262 | // A later repaired record can contain a stale copy of a removed message. | |
| 263 | await expect(manager.load()).resolves.toBe(true); | |
| 264 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); | |
| 265 | expect(load).toHaveBeenCalledTimes(2); | |
| 266 | }); | |
| 267 | ||
| 141 | 268 | it("groups messages by scoped thread and preserves creation order", () => { |
| 142 | 269 | const later = queuedMessage({ |
| 143 | 270 | messageId: "message-2", |
| ... | ... | @@ -335,7 +462,7 @@ describe("thread outbox", () => { |
| 335 | 462 | const manager = createThreadOutboxManager({ |
| 336 | 463 | registry, |
| 337 | 464 | storage: { |
| 338 | load: async () => [], | |
| 465 | load: async () => ({ messages: [], errors: [] }), | |
| 339 | 466 | write: async () => undefined, |
| 340 | 467 | remove: async () => undefined, |
| 341 | 468 | }, |
| ... | ... | @@ -382,7 +509,7 @@ describe("thread outbox", () => { |
| 382 | 509 | if (loadCalls === 1) { |
| 383 | 510 | await initialLoadBlocked; |
| 384 | 511 | } |
| 385 | return [...stored.values()]; | |
| 512 | return { messages: [...stored.values()], errors: [] }; | |
| 386 | 513 | }, |
| 387 | 514 | write: async () => undefined, |
| 388 | 515 | remove: async (candidate) => { |
| ... | ... | @@ -418,7 +545,7 @@ describe("thread outbox", () => { |
| 418 | 545 | load: async () => { |
| 419 | 546 | loadCalls += 1; |
| 420 | 547 | if (loadCalls === 1) throw loadCause; |
| 421 | return []; | |
| 548 | return { messages: [], errors: [] }; | |
| 422 | 549 | }, |
| 423 | 550 | write: async () => undefined, |
| 424 | 551 | remove: async () => undefined, |
| ... | ... | @@ -451,7 +578,7 @@ describe("thread outbox", () => { |
| 451 | 578 | const removalCause = new Error("remove failed"); |
| 452 | 579 | let failRemoval = true; |
| 453 | 580 | const storage: ThreadOutboxStorage = { |
| 454 | load: async () => [...stored.values()], | |
| 581 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 455 | 582 | write: async (message) => { |
| 456 | 583 | stored.set(message.messageId, message); |
| 457 | 584 | }, |
| ... | ... | @@ -501,7 +628,7 @@ describe("thread outbox", () => { |
| 501 | 628 | const manager = createThreadOutboxManager({ |
| 502 | 629 | registry, |
| 503 | 630 | storage: { |
| 504 | load: async () => [], | |
| 631 | load: async () => ({ messages: [], errors: [] }), | |
| 505 | 632 | write: async () => writeBlocked, |
| 506 | 633 | remove: async () => undefined, |
| 507 | 634 | }, |
| ... | ... | @@ -530,7 +657,7 @@ describe("thread outbox", () => { |
| 530 | 657 | const manager = createThreadOutboxManager({ |
| 531 | 658 | registry, |
| 532 | 659 | storage: { |
| 533 | load: async () => [], | |
| 660 | load: async () => ({ messages: [], errors: [] }), | |
| 534 | 661 | write: async () => { |
| 535 | 662 | throw writeCause; |
| 536 | 663 | }, |
| ... | ... | @@ -561,7 +688,7 @@ describe("thread outbox", () => { |
| 561 | 688 | const manager = createThreadOutboxManager({ |
| 562 | 689 | registry, |
| 563 | 690 | storage: { |
| 564 | load: async () => [], | |
| 691 | load: async () => ({ messages: [], errors: [] }), | |
| 565 | 692 | write: async () => { |
| 566 | 693 | throw new Error("disk full"); |
| 567 | 694 | }, |
| ... | ... | @@ -593,7 +720,7 @@ describe("thread outbox", () => { |
| 593 | 720 | const manager = createThreadOutboxManager({ |
| 594 | 721 | registry, |
| 595 | 722 | storage: { |
| 596 | load: async () => [], | |
| 723 | load: async () => ({ messages: [], errors: [] }), | |
| 597 | 724 | write: async () => { |
| 598 | 725 | if (failNextWrite) { |
| 599 | 726 | failNextWrite = false; |
| ... | ... | @@ -630,7 +757,7 @@ describe("thread outbox", () => { |
| 630 | 757 | const manager = createThreadOutboxManager({ |
| 631 | 758 | registry, |
| 632 | 759 | storage: { |
| 633 | load: async () => [], | |
| 760 | load: async () => ({ messages: [], errors: [] }), | |
| 634 | 761 | write: async () => undefined, |
| 635 | 762 | remove: async () => undefined, |
| 636 | 763 | }, |
| ... | ... | @@ -654,7 +781,7 @@ describe("thread outbox", () => { |
| 654 | 781 | const registry = AtomRegistry.make(); |
| 655 | 782 | const stored = new Map<MessageId, QueuedThreadMessage>(); |
| 656 | 783 | const storage: ThreadOutboxStorage = { |
| 657 | load: async () => [...stored.values()], | |
| 784 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 658 | 785 | write: async (message) => { |
| 659 | 786 | stored.set(message.messageId, message); |
| 660 | 787 | }, |
| ... | ... | @@ -689,7 +816,7 @@ describe("thread outbox", () => { |
| 689 | 816 | const manager = createThreadOutboxManager({ |
| 690 | 817 | registry, |
| 691 | 818 | storage: { |
| 692 | load: async () => [], | |
| 819 | load: async () => ({ messages: [], errors: [] }), | |
| 693 | 820 | write: async (message) => { |
| 694 | 821 | writes.push(message.text); |
| 695 | 822 | }, |
| ... | ... | @@ -733,7 +860,7 @@ describe("thread outbox", () => { |
| 733 | 860 | const manager = createThreadOutboxManager({ |
| 734 | 861 | registry, |
| 735 | 862 | storage: { |
| 736 | load: async () => [], | |
| 863 | load: async () => ({ messages: [], errors: [] }), | |
| 737 | 864 | write: async (message) => { |
| 738 | 865 | writes.push(message.text); |
| 739 | 866 | if (message.text === "stale upload") { |
| ... | ... | @@ -777,7 +904,7 @@ describe("thread outbox", () => { |
| 777 | 904 | const manager = createThreadOutboxManager({ |
| 778 | 905 | registry, |
| 779 | 906 | storage: { |
| 780 | load: async () => [...stored.values()], | |
| 907 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 781 | 908 | write: async (message) => { |
| 782 | 909 | stored.set(message.messageId, message); |
| 783 | 910 | }, |
| ... | ... | @@ -826,7 +953,7 @@ describe("thread outbox", () => { |
| 826 | 953 | const manager = createThreadOutboxManager({ |
| 827 | 954 | registry, |
| 828 | 955 | storage: { |
| 829 | load: async () => [...stored.values()], | |
| 956 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 830 | 957 | write: async (message) => { |
| 831 | 958 | if (message === retried) { |
| 832 | 959 | replacementWriteStarted.resolve(); |
| ... | ... | @@ -876,7 +1003,7 @@ describe("thread outbox", () => { |
| 876 | 1003 | const manager = createThreadOutboxManager({ |
| 877 | 1004 | registry, |
| 878 | 1005 | storage: { |
| 879 | load: async () => [...stored.values()], | |
| 1006 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 880 | 1007 | write: async (message) => { |
| 881 | 1008 | stored.set(message.messageId, message); |
| 882 | 1009 | }, |
| ... | ... | @@ -915,7 +1042,7 @@ describe("thread outbox", () => { |
| 915 | 1042 | const manager = createThreadOutboxManager({ |
| 916 | 1043 | registry, |
| 917 | 1044 | storage: { |
| 918 | load: async () => [...stored.values()], | |
| 1045 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 919 | 1046 | write: async (message) => { |
| 920 | 1047 | stored.set(message.messageId, message); |
| 921 | 1048 | }, |
| ... | ... | @@ -979,7 +1106,7 @@ describe("thread outbox", () => { |
| 979 | 1106 | const manager = createThreadOutboxManager({ |
| 980 | 1107 | registry, |
| 981 | 1108 | storage: { |
| 982 | load: async () => [...stored.values()], | |
| 1109 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 983 | 1110 | write: async (message) => { |
| 984 | 1111 | stored.set(message.messageId, message); |
| 985 | 1112 | }, |
| ... | ... | @@ -1022,7 +1149,7 @@ describe("thread outbox", () => { |
| 1022 | 1149 | const manager = createThreadOutboxManager({ |
| 1023 | 1150 | registry, |
| 1024 | 1151 | storage: { |
| 1025 | load: async () => [...stored.values()], | |
| 1152 | load: async () => ({ messages: [...stored.values()], errors: [] }), | |
| 1026 | 1153 | write: async (message) => { |
| 1027 | 1154 | stored.set(message.messageId, message); |
| 1028 | 1155 | }, |
apps/mobile/src/state/thread-outbox.ts-4| ... | ... | @@ -21,10 +21,6 @@ export async function flushThreadOutbox(): Promise<void> { |
| 21 | 21 | await flushThreadOutboxWrites(); |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | export function ensureThreadOutboxLoaded(): void { | |
| 25 | void threadOutboxManager.load(); | |
| 26 | } | |
| 27 | ||
| 28 | 24 | export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { |
| 29 | 25 | return threadOutboxManager.enqueue(message); |
| 30 | 26 | } |
apps/mobile/src/state/use-composer-drafts.test.ts+86-1| ... | ... | @@ -1748,7 +1748,92 @@ describe("mobile composer drafts", () => { |
| 1748 | 1748 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]); |
| 1749 | 1749 | }); |
| 1750 | 1750 | |
| 1751 | // Uses a fresh module instance (hydration is one-shot), so it stays last. | |
| 1751 | // These final tests use fresh module instances for independent hydration. | |
| 1752 | it("keeps attachment files and uploads after a recovered message leaves an incomplete outbox", async () => { | |
| 1753 | vi.resetModules(); | |
| 1754 | const drafts = await import("./use-composer-drafts"); | |
| 1755 | const { threadOutboxManager: manager } = await import("./thread-outbox"); | |
| 1756 | const { expoThreadOutboxStorage: storage, ThreadOutboxStorageError } = | |
| 1757 | await import("./thread-outbox-storage"); | |
| 1758 | const { removeThreadOutboxMessage } = await import("./thread-outbox-removal"); | |
| 1759 | const { appAtomRegistry: registry } = await import("./atom-registry"); | |
| 1760 | onTestFinished(() => registry.dispose()); | |
| 1761 | const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 1762 | onTestFinished(() => warning.mockRestore()); | |
| 1763 | const file = { | |
| 1764 | id: "file-partial-outbox", | |
| 1765 | type: "file" as const, | |
| 1766 | name: "shared.pdf", | |
| 1767 | mimeType: "application/pdf", | |
| 1768 | sizeBytes: 42, | |
| 1769 | fileUri: "file:///documents/t3-composer-attachments/shared.pdf", | |
| 1770 | uploadEnvironmentId: EnvironmentId.make("environment-1"), | |
| 1771 | uploadedAttachmentId: "pending-partial-outbox", | |
| 1772 | }; | |
| 1773 | const readable = { | |
| 1774 | environmentId: EnvironmentId.make("environment-1"), | |
| 1775 | threadId: ThreadId.make("thread-1"), | |
| 1776 | messageId: MessageId.make("message-readable"), | |
| 1777 | commandId: CommandId.make("command-readable"), | |
| 1778 | text: "Send the readable message", | |
| 1779 | attachments: [file], | |
| 1780 | createdAt: "2026-09-04T00:00:00.000Z", | |
| 1781 | }; | |
| 1782 | const saved = new Map([[readable.messageId, readable]]); | |
| 1783 | let unreadable = true; | |
| 1784 | const load = vi.spyOn(storage, "load").mockImplementation(async () => ({ | |
| 1785 | messages: [...saved.values()], | |
| 1786 | errors: unreadable | |
| 1787 | ? [ | |
| 1788 | new ThreadOutboxStorageError({ | |
| 1789 | operation: "read-message", | |
| 1790 | environmentId: null, | |
| 1791 | threadId: null, | |
| 1792 | messageId: null, | |
| 1793 | fileName: "unreadable.json", | |
| 1794 | cause: new Error("unreadable record"), | |
| 1795 | }), | |
| 1796 | ] | |
| 1797 | : [], | |
| 1798 | })); | |
| 1799 | const remove = vi.spyOn(storage, "remove").mockImplementation(async (message) => { | |
| 1800 | saved.delete(message.messageId); | |
| 1801 | }); | |
| 1802 | onTestFinished(() => { | |
| 1803 | load.mockRestore(); | |
| 1804 | remove.mockRestore(); | |
| 1805 | }); | |
| 1806 | ||
| 1807 | await expect(manager.load()).resolves.toBe(false); | |
| 1808 | await expect(manager.confirmQueued(readable)).resolves.toBe(true); | |
| 1809 | await expect(removeThreadOutboxMessage(readable)).resolves.toBe(true); | |
| 1810 | await drafts.releaseUnusedComposerAttachmentFiles([file]); | |
| 1811 | expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toEqual({}); | |
| 1812 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 1813 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | |
| 1814 | ||
| 1815 | const recovered = { | |
| 1816 | ...readable, | |
| 1817 | environmentId: EnvironmentId.make("environment-2"), | |
| 1818 | messageId: MessageId.make("message-recovered"), | |
| 1819 | commandId: CommandId.make("command-recovered"), | |
| 1820 | }; | |
| 1821 | saved.set(recovered.messageId, recovered); | |
| 1822 | unreadable = false; | |
| 1823 | await expect(manager.load()).resolves.toBe(true); | |
| 1824 | await drafts.releaseUnusedComposerAttachmentFiles([file]); | |
| 1825 | expect(composerAttachmentCleanupMocks.remove).not.toHaveBeenCalled(); | |
| 1826 | expect(composerAttachmentCleanupMocks.releaseUploads).not.toHaveBeenCalled(); | |
| 1827 | ||
| 1828 | await removeThreadOutboxMessage(recovered); | |
| 1829 | await drafts.releaseUnusedComposerAttachmentFiles([file]); | |
| 1830 | expect(composerAttachmentCleanupMocks.remove).toHaveBeenCalledWith(file.fileUri); | |
| 1831 | expect(composerAttachmentCleanupMocks.releaseUploads).toHaveBeenCalledWith( | |
| 1832 | file.uploadEnvironmentId, | |
| 1833 | [file.uploadedAttachmentId], | |
| 1834 | ); | |
| 1835 | }); | |
| 1836 | ||
| 1752 | 1837 | it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { |
| 1753 | 1838 | const file = { |
| 1754 | 1839 | id: "file-cold-start", |
apps/mobile/src/state/use-thread-outbox-drain.test.ts+3-9| ... | ... | @@ -54,6 +54,8 @@ const harness = vi.hoisted(() => ({ |
| 54 | 54 | })(), |
| 55 | 55 | })); |
| 56 | 56 | |
| 57 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | |
| 58 | ||
| 57 | 59 | vi.mock("expo-file-system", () => ({ |
| 58 | 60 | Directory: harness.draftFile.Directory, |
| 59 | 61 | File: harness.draftFile.File, |
| ... | ... | @@ -113,7 +115,7 @@ vi.mock("./thread-outbox", async () => { |
| 113 | 115 | harness.manager = createThreadOutboxManager({ |
| 114 | 116 | registry: appAtomRegistry, |
| 115 | 117 | storage: { |
| 116 | load: async () => [], | |
| 118 | load: async () => ({ messages: [], errors: [] }), | |
| 117 | 119 | write: async () => undefined, |
| 118 | 120 | remove: (message) => harness.removeOutboxMessage(message), |
| 119 | 121 | }, |
| ... | ... | @@ -122,7 +124,6 @@ vi.mock("./thread-outbox", async () => { |
| 122 | 124 | return { |
| 123 | 125 | threadOutboxManager: manager, |
| 124 | 126 | flushThreadOutbox: async () => undefined, |
| 125 | ensureThreadOutboxLoaded: () => undefined, | |
| 126 | 127 | confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message), |
| 127 | 128 | updateThreadOutboxMessage: (message: never, expectedRevision?: number) => |
| 128 | 129 | manager.update(message, expectedRevision), |
| ... | ... | @@ -219,7 +220,6 @@ describe("thread outbox attachment preparation", () => { |
| 219 | 220 | ); |
| 220 | 221 | const preparationStarted = Promise.withResolvers<void>(); |
| 221 | 222 | const preparationBarrier = Promise.withResolvers<PreparedTurnAttachments>(); |
| 222 | const releaseUploads = vi.fn(async () => undefined); | |
| 223 | 223 | harness.prepareTurnAttachments.mockImplementationOnce(async () => { |
| 224 | 224 | preparationStarted.resolve(); |
| 225 | 225 | return preparationBarrier.promise; |
| ... | ... | @@ -237,12 +237,10 @@ describe("thread outbox attachment preparation", () => { |
| 237 | 237 | attachments: [], |
| 238 | 238 | draftAttachments: message.attachments, |
| 239 | 239 | pendingAttachmentIds: ["pending-reused-upload"], |
| 240 | releaseUploads, | |
| 241 | 240 | }); |
| 242 | 241 | |
| 243 | 242 | await expect(preparation).resolves.toEqual({ status: "abandoned" }); |
| 244 | 243 | expect(remainingMessages()).toEqual([edited]); |
| 245 | expect(releaseUploads).not.toHaveBeenCalled(); | |
| 246 | 244 | }); |
| 247 | 245 | |
| 248 | 246 | it("keeps an unchanged queued payload ready after attachment reuse", async () => { |
| ... | ... | @@ -254,13 +252,11 @@ describe("thread outbox attachment preparation", () => { |
| 254 | 252 | }), |
| 255 | 253 | "pending-reused-upload", |
| 256 | 254 | ); |
| 257 | const releaseUploads = vi.fn(async () => undefined); | |
| 258 | 255 | harness.prepareTurnAttachments.mockResolvedValueOnce({ |
| 259 | 256 | status: "ready", |
| 260 | 257 | attachments: [], |
| 261 | 258 | draftAttachments: message.attachments, |
| 262 | 259 | pendingAttachmentIds: ["pending-reused-upload"], |
| 263 | releaseUploads, | |
| 264 | 260 | }); |
| 265 | 261 | await harness.manager.enqueue(message); |
| 266 | 262 | const revision = harness.manager.revisionOf(message.messageId); |
| ... | ... | @@ -271,7 +267,6 @@ describe("thread outbox attachment preparation", () => { |
| 271 | 267 | persistedMessage: message, |
| 272 | 268 | deliveryRevision: revision, |
| 273 | 269 | }); |
| 274 | expect(releaseUploads).not.toHaveBeenCalled(); | |
| 275 | 270 | }); |
| 276 | 271 | |
| 277 | 272 | it("uses the known next revision after persisting uploaded references", async () => { |
| ... | ... | @@ -296,7 +291,6 @@ describe("thread outbox attachment preparation", () => { |
| 296 | 291 | attachments: [], |
| 297 | 292 | draftAttachments: uploadedAttachments, |
| 298 | 293 | pendingAttachmentIds: ["pending-new-upload"], |
| 299 | releaseUploads: async () => undefined, | |
| 300 | 294 | }; |
| 301 | 295 | }); |
| 302 | 296 | await harness.manager.enqueue(message); |
apps/mobile/src/state/use-thread-outbox-drain.ts+16-15| ... | ... | @@ -15,6 +15,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 15 | 15 | import * as Cause from "effect/Cause"; |
| 16 | 16 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | 17 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 18 | import { Alert } from "react-native"; | |
| 18 | 19 | |
| 19 | 20 | import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; |
| 20 | 21 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; |
| ... | ... | @@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities"; |
| 26 | 27 | import { serverEnvironment } from "./server"; |
| 27 | 28 | import { |
| 28 | 29 | confirmThreadOutboxMessageQueued, |
| 29 | ensureThreadOutboxLoaded, | |
| 30 | 30 | threadOutboxManager, |
| 31 | 31 | threadOutboxRevision, |
| 32 | 32 | updateThreadOutboxMessage, |
| ... | ... | @@ -583,8 +583,21 @@ export function useThreadOutboxDrain(): void { |
| 583 | 583 | ); |
| 584 | 584 | |
| 585 | 585 | useEffect(() => { |
| 586 | ensureThreadOutboxLoaded(); | |
| 586 | let mounted = true; | |
| 587 | const load = async () => { | |
| 588 | if ((await threadOutboxManager.load()) || !mounted) return; | |
| 589 | Alert.alert( | |
| 590 | "Some queued messages could not be loaded", | |
| 591 | "Unreadable records and attachment files are still saved. Other messages can still be sent.", | |
| 592 | [ | |
| 593 | { text: "Dismiss", style: "cancel" }, | |
| 594 | { text: "Retry", onPress: () => void load() }, | |
| 595 | ], | |
| 596 | ); | |
| 597 | }; | |
| 598 | void load(); | |
| 587 | 599 | return () => { |
| 600 | mounted = false; | |
| 588 | 601 | for (const timer of retryTimersRef.current.values()) { |
| 589 | 602 | clearTimeout(timer); |
| 590 | 603 | } |
| ... | ... | @@ -766,12 +779,6 @@ export function useThreadOutboxDrain(): void { |
| 766 | 779 | (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; |
| 767 | 780 | if (delivered) { |
| 768 | 781 | acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); |
| 769 | // The delivered turn holds its own copy of the bytes. A failed delete | |
| 770 | // is surfaced (never fails the delivered turn); the server also | |
| 771 | // expires leaked pending uploads. | |
| 772 | await prepared.releaseUploads().catch((error) => { | |
| 773 | console.warn("[thread-outbox] could not delete consumed pending uploads", error); | |
| 774 | }); | |
| 775 | 782 | } |
| 776 | 783 | return delivered; |
| 777 | 784 | }, |
| ... | ... | @@ -904,13 +911,7 @@ export function useThreadOutboxDrain(): void { |
| 904 | 911 | // payload as a duplicate creation. Hand it to the thread's composer. |
| 905 | 912 | return recoverEditedCreationAfterDelivery(persistedMessage); |
| 906 | 913 | } |
| 907 | if (outcome === "removed") { | |
| 908 | await prepared.releaseUploads().catch((error) => { | |
| 909 | console.warn("[thread-outbox] could not delete consumed pending uploads", error); | |
| 910 | }); | |
| 911 | return true; | |
| 912 | } | |
| 913 | return false; | |
| 914 | return outcome === "removed"; | |
| 914 | 915 | }, |
| 915 | 916 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], |
| 916 | 917 | ); |
apps/server/src/http.ts+9-7| ... | ... | @@ -566,15 +566,20 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( |
| 566 | 566 | } |
| 567 | 567 | } |
| 568 | 568 | const fileInfo = opened.info; |
| 569 | const mimeType = Mime.getType(filePath) ?? "application/octet-stream"; | |
| 570 | const isHtml = mimeType === "text/html"; | |
| 569 | 571 | |
| 570 | 572 | // A hash-like name is not enough: custom static files can use the same naming pattern. |
| 571 | 573 | const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/"); |
| 572 | 574 | const immutable = |
| 573 | /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && immutableBuildAssets.has(relativePath); | |
| 575 | !isHtml && | |
| 576 | /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && | |
| 577 | immutableBuildAssets.has(relativePath); | |
| 574 | 578 | const headers: Record<string, string> = { |
| 575 | 579 | "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache", |
| 576 | 580 | }; |
| 577 | const modifiedAt = Option.getOrUndefined(fileInfo.mtime); | |
| 581 | // Deployments can preserve HTML size and mtime while changing its bundle URLs. | |
| 582 | const modifiedAt = isHtml ? undefined : Option.getOrUndefined(fileInfo.mtime); | |
| 578 | 583 | const etag = modifiedAt |
| 579 | 584 | ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"` |
| 580 | 585 | : undefined; |
| ... | ... | @@ -599,17 +604,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( |
| 599 | 604 | : ifModifiedSince !== undefined && |
| 600 | 605 | modifiedAt !== undefined && |
| 601 | 606 | Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince); |
| 602 | if (unchanged) { | |
| 607 | if (!isHtml && unchanged) { | |
| 603 | 608 | return HttpServerResponse.empty({ |
| 604 | 609 | status: 304, |
| 605 | 610 | headers: { ...headers, Vary: "Accept-Encoding" }, |
| 606 | 611 | }); |
| 607 | 612 | } |
| 608 | 613 | |
| 609 | const contentType = | |
| 610 | path.extname(filePath) === ".html" | |
| 611 | ? "text/html; charset=utf-8" | |
| 612 | : (Mime.getType(filePath) ?? "application/octet-stream"); | |
| 614 | const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; | |
| 613 | 615 | // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. |
| 614 | 616 | // HEAD still passes through compression, which selects headers without reading the stream. |
| 615 | 617 | return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { |
apps/server/src/orchestration/LiveStreamBudget.ts+1-1| ... | ... | @@ -93,7 +93,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* |
| 93 | 93 | return Effect.succeed(item); |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | // Replace one coalescing batch atomically. Both raw and projected payloads | |
| 96 | // Replace one coalescing batch atomically. Queued and coalesced payloads | |
| 97 | 97 | // count against the same budget, and discarded updates release their charge. |
| 98 | 98 | const replace = <A extends object>( |
| 99 | 99 | previous: ReadonlyArray<RetainedLiveItem<unknown>>, |
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+3-2| ... | ... | @@ -130,7 +130,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales |
| 130 | 130 | pendingUpdates, |
| 131 | 131 | coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({ |
| 132 | 132 | kind: "event" as const, |
| 133 | event: projectActivityEvent(event), | |
| 133 | event, | |
| 134 | 134 | })), |
| 135 | 135 | (item) => item.event, |
| 136 | 136 | ); |
| ... | ... | @@ -167,7 +167,8 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales |
| 167 | 167 | Effect.gen(function* () { |
| 168 | 168 | yield* budget.check; |
| 169 | 169 | if (input.kind === "event") { |
| 170 | yield* budget.retain(input.event).pipe( | |
| 170 | // Retain only the client payload, not full persisted tool output. | |
| 171 | yield* budget.retain(projectActivityEvent(input.event)).pipe( | |
| 171 | 172 | Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), |
| 172 | 173 | Effect.uninterruptible, |
| 173 | 174 | ); |
apps/server/src/server.test.ts+153-15| ... | ... | @@ -1688,14 +1688,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1688 | 1688 | const fileSystem = yield* FileSystem.FileSystem; |
| 1689 | 1689 | const path = yield* Path.Path; |
| 1690 | 1690 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" }); |
| 1691 | const indexPath = path.join(staticDir, "index.html"); | |
| 1692 | yield* fileSystem.writeFileString(indexPath, "<html>first build</html>"); | |
| 1691 | const assetPath = path.join(staticDir, "app.js"); | |
| 1692 | yield* fileSystem.writeFileString(assetPath, 'export const build = "first";'); | |
| 1693 | 1693 | yield* buildAppUnderTest({ config: { staticDir } }); |
| 1694 | 1694 | |
| 1695 | const initial = yield* HttpClient.get("/"); | |
| 1695 | const initial = yield* HttpClient.get("/app.js"); | |
| 1696 | 1696 | assert.equal(initial.status, 200); |
| 1697 | 1697 | assert.equal(initial.headers["cache-control"], "no-cache"); |
| 1698 | assert.include(yield* initial.text, "first build"); | |
| 1698 | assert.include(yield* initial.text, "first"); | |
| 1699 | 1699 | const etag = initial.headers.etag; |
| 1700 | 1700 | assert.isDefined(etag); |
| 1701 | 1701 | assert.isDefined(initial.headers["last-modified"]); |
| ... | ... | @@ -1706,30 +1706,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1706 | 1706 | { "if-none-match": "*" }, |
| 1707 | 1707 | { "if-modified-since": initial.headers["last-modified"]! }, |
| 1708 | 1708 | ]) { |
| 1709 | const response = yield* HttpClient.get("/", { headers }); | |
| 1709 | const response = yield* HttpClient.get("/app.js", { headers }); | |
| 1710 | 1710 | assert.equal(response.status, 304); |
| 1711 | 1711 | assert.equal(response.headers.etag, etag); |
| 1712 | 1712 | assert.equal(response.headers["cache-control"], "no-cache"); |
| 1713 | 1713 | assert.equal(yield* response.text, ""); |
| 1714 | 1714 | } |
| 1715 | 1715 | |
| 1716 | const mismatched = yield* HttpClient.get("/", { | |
| 1716 | const mismatched = yield* HttpClient.get("/app.js", { | |
| 1717 | 1717 | headers: { |
| 1718 | 1718 | "if-none-match": '"another-build"', |
| 1719 | 1719 | "if-modified-since": initial.headers["last-modified"]!, |
| 1720 | 1720 | }, |
| 1721 | 1721 | }); |
| 1722 | 1722 | assert.equal(mismatched.status, 200); |
| 1723 | assert.include(yield* mismatched.text, "first build"); | |
| 1723 | assert.include(yield* mismatched.text, "first"); | |
| 1724 | 1724 | |
| 1725 | yield* fileSystem.writeFileString(indexPath, "<html>the next build is available</html>"); | |
| 1726 | const changed = yield* HttpClient.get("/", { headers: { "if-none-match": etag! } }); | |
| 1725 | yield* fileSystem.writeFileString(assetPath, 'export const build = "the next build";'); | |
| 1726 | const changed = yield* HttpClient.get("/app.js", { headers: { "if-none-match": etag! } }); | |
| 1727 | 1727 | assert.equal(changed.status, 200); |
| 1728 | 1728 | assert.notEqual(changed.headers.etag, etag); |
| 1729 | 1729 | assert.include(yield* changed.text, "next build"); |
| 1730 | 1730 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1731 | 1731 | ); |
| 1732 | 1732 | |
| 1733 | it.effect("serves changed HTML with the same size and timestamp", () => | |
| 1734 | Effect.gen(function* () { | |
| 1735 | const fileSystem = yield* FileSystem.FileSystem; | |
| 1736 | const path = yield* Path.Path; | |
| 1737 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-html-" }); | |
| 1738 | const indexPath = path.join(staticDir, "index.html"); | |
| 1739 | const modifiedAt = DateTime.toDateUtc(DateTime.makeUnsafe("1985-10-26T08:15:00.000Z")); | |
| 1740 | yield* fileSystem.writeFileString(indexPath, "<html>old build</html>"); | |
| 1741 | yield* fileSystem.utimes(indexPath, modifiedAt, modifiedAt); | |
| 1742 | yield* buildAppUnderTest({ config: { staticDir } }); | |
| 1743 | ||
| 1744 | const initial = yield* HttpClient.get("/"); | |
| 1745 | assert.equal(yield* initial.text, "<html>old build</html>"); | |
| 1746 | const previousEtag = initial.headers.etag ?? '"previous-html"'; | |
| 1747 | const nextHtml = "<html>new build</html>"; | |
| 1748 | yield* fileSystem.writeFileString(indexPath, nextHtml); | |
| 1749 | yield* fileSystem.utimes(indexPath, modifiedAt, modifiedAt); | |
| 1750 | ||
| 1751 | for (const [resource, headers] of [ | |
| 1752 | ["/", { "if-none-match": previousEtag }], | |
| 1753 | ["/threads/example", { "if-modified-since": modifiedAt.toUTCString() }], | |
| 1754 | ["/", { "if-none-match": "*" }], | |
| 1755 | ] as const) { | |
| 1756 | const response = yield* HttpClient.get(resource, { headers }); | |
| 1757 | assert.equal(response.status, 200); | |
| 1758 | assert.equal(yield* response.text, nextHtml); | |
| 1759 | assert.equal(response.headers["cache-control"], "no-cache"); | |
| 1760 | assert.isUndefined(response.headers.etag); | |
| 1761 | assert.isUndefined(response.headers["last-modified"]); | |
| 1762 | } | |
| 1763 | ||
| 1764 | const head = yield* HttpClient.head("/", { | |
| 1765 | headers: { "if-none-match": previousEtag, "accept-encoding": "identity" }, | |
| 1766 | }); | |
| 1767 | assert.equal(head.status, 200); | |
| 1768 | assert.equal(head.headers["content-length"], String(Buffer.byteLength(nextHtml))); | |
| 1769 | assert.equal(yield* head.text, ""); | |
| 1770 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1771 | ); | |
| 1772 | ||
| 1733 | 1773 | it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () => |
| 1734 | 1774 | Effect.gen(function* () { |
| 1735 | 1775 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -1917,8 +1957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1917 | 1957 | const fileSystem = yield* FileSystem.FileSystem; |
| 1918 | 1958 | const path = yield* Path.Path; |
| 1919 | 1959 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" }); |
| 1920 | const filePath = path.join(staticDir, "index.html"); | |
| 1921 | const body = "<p>file content</p>".repeat(1024); | |
| 1960 | const filePath = path.join(staticDir, "app.txt"); | |
| 1961 | const body = "file content\n".repeat(1024); | |
| 1922 | 1962 | yield* fileSystem.writeFileString(filePath, body); |
| 1923 | 1963 | const closed = yield* Queue.unbounded<FileSystem.File>(); |
| 1924 | 1964 | const blocked = yield* Deferred.make<void>(); |
| ... | ... | @@ -1963,14 +2003,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1963 | 2003 | Effect.provideService(FileSystem.FileSystem, trackedFileSystem), |
| 1964 | 2004 | ); |
| 1965 | 2005 | |
| 1966 | const get = yield* HttpClient.get("/"); | |
| 2006 | const get = yield* HttpClient.get("/app.txt"); | |
| 1967 | 2007 | assert.equal(yield* get.text, body); |
| 1968 | 2008 | yield* Queue.take(closed); |
| 1969 | 2009 | assert.equal(active.size, 0); |
| 1970 | 2010 | assert.isAbove(bodyReads, 0); |
| 1971 | 2011 | const readsAfterGet = bodyReads; |
| 1972 | 2012 | |
| 1973 | const head = yield* HttpClient.head("/", { headers: { "accept-encoding": "gzip" } }); | |
| 2013 | const head = yield* HttpClient.head("/app.txt", { headers: { "accept-encoding": "gzip" } }); | |
| 1974 | 2014 | assert.equal(head.status, 200); |
| 1975 | 2015 | assert.equal(head.headers["content-encoding"], "gzip"); |
| 1976 | 2016 | assert.equal(yield* head.text, ""); |
| ... | ... | @@ -1978,7 +2018,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1978 | 2018 | assert.equal(active.size, 0); |
| 1979 | 2019 | assert.equal(bodyReads, readsAfterGet); |
| 1980 | 2020 | |
| 1981 | const unchanged = yield* HttpClient.get("/", { | |
| 2021 | const unchanged = yield* HttpClient.get("/app.txt", { | |
| 1982 | 2022 | headers: { "if-none-match": get.headers.etag! }, |
| 1983 | 2023 | }); |
| 1984 | 2024 | assert.equal(unchanged.status, 304); |
| ... | ... | @@ -1987,7 +2027,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1987 | 2027 | assert.equal(bodyReads, readsAfterGet); |
| 1988 | 2028 | |
| 1989 | 2029 | blockAfterOpen = true; |
| 1990 | const cancelled = yield* HttpClient.get("/").pipe(Effect.forkChild); | |
| 2030 | const cancelled = yield* HttpClient.get("/app.txt").pipe(Effect.forkChild); | |
| 1991 | 2031 | yield* Deferred.await(blocked); |
| 1992 | 2032 | assert.equal(active.size, 1); |
| 1993 | 2033 | yield* Fiber.interrupt(cancelled); |
| ... | ... | @@ -7866,6 +7906,104 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7866 | 7906 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 7867 | 7907 | ); |
| 7868 | 7908 | |
| 7909 | for (const subscription of ["thread", "shell"] as const) { | |
| 7910 | it.effect("delivers large raw tool results through the " + subscription + " stream", () => | |
| 7911 | Effect.gen(function* () { | |
| 7912 | const eventThreadId = | |
| 7913 | subscription === "thread" ? defaultThreadId : ThreadId.make("other-live-thread"); | |
| 7914 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; | |
| 7915 | const baseEvent = makeLiveToolActivityEvent(2, "tool.completed"); | |
| 7916 | const event: OrchestrationEvent = { | |
| 7917 | ...baseEvent, | |
| 7918 | aggregateId: eventThreadId, | |
| 7919 | payload: { | |
| 7920 | threadId: eventThreadId, | |
| 7921 | activity: { | |
| 7922 | ...baseEvent.payload.activity, | |
| 7923 | summary: "Build complete", | |
| 7924 | payload: { | |
| 7925 | itemType: "command_execution", | |
| 7926 | toolCallId: "call-build", | |
| 7927 | status: "completed", | |
| 7928 | title: "Build complete", | |
| 7929 | data: { | |
| 7930 | item: { | |
| 7931 | command: "build", | |
| 7932 | aggregatedOutput: "Build complete\n" + "x".repeat(9 * 1024 * 1024), | |
| 7933 | }, | |
| 7934 | }, | |
| 7935 | }, | |
| 7936 | }, | |
| 7937 | }, | |
| 7938 | }; | |
| 7939 | yield* buildAppUnderTest({ | |
| 7940 | layers: { | |
| 7941 | orchestrationEngine: { | |
| 7942 | streamDomainEvents: Stream.concat(Stream.make(event), Stream.never), | |
| 7943 | }, | |
| 7944 | projectionSnapshotQuery: { | |
| 7945 | getThreadDetailSnapshot: () => | |
| 7946 | Effect.succeed(Option.some({ snapshotSequence: 1, thread })), | |
| 7947 | getThreadShellById: (threadId) => | |
| 7948 | Effect.succeed( | |
| 7949 | Option.some({ | |
| 7950 | ...makeDefaultOrchestrationThreadShell(), | |
| 7951 | id: threadId, | |
| 7952 | title: "Build complete", | |
| 7953 | }), | |
| 7954 | ), | |
| 7955 | }, | |
| 7956 | }, | |
| 7957 | }); | |
| 7958 | ||
| 7959 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 7960 | const items = | |
| 7961 | subscription === "thread" | |
| 7962 | ? yield* Effect.scoped( | |
| 7963 | withWsRpcClient(wsUrl, (client) => | |
| 7964 | client[ORCHESTRATION_WS_METHODS.subscribeThread]({ | |
| 7965 | threadId: defaultThreadId, | |
| 7966 | requestCompletionMarker: true, | |
| 7967 | }).pipe( | |
| 7968 | Stream.takeUntil((item) => item.kind === "synchronized"), | |
| 7969 | Stream.runCollect, | |
| 7970 | ), | |
| 7971 | ), | |
| 7972 | ) | |
| 7973 | : yield* Effect.scoped( | |
| 7974 | withWsRpcClient(wsUrl, (client) => | |
| 7975 | client[ORCHESTRATION_WS_METHODS.subscribeShell]({ | |
| 7976 | requestCompletionMarker: true, | |
| 7977 | }).pipe( | |
| 7978 | Stream.takeUntil((item) => item.kind === "synchronized"), | |
| 7979 | Stream.runCollect, | |
| 7980 | ), | |
| 7981 | ), | |
| 7982 | ); | |
| 7983 | ||
| 7984 | assert.equal(items[0]?.kind, "snapshot"); | |
| 7985 | const update = items[1]; | |
| 7986 | if (subscription === "thread") { | |
| 7987 | assertTrue(update?.kind === "event" && update.event.type === "thread.activity-appended"); | |
| 7988 | assert.equal(update.event.sequence, 2); | |
| 7989 | assert.deepEqual(update.event.payload.activity.payload, { | |
| 7990 | itemType: "command_execution", | |
| 7991 | toolCallId: "call-build", | |
| 7992 | status: "completed", | |
| 7993 | title: "Build complete", | |
| 7994 | data: { item: { command: "build", aggregatedOutput: "Build complete" } }, | |
| 7995 | }); | |
| 7996 | } else { | |
| 7997 | assertTrue(update?.kind === "thread-upserted"); | |
| 7998 | assert.equal(update.sequence, 2); | |
| 7999 | assert.equal(update.thread.id, eventThreadId); | |
| 8000 | assert.equal(update.thread.title, "Build complete"); | |
| 8001 | } | |
| 8002 | assert.deepEqual(items[2], { kind: "synchronized" }); | |
| 8003 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 8004 | ); | |
| 8005 | } | |
| 8006 | ||
| 7869 | 8007 | it.effect("coalesces buffered live tool updates to the latest state", () => |
| 7870 | 8008 | Effect.gen(function* () { |
| 7871 | 8009 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; |
apps/server/src/ws.ts+27-11| ... | ... | @@ -38,7 +38,7 @@ import { |
| 38 | 38 | OrchestrationSearchThreadsError, |
| 39 | 39 | OrchestrationGetTurnDiffError, |
| 40 | 40 | ORCHESTRATION_WS_METHODS, |
| 41 | type ProjectId, | |
| 41 | ProjectId, | |
| 42 | 42 | type ProjectEntriesFailure, |
| 43 | 43 | type ProjectFileFailure, |
| 44 | 44 | type ProjectFileOperation, |
| ... | ... | @@ -723,19 +723,33 @@ const makeWsRpcLayer = ( |
| 723 | 723 | }); |
| 724 | 724 | }; |
| 725 | 725 | |
| 726 | // Shell updates refetch the aggregate. Message and tool bodies are not needed. | |
| 727 | const toShellEvent = ({ | |
| 728 | type, | |
| 729 | aggregateKind, | |
| 730 | aggregateId, | |
| 731 | sequence, | |
| 732 | }: OrchestrationEvent) => ({ | |
| 733 | type, | |
| 734 | aggregateKind, | |
| 735 | aggregateId, | |
| 736 | sequence, | |
| 737 | }); | |
| 738 | type ShellEvent = ReturnType<typeof toShellEvent>; | |
| 739 | ||
| 726 | 740 | const toShellStreamEvent = ( |
| 727 | event: OrchestrationEvent, | |
| 741 | event: ShellEvent, | |
| 728 | 742 | ): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => { |
| 729 | 743 | switch (event.type) { |
| 730 | 744 | case "project.created": |
| 731 | 745 | case "project.meta-updated": |
| 732 | return projectUpsertOrRemove(event.payload.projectId, event.sequence); | |
| 746 | return projectUpsertOrRemove(ProjectId.make(event.aggregateId), event.sequence); | |
| 733 | 747 | case "project.deleted": |
| 734 | 748 | return Effect.succeed( |
| 735 | 749 | Option.some({ |
| 736 | 750 | kind: "project-removed" as const, |
| 737 | 751 | sequence: event.sequence, |
| 738 | projectId: event.payload.projectId, | |
| 752 | projectId: ProjectId.make(event.aggregateId), | |
| 739 | 753 | }), |
| 740 | 754 | ); |
| 741 | 755 | case "thread.deleted": |
| ... | ... | @@ -744,11 +758,11 @@ const makeWsRpcLayer = ( |
| 744 | 758 | Option.some({ |
| 745 | 759 | kind: "thread-removed" as const, |
| 746 | 760 | sequence: event.sequence, |
| 747 | threadId: event.payload.threadId, | |
| 761 | threadId: ThreadId.make(event.aggregateId), | |
| 748 | 762 | }), |
| 749 | 763 | ); |
| 750 | 764 | case "thread.unarchived": |
| 751 | return threadUpsertOrRemove(event.payload.threadId, event.sequence); | |
| 765 | return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); | |
| 752 | 766 | default: |
| 753 | 767 | if (event.aggregateKind !== "thread") { |
| 754 | 768 | return Effect.succeed(Option.none()); |
| ... | ... | @@ -862,13 +876,13 @@ const makeWsRpcLayer = ( |
| 862 | 876 | // item. The refetch runs with bounded concurrency (order-preserving). |
| 863 | 877 | const SHELL_REFETCH_CONCURRENCY = 8; |
| 864 | 878 | const coalesceShellEvents = ( |
| 865 | events: ReadonlyArray<OrchestrationEvent>, | |
| 879 | events: ReadonlyArray<ShellEvent>, | |
| 866 | 880 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamEvent>, never, never> => |
| 867 | 881 | Effect.gen(function* () { |
| 868 | 882 | if (events.length === 0) { |
| 869 | 883 | return []; |
| 870 | 884 | } |
| 871 | const latestByAggregate = new Map<string, OrchestrationEvent>(); | |
| 885 | const latestByAggregate = new Map<string, ShellEvent>(); | |
| 872 | 886 | for (const event of events) { |
| 873 | 887 | latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); |
| 874 | 888 | } |
| ... | ... | @@ -891,16 +905,17 @@ const makeWsRpcLayer = ( |
| 891 | 905 | stream: Stream.Stream<OrchestrationEvent, E, R>, |
| 892 | 906 | ): Stream.Stream<OrchestrationShellStreamEvent, E, R> => |
| 893 | 907 | stream.pipe( |
| 908 | Stream.map(toShellEvent), | |
| 894 | 909 | Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), |
| 895 | 910 | Stream.mapEffect(coalesceShellEvents), |
| 896 | 911 | Stream.flatMap((items) => Stream.fromIterable(items)), |
| 897 | 912 | ); |
| 898 | 913 | |
| 899 | 914 | type ShellLiveInput = |
| 900 | | { readonly kind: "event"; readonly event: OrchestrationEvent } | |
| 915 | | { readonly kind: "event"; readonly event: ShellEvent } | |
| 901 | 916 | | { readonly kind: "synchronized" }; |
| 902 | 917 | |
| 903 | // A completion marker is queued alongside raw live events so it cannot | |
| 918 | // A completion marker is queued alongside live event metadata so it cannot | |
| 904 | 919 | // overtake an event still waiting in the coalescing window. Split each |
| 905 | 920 | // batch at markers and coalesce only the event segments on either side. |
| 906 | 921 | const coalesceShellLiveInputs = ( |
| ... | ... | @@ -908,7 +923,7 @@ const makeWsRpcLayer = ( |
| 908 | 923 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamItem>, never, never> => |
| 909 | 924 | Effect.gen(function* () { |
| 910 | 925 | const output: Array<OrchestrationShellStreamItem> = []; |
| 911 | let pendingEvents: Array<OrchestrationEvent> = []; | |
| 926 | let pendingEvents: Array<ShellEvent> = []; | |
| 912 | 927 | |
| 913 | 928 | for (const input of inputs) { |
| 914 | 929 | if (input.kind === "event") { |
| ... | ... | @@ -1430,6 +1445,7 @@ const makeWsRpcLayer = ( |
| 1430 | 1445 | ); |
| 1431 | 1446 | yield* Effect.forkScoped( |
| 1432 | 1447 | orchestrationEngine.streamDomainEvents.pipe( |
| 1448 | Stream.map(toShellEvent), | |
| 1433 | 1449 | Stream.runForEach((event) => |
| 1434 | 1450 | liveBudget.retain({ kind: "event" as const, event }, event).pipe( |
| 1435 | 1451 | Effect.flatMap((item) => Queue.offer(liveBuffer, item)), |
apps/web/src/components/ChatMarkdown.test.tsx+44| ... | ... | @@ -104,6 +104,50 @@ describe("ChatMarkdown favicon privacy", () => { |
| 104 | 104 | }); |
| 105 | 105 | |
| 106 | 106 | describe("ChatMarkdown streaming", () => { |
| 107 | it("recovers highlighting after a failed fence changes without resetting its controls", async () => { | |
| 108 | const highlighter = await getSyntaxHighlighterPromise("text"); | |
| 109 | const codeToHtml = highlighter.codeToHtml.bind(highlighter); | |
| 110 | let fail = true; | |
| 111 | vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => { | |
| 112 | if (fail) throw new Error("Temporary highlighter failure"); | |
| 113 | return codeToHtml(...args); | |
| 114 | }); | |
| 115 | vi.spyOn(console, "error").mockImplementation(() => {}); | |
| 116 | vi.spyOn(console, "warn").mockImplementation(() => {}); | |
| 117 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 118 | let renderer: ReactTestRenderer | undefined; | |
| 119 | ||
| 120 | try { | |
| 121 | await act(async () => { | |
| 122 | renderer = create( | |
| 123 | <ChatMarkdown cwd="/tmp/project" text={"```text\ninitial\n```"} isStreaming />, | |
| 124 | ); | |
| 125 | }); | |
| 126 | const mounted = renderer!; | |
| 127 | const codeBlock = mounted.root.findByProps({ "data-language": "text" }); | |
| 128 | const initialWrap = codeBlock.props["data-wrap"] === "true"; | |
| 129 | const wrap = codeButton(mounted, initialWrap ? "Disable line wrap" : "Wrap lines"); | |
| 130 | await act(async () => { | |
| 131 | wrap.onClick?.({} as Parameters<NonNullable<typeof wrap.onClick>>[0]); | |
| 132 | }); | |
| 133 | expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(0); | |
| 134 | ||
| 135 | fail = false; | |
| 136 | await act(async () => { | |
| 137 | mounted.update( | |
| 138 | <ChatMarkdown cwd="/tmp/project" text={"```text\nrecovered\n```"} isStreaming />, | |
| 139 | ); | |
| 140 | }); | |
| 141 | expect(mounted.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(1); | |
| 142 | expect(mounted.root.findByProps({ "data-language": "text" })).toBe(codeBlock); | |
| 143 | expect(codeBlock.props["data-wrap"]).toBe(String(!initialWrap)); | |
| 144 | } finally { | |
| 145 | await act(async () => renderer?.unmount()); | |
| 146 | vi.unstubAllGlobals(); | |
| 147 | vi.restoreAllMocks(); | |
| 148 | } | |
| 149 | }); | |
| 150 | ||
| 107 | 151 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { |
| 108 | 152 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 109 | 153 | const highlight = vi.spyOn(highlighter, "codeToHtml"); |
apps/web/src/components/ChatMarkdown.tsx+4-1| ... | ... | @@ -2873,7 +2873,10 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2873 | 2873 | fenceTitle={fenceTitle} |
| 2874 | 2874 | theme={resolvedTheme} |
| 2875 | 2875 | > |
| 2876 | <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}> | |
| 2876 | <RenderErrorBoundary | |
| 2877 | resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} | |
| 2878 | fallback={<pre {...props}>{children}</pre>} | |
| 2879 | > | |
| 2877 | 2880 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 2878 | 2881 | <SuspenseShikiCodeBlock |
| 2879 | 2882 | className={codeBlock.className} |
apps/web/src/components/DiffWorkerPoolProvider.tsx+78-30| ... | ... | @@ -1,7 +1,15 @@ |
| 1 | import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; | |
| 1 | import { WorkerPoolContext, useWorkerPool } from "@pierre/diffs/react"; | |
| 2 | import { WorkerPoolManager } from "@pierre/diffs/worker"; | |
| 2 | 3 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; |
| 3 | 4 | import * as Schema from "effect/Schema"; |
| 4 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 5 | import { | |
| 6 | useCallback, | |
| 7 | useEffect, | |
| 8 | useMemo, | |
| 9 | useState, | |
| 10 | useSyncExternalStore, | |
| 11 | type ReactNode, | |
| 12 | } from "react"; | |
| 5 | 13 | import { useTheme } from "../hooks/useTheme"; |
| 6 | 14 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 7 | 15 | import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; |
| ... | ... | @@ -16,6 +24,46 @@ export class DiffWorkerError extends Schema.TaggedErrorClass<DiffWorkerError>()( |
| 16 | 24 | } |
| 17 | 25 | } |
| 18 | 26 | |
| 27 | const DIFF_WORKER_IDLE_TTL_MS = 30_000; | |
| 28 | let sharedWorkerPool: | |
| 29 | | { | |
| 30 | readonly pool: WorkerPoolManager; | |
| 31 | consumers: number; | |
| 32 | idleTimer: ReturnType<typeof setTimeout> | undefined; | |
| 33 | } | |
| 34 | | undefined; | |
| 35 | ||
| 36 | /** Create workers after a viewer commits, then reuse them across short panel closures. */ | |
| 37 | function acquireDiffWorkerPool(themeName: DiffThemeName, poolSize: number) { | |
| 38 | const entry = (sharedWorkerPool ??= { | |
| 39 | pool: new WorkerPoolManager( | |
| 40 | { | |
| 41 | workerFactory: () => { | |
| 42 | try { | |
| 43 | return new DiffsWorker(); | |
| 44 | } catch (cause) { | |
| 45 | throw new DiffWorkerError({ operation: "create-worker", themeName, cause }); | |
| 46 | } | |
| 47 | }, | |
| 48 | poolSize, | |
| 49 | totalASTLRUCacheSize: 240, | |
| 50 | }, | |
| 51 | { | |
| 52 | theme: themeName, | |
| 53 | preferredHighlighter: PREFERRED_HIGHLIGHTER, | |
| 54 | tokenizeMaxLineLength: 1_000, | |
| 55 | useTokenTransformer: true, | |
| 56 | }, | |
| 57 | ), | |
| 58 | consumers: 0, | |
| 59 | idleTimer: undefined, | |
| 60 | }); | |
| 61 | clearTimeout(entry.idleTimer); | |
| 62 | entry.idleTimer = undefined; | |
| 63 | entry.consumers += 1; | |
| 64 | return entry; | |
| 65 | } | |
| 66 | ||
| 19 | 67 | function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { |
| 20 | 68 | const workerPool = useWorkerPool(); |
| 21 | 69 | |
| ... | ... | @@ -49,16 +97,17 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { |
| 49 | 97 | // Plain-text views do not queue a highlight task that could retry a blank first render. |
| 50 | 98 | function DiffWorkerReady({ children }: { children?: ReactNode }) { |
| 51 | 99 | const workerPool = useWorkerPool(); |
| 52 | const [ready, setReady] = useState( | |
| 53 | () => !workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool(), | |
| 54 | ); | |
| 100 | const [readyPool, setReadyPool] = useState<WorkerPoolManager>(); | |
| 101 | const ready = workerPool | |
| 102 | ? readyPool === workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool() | |
| 103 | : typeof window === "undefined"; | |
| 55 | 104 | |
| 56 | 105 | useEffect(() => { |
| 57 | 106 | if (ready || !workerPool) return; |
| 58 | 107 | |
| 59 | 108 | let mounted = true; |
| 60 | 109 | const finish = () => { |
| 61 | if (mounted) setReady(true); | |
| 110 | if (mounted) setReadyPool(workerPool); | |
| 62 | 111 | }; |
| 63 | 112 | // Failed pools use Pierre's existing main-thread highlighter. |
| 64 | 113 | void workerPool.initialize().then(finish, finish); |
| ... | ... | @@ -87,33 +136,32 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { |
| 87 | 136 | typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); |
| 88 | 137 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); |
| 89 | 138 | }, []); |
| 139 | const workerPool = useSyncExternalStore( | |
| 140 | useCallback( | |
| 141 | (onStoreChange) => { | |
| 142 | if (typeof window === "undefined") return () => {}; | |
| 143 | const entry = acquireDiffWorkerPool(diffThemeName, workerPoolSize); | |
| 144 | onStoreChange(); | |
| 145 | return () => { | |
| 146 | entry.consumers -= 1; | |
| 147 | if (entry.consumers !== 0) return; | |
| 148 | entry.idleTimer = setTimeout(() => { | |
| 149 | entry.idleTimer = undefined; | |
| 150 | entry.pool.terminate(); | |
| 151 | if (sharedWorkerPool === entry) sharedWorkerPool = undefined; | |
| 152 | }, DIFF_WORKER_IDLE_TTL_MS); | |
| 153 | }; | |
| 154 | }, | |
| 155 | [diffThemeName, workerPoolSize], | |
| 156 | ), | |
| 157 | () => sharedWorkerPool?.pool, | |
| 158 | () => undefined, | |
| 159 | ); | |
| 90 | 160 | |
| 91 | 161 | return ( |
| 92 | <WorkerPoolContextProvider | |
| 93 | poolOptions={{ | |
| 94 | workerFactory: () => { | |
| 95 | try { | |
| 96 | return new DiffsWorker(); | |
| 97 | } catch (cause) { | |
| 98 | throw new DiffWorkerError({ | |
| 99 | operation: "create-worker", | |
| 100 | themeName: diffThemeName, | |
| 101 | cause, | |
| 102 | }); | |
| 103 | } | |
| 104 | }, | |
| 105 | poolSize: workerPoolSize, | |
| 106 | totalASTLRUCacheSize: 240, | |
| 107 | }} | |
| 108 | highlighterOptions={{ | |
| 109 | theme: diffThemeName, | |
| 110 | preferredHighlighter: PREFERRED_HIGHLIGHTER, | |
| 111 | tokenizeMaxLineLength: 1_000, | |
| 112 | useTokenTransformer: true, | |
| 113 | }} | |
| 114 | > | |
| 162 | <WorkerPoolContext value={workerPool}> | |
| 115 | 163 | <DiffWorkerThemeSync themeName={diffThemeName} /> |
| 116 | 164 | <DiffWorkerReady>{children}</DiffWorkerReady> |
| 117 | </WorkerPoolContextProvider> | |
| 165 | </WorkerPoolContext> | |
| 118 | 166 | ); |
| 119 | 167 | } |
apps/web/src/components/RenderErrorBoundary.tsx+28-3| ... | ... | @@ -1,10 +1,35 @@ |
| 1 | 1 | import { Component, type ReactNode } from "react"; |
| 2 | 2 | |
| 3 | interface RenderErrorBoundaryProps { | |
| 4 | readonly children: ReactNode; | |
| 5 | readonly fallback: ReactNode; | |
| 6 | readonly resetKeys?: ReadonlyArray<unknown>; | |
| 7 | } | |
| 8 | ||
| 9 | interface RenderErrorBoundaryState { | |
| 10 | readonly failed: boolean; | |
| 11 | readonly resetKeys?: ReadonlyArray<unknown> | undefined; | |
| 12 | } | |
| 13 | ||
| 3 | 14 | export class RenderErrorBoundary extends Component< |
| 4 | { readonly children: ReactNode; readonly fallback: ReactNode }, | |
| 5 | { readonly failed: boolean } | |
| 15 | RenderErrorBoundaryProps, | |
| 16 | RenderErrorBoundaryState | |
| 6 | 17 | > { |
| 7 | override state = { failed: false }; | |
| 18 | override state = { failed: false, resetKeys: this.props.resetKeys }; | |
| 19 | ||
| 20 | // Retry changed inputs without remounting healthy children or their controls. | |
| 21 | static getDerivedStateFromProps( | |
| 22 | { resetKeys }: RenderErrorBoundaryProps, | |
| 23 | state: RenderErrorBoundaryState, | |
| 24 | ) { | |
| 25 | if ( | |
| 26 | resetKeys?.length !== state.resetKeys?.length || | |
| 27 | resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) | |
| 28 | ) { | |
| 29 | return { failed: false, resetKeys }; | |
| 30 | } | |
| 31 | return null; | |
| 32 | } | |
| 8 | 33 | |
| 9 | 34 | static getDerivedStateFromError() { |
| 10 | 35 | return { failed: true }; |
apps/web/src/components/chat/ComposerActivityStatus.tsx+3-2| ... | ... | @@ -1,12 +1,13 @@ |
| 1 | 1 | import { LoaderCircleIcon } from "lucide-react"; |
| 2 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | |
| 2 | 3 | import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; |
| 3 | 4 | import { ComposerBanner } from "./ComposerBanner"; |
| 4 | 5 | |
| 5 | 6 | export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { |
| 6 | 7 | return ( |
| 7 | 8 | <ComposerBanner.Row> |
| 8 | <ComposerBanner.Icon> | |
| 9 | <LoaderCircleIcon /> | |
| 9 | <ComposerBanner.Icon ref={observeVisibleAnimation}> | |
| 10 | <LoaderCircleIcon className="motion-safe:visible-animate-spin" /> | |
| 10 | 11 | </ComposerBanner.Icon> |
| 11 | 12 | <ComposerBanner.Content> |
| 12 | 13 | <span |
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+8-1| ... | ... | @@ -2,6 +2,7 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 2 | 2 | import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; |
| 3 | 3 | import { useId, useState } from "react"; |
| 4 | 4 | |
| 5 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | |
| 5 | 6 | import { serverUpdateStageLabel } from "../ServerUpdateAction"; |
| 6 | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | 8 | import { ComposerBanner } from "./ComposerBanner"; |
| ... | ... | @@ -12,7 +13,13 @@ export function ComposerServerUpdateIcon({ |
| 12 | 13 | readonly status: ServerUpdateState["status"]; |
| 13 | 14 | }) { |
| 14 | 15 | if (status === "running") { |
| 15 | return <LoaderCircleIcon aria-hidden />; | |
| 16 | return ( | |
| 17 | <LoaderCircleIcon | |
| 18 | aria-hidden | |
| 19 | ref={observeVisibleAnimation} | |
| 20 | className="motion-safe:visible-animate-spin" | |
| 21 | /> | |
| 22 | ); | |
| 16 | 23 | } |
| 17 | 24 | if (status === "failed") { |
| 18 | 25 | return <CircleAlertIcon aria-hidden className="text-error" />; |
apps/web/src/components/chat/MessagesTimeline.tsx+45-8| ... | ... | @@ -28,6 +28,7 @@ const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 28 | 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 29 | 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 30 | 30 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 31 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | |
| 31 | 32 | import { |
| 32 | 33 | createContext, |
| 33 | 34 | Fragment, |
| ... | ... | @@ -1652,12 +1653,21 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1652 | 1653 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> |
| 1653 | 1654 | <span |
| 1654 | 1655 | key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"} |
| 1656 | ref={isPreparingWorktree || isCompacting ? observeVisibleAnimation : undefined} | |
| 1655 | 1657 | className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none" |
| 1656 | 1658 | > |
| 1657 | 1659 | {isPreparingWorktree ? ( |
| 1658 | "Setting up worktree…" | |
| 1660 | <> | |
| 1661 | Setting up worktree… | |
| 1662 | <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay> | |
| 1663 | </> | |
| 1659 | 1664 | ) : isCompacting ? ( |
| 1660 | <CompactingLabel /> | |
| 1665 | <> | |
| 1666 | <CompactingLabel /> | |
| 1667 | <ActivityShimmerOverlay> | |
| 1668 | <CompactingLabel /> | |
| 1669 | </ActivityShimmerOverlay> | |
| 1670 | </> | |
| 1661 | 1671 | ) : row.createdAt ? ( |
| 1662 | 1672 | <> |
| 1663 | 1673 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| ... | ... | @@ -1677,7 +1687,7 @@ function ThinkingTimelineRow() { |
| 1677 | 1687 | return ( |
| 1678 | 1688 | <div className="min-h-7"> |
| 1679 | 1689 | {isPreparingWorktree || isCompacting ? null : ( |
| 1680 | <LiveActivityRow label="Thinking" iconName="brain" /> | |
| 1690 | <LiveActivityRow label="Thinking" iconName="brain" active shimmer /> | |
| 1681 | 1691 | )} |
| 1682 | 1692 | </div> |
| 1683 | 1693 | ); |
| ... | ... | @@ -1919,6 +1929,19 @@ function ExpandedWorkGroupEntries({ |
| 1919 | 1929 | |
| 1920 | 1930 | const workEntryKey = (entry: TimelineWorkEntry) => entry.id; |
| 1921 | 1931 | |
| 1932 | function ActivityShimmerOverlay({ children }: { children: ReactNode }) { | |
| 1933 | return ( | |
| 1934 | <span | |
| 1935 | aria-hidden | |
| 1936 | className="live-activity-focus pointer-events-none absolute inset-y-0 select-none" | |
| 1937 | > | |
| 1938 | <span className="live-activity-focus-counter block"> | |
| 1939 | <span className="live-activity-focus-aligned block text-foreground">{children}</span> | |
| 1940 | </span> | |
| 1941 | </span> | |
| 1942 | ); | |
| 1943 | } | |
| 1944 | ||
| 1922 | 1945 | const failedToolIconClassName = "text-tool-error-icon/40"; |
| 1923 | 1946 | |
| 1924 | 1947 | /** Image icons and the gradient computer-use mark cannot take a currentColor |
| ... | ... | @@ -1936,23 +1959,35 @@ function LiveActivityRow({ |
| 1936 | 1959 | toolIcon, |
| 1937 | 1960 | failed = false, |
| 1938 | 1961 | active = false, |
| 1962 | shimmer = false, | |
| 1939 | 1963 | }: { |
| 1940 | 1964 | label: string; |
| 1941 | 1965 | iconName?: WorkEntryIconName; |
| 1942 | 1966 | toolIcon?: ToolActivityIcon | undefined; |
| 1943 | 1967 | failed?: boolean; |
| 1944 | 1968 | active?: boolean; |
| 1969 | shimmer?: boolean; | |
| 1945 | 1970 | }) { |
| 1971 | const animated = active && !failed; | |
| 1972 | const showShimmer = animated && shimmer; | |
| 1946 | 1973 | return ( |
| 1947 | <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed"> | |
| 1974 | <div | |
| 1975 | ref={animated ? observeVisibleAnimation : undefined} | |
| 1976 | className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed" | |
| 1977 | > | |
| 1948 | 1978 | <LiveActivityContent |
| 1949 | 1979 | label={label} |
| 1950 | 1980 | iconName={iconName} |
| 1951 | 1981 | toolIcon={toolIcon} |
| 1952 | 1982 | failed={failed} |
| 1953 | 1983 | announceFailure={failed} |
| 1954 | active={active} | |
| 1984 | active={animated && !shimmer} | |
| 1955 | 1985 | /> |
| 1986 | {showShimmer ? ( | |
| 1987 | <ActivityShimmerOverlay> | |
| 1988 | <LiveActivityContent label={label} iconName={iconName} toolIcon={toolIcon} highlighted /> | |
| 1989 | </ActivityShimmerOverlay> | |
| 1990 | ) : null} | |
| 1956 | 1991 | </div> |
| 1957 | 1992 | ); |
| 1958 | 1993 | } |
| ... | ... | @@ -1964,6 +1999,7 @@ function LiveActivityContent({ |
| 1964 | 1999 | failed = false, |
| 1965 | 2000 | announceFailure = false, |
| 1966 | 2001 | active = false, |
| 2002 | highlighted = false, | |
| 1967 | 2003 | }: { |
| 1968 | 2004 | label: string; |
| 1969 | 2005 | iconName: WorkEntryIconName | undefined; |
| ... | ... | @@ -1971,6 +2007,7 @@ function LiveActivityContent({ |
| 1971 | 2007 | failed?: boolean; |
| 1972 | 2008 | announceFailure?: boolean; |
| 1973 | 2009 | active?: boolean; |
| 2010 | highlighted?: boolean; | |
| 1974 | 2011 | }) { |
| 1975 | 2012 | const showTrailingFailureMark = |
| 1976 | 2013 | failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); |
| ... | ... | @@ -1980,14 +2017,14 @@ function LiveActivityContent({ |
| 1980 | 2017 | className={cn( |
| 1981 | 2018 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", |
| 1982 | 2019 | iconName ? "px-0.5" : "px-1", |
| 1983 | "text-secondary-label", | |
| 2020 | highlighted ? "text-foreground" : "text-secondary-label", | |
| 1984 | 2021 | )} |
| 1985 | 2022 | > |
| 1986 | 2023 | {iconName ? ( |
| 1987 | 2024 | <span |
| 1988 | 2025 | className={cn( |
| 1989 | 2026 | "flex size-6 shrink-0 items-center justify-center", |
| 1990 | failed ? failedToolIconClassName : "text-icon-muted", | |
| 2027 | failed ? failedToolIconClassName : highlighted ? "text-foreground" : "text-icon-muted", | |
| 1991 | 2028 | )} |
| 1992 | 2029 | role={announceFailure ? "img" : undefined} |
| 1993 | 2030 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| ... | ... | @@ -1996,7 +2033,7 @@ function LiveActivityContent({ |
| 1996 | 2033 | icon={toolIcon} |
| 1997 | 2034 | fallbackName={iconName} |
| 1998 | 2035 | className="block size-4 shrink-0 stroke-[1.8]" |
| 1999 | muted | |
| 2036 | muted={!highlighted} | |
| 2000 | 2037 | /> |
| 2001 | 2038 | </span> |
| 2002 | 2039 | ) : null} |
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+39-12| ... | ... | @@ -9,6 +9,7 @@ import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react"; |
| 9 | 9 | import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; |
| 10 | 10 | import { |
| 11 | 11 | act, |
| 12 | StrictMode, | |
| 12 | 13 | useEffect, |
| 13 | 14 | useImperativeHandle, |
| 14 | 15 | useLayoutEffect, |
| ... | ... | @@ -149,6 +150,14 @@ function Views({ count }: { count: number }) { |
| 149 | 150 | ); |
| 150 | 151 | } |
| 151 | 152 | |
| 153 | function renderViews(count: number) { | |
| 154 | return ( | |
| 155 | <StrictMode> | |
| 156 | <Views count={count} /> | |
| 157 | </StrictMode> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 152 | 161 | describe("StyledDiffCodeView", () => { |
| 153 | 162 | beforeEach(() => { |
| 154 | 163 | testState.codeViewClassName = null; |
| ... | ... | @@ -208,6 +217,7 @@ describe("code-view worker lifecycle", () => { |
| 208 | 217 | vi.stubGlobal("window", {}); |
| 209 | 218 | vi.stubGlobal("navigator", { hardwareConcurrency: 2 }); |
| 210 | 219 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 220 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | |
| 211 | 221 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => |
| 212 | 222 | setImmediate(() => callback(0)), |
| 213 | 223 | ); |
| ... | ... | @@ -226,14 +236,16 @@ describe("code-view worker lifecycle", () => { |
| 226 | 236 | afterEach(async () => { |
| 227 | 237 | await act(async () => renderer?.unmount()); |
| 228 | 238 | renderer = undefined; |
| 239 | await vi.runOnlyPendingTimersAsync(); | |
| 229 | 240 | await Promise.all(testState.terminations); |
| 241 | vi.useRealTimers(); | |
| 230 | 242 | vi.restoreAllMocks(); |
| 231 | 243 | vi.unstubAllGlobals(); |
| 232 | 244 | }); |
| 233 | 245 | |
| 234 | it("starts on demand, shares one pool, disposes the last view, and reopens without resetting siblings", async () => { | |
| 246 | it("starts on demand, keeps quick reopens warm, and expires the last closed pool", async () => { | |
| 235 | 247 | await act(async () => { |
| 236 | renderer = create(<Views count={0} />); | |
| 248 | renderer = create(renderViews(0)); | |
| 237 | 249 | }); |
| 238 | 250 | const mounted = renderer!; |
| 239 | 251 | const button = mounted.root.findByType("button"); |
| ... | ... | @@ -241,7 +253,7 @@ describe("code-view worker lifecycle", () => { |
| 241 | 253 | expect(testState.pools.size).toBe(0); |
| 242 | 254 | await act(async () => { |
| 243 | 255 | (button.props as { onClick(): void }).onClick(); |
| 244 | mounted.update(<Views count={1} />); | |
| 256 | mounted.update(renderViews(1)); | |
| 245 | 257 | }); |
| 246 | 258 | const pool = [...testState.pools.keys()][0]!; |
| 247 | 259 | await act(async () => testState.pools.get(pool)); |
| ... | ... | @@ -252,25 +264,39 @@ describe("code-view worker lifecycle", () => { |
| 252 | 264 | ); |
| 253 | 265 | expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0); |
| 254 | 266 | |
| 255 | await act(async () => mounted.update(<Views count={2} />)); | |
| 267 | await act(async () => mounted.update(renderViews(2))); | |
| 256 | 268 | await act(async () => pool.primeFileHighlightCache(files[1]!)); |
| 257 | 269 | expect(testState.pools.size).toBe(1); |
| 258 | 270 | expect(testState.workers).toHaveLength(2); |
| 259 | expect(testState.renderPools).toEqual([pool, pool]); | |
| 271 | expect(new Set(testState.renderPools)).toEqual(new Set([pool])); | |
| 260 | 272 | expect(pool.getFileResultCache(files[1]!)).toBeDefined(); |
| 261 | 273 | expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain( |
| 262 | 274 | "answer", |
| 263 | 275 | ); |
| 264 | 276 | |
| 265 | await act(async () => mounted.update(<Views count={1} />)); | |
| 277 | await act(async () => mounted.update(renderViews(1))); | |
| 278 | expect(pool.getStats().totalWorkers).toBe(2); | |
| 279 | expect(testState.terminations).toHaveLength(0); | |
| 280 | await act(async () => mounted.update(renderViews(0))); | |
| 281 | await vi.advanceTimersByTimeAsync(29_999); | |
| 266 | 282 | expect(pool.getStats().totalWorkers).toBe(2); |
| 267 | 283 | expect(testState.terminations).toHaveLength(0); |
| 268 | await act(async () => mounted.update(<Views count={0} />)); | |
| 284 | ||
| 285 | await act(async () => mounted.update(renderViews(1))); | |
| 286 | expect(testState.pools.size).toBe(1); | |
| 287 | expect(testState.workers).toHaveLength(2); | |
| 288 | expect(mounted.root.findAllByProps({ role: "status" })).toHaveLength(0); | |
| 289 | expect(pool.getFileResultCache(files[1]!)).toBeDefined(); | |
| 290 | await vi.advanceTimersByTimeAsync(30_000); | |
| 291 | expect(pool.getStats().totalWorkers).toBe(2); | |
| 292 | ||
| 293 | await act(async () => mounted.update(renderViews(0))); | |
| 294 | await vi.advanceTimersByTimeAsync(30_000); | |
| 269 | 295 | await Promise.all(testState.terminations); |
| 270 | 296 | expect(pool.getStats().totalWorkers).toBe(0); |
| 271 | 297 | expect(testState.terminations).toHaveLength(2); |
| 272 | 298 | |
| 273 | await act(async () => mounted.update(<Views count={1} />)); | |
| 299 | await act(async () => mounted.update(renderViews(1))); | |
| 274 | 300 | const reopenedPool = [...testState.pools.keys()][1]!; |
| 275 | 301 | await act(async () => testState.pools.get(reopenedPool)); |
| 276 | 302 | expect(reopenedPool.isInitialized()).toBe(true); |
| ... | ... | @@ -285,7 +311,7 @@ describe("code-view worker lifecycle", () => { |
| 285 | 311 | it("renders through Pierre's main-thread fallback when worker creation fails", async () => { |
| 286 | 312 | testState.failWorkers = true; |
| 287 | 313 | await act(async () => { |
| 288 | renderer = create(<Views count={1} />); | |
| 314 | renderer = create(renderViews(1)); | |
| 289 | 315 | }); |
| 290 | 316 | const pool = [...testState.pools.keys()][0]!; |
| 291 | 317 | await act(async () => { |
| ... | ... | @@ -309,7 +335,7 @@ describe("code-view worker lifecycle", () => { |
| 309 | 335 | testState.onInitializationHeld = resolve; |
| 310 | 336 | }); |
| 311 | 337 | await act(async () => { |
| 312 | renderer = create(<Views count={2} />); | |
| 338 | renderer = create(renderViews(2)); | |
| 313 | 339 | }); |
| 314 | 340 | const pool = [...testState.pools.keys()][0]!; |
| 315 | 341 | const pending = testState.pools.get(pool)!; |
| ... | ... | @@ -319,10 +345,11 @@ describe("code-view worker lifecycle", () => { |
| 319 | 345 | expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2); |
| 320 | 346 | expect(testState.renderPools).toHaveLength(0); |
| 321 | 347 | |
| 322 | await act(async () => renderer!.update(<Views count={1} />)); | |
| 348 | await act(async () => renderer!.update(renderViews(1))); | |
| 323 | 349 | expect(pool.getStats().totalWorkers).toBe(2); |
| 324 | 350 | expect(testState.terminations).toHaveLength(0); |
| 325 | await act(async () => renderer!.update(<Views count={0} />)); | |
| 351 | await act(async () => renderer!.update(renderViews(0))); | |
| 352 | await vi.advanceTimersByTimeAsync(30_000); | |
| 326 | 353 | await pending; |
| 327 | 354 | await Promise.all(testState.terminations); |
| 328 | 355 | await act(async () => { |
apps/web/src/index.css+83| ... | ... | @@ -450,9 +450,92 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 450 | 450 | background-repeat: no-repeat; |
| 451 | 451 | background-clip: text; |
| 452 | 452 | animation: live-tool-shine 2.2s steps(30) infinite; |
| 453 | animation-play-state: var(--visible-animation-state, paused); | |
| 453 | 454 | } |
| 454 | 455 | } |
| 455 | 456 | |
| 457 | @utility visible-animate-spin { | |
| 458 | animation: var(--animate-spin); | |
| 459 | animation-play-state: var(--visible-animation-state, paused); | |
| 460 | will-change: var(--visible-animation-will-change, auto); | |
| 461 | } | |
| 462 | ||
| 463 | @keyframes live-activity-focus { | |
| 464 | from { | |
| 465 | transform: translateX(0); | |
| 466 | } | |
| 467 | to { | |
| 468 | transform: translateX(100%); | |
| 469 | } | |
| 470 | } | |
| 471 | ||
| 472 | @keyframes live-activity-focus-counter { | |
| 473 | from { | |
| 474 | transform: translateX(0); | |
| 475 | } | |
| 476 | to { | |
| 477 | transform: translateX(-100%); | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | @utility live-activity-focus { | |
| 482 | --live-activity-focus-width: 4.5rem; | |
| 483 | ||
| 484 | right: auto; | |
| 485 | left: calc(-1 * var(--live-activity-focus-width)); | |
| 486 | width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width)); | |
| 487 | -webkit-mask-image: linear-gradient( | |
| 488 | to right, | |
| 489 | transparent 0, | |
| 490 | rgb(0 0 0 / 12%) 0.675rem, | |
| 491 | rgb(0 0 0 / 55%) 1.575rem, | |
| 492 | black 2.25rem, | |
| 493 | rgb(0 0 0 / 55%) 2.925rem, | |
| 494 | rgb(0 0 0 / 12%) 3.825rem, | |
| 495 | transparent var(--live-activity-focus-width), | |
| 496 | transparent 100% | |
| 497 | ); | |
| 498 | -webkit-mask-repeat: no-repeat; | |
| 499 | mask-image: linear-gradient( | |
| 500 | to right, | |
| 501 | transparent 0, | |
| 502 | rgb(0 0 0 / 12%) 0.675rem, | |
| 503 | rgb(0 0 0 / 55%) 1.575rem, | |
| 504 | black 2.25rem, | |
| 505 | rgb(0 0 0 / 55%) 2.925rem, | |
| 506 | rgb(0 0 0 / 12%) 3.825rem, | |
| 507 | transparent var(--live-activity-focus-width), | |
| 508 | transparent 100% | |
| 509 | ); | |
| 510 | mask-repeat: no-repeat; | |
| 511 | animation: live-activity-focus 2.2s linear infinite; | |
| 512 | animation-play-state: var(--visible-animation-state, paused); | |
| 513 | will-change: var(--visible-animation-will-change, auto); | |
| 514 | ||
| 515 | @media (prefers-reduced-motion: reduce), (forced-colors: active) { | |
| 516 | animation: none; | |
| 517 | opacity: 0; | |
| 518 | will-change: auto; | |
| 519 | } | |
| 520 | } | |
| 521 | ||
| 522 | @utility live-activity-focus-counter { | |
| 523 | width: 100%; | |
| 524 | animation: live-activity-focus-counter 2.2s linear infinite; | |
| 525 | animation-play-state: var(--visible-animation-state, paused); | |
| 526 | will-change: var(--visible-animation-will-change, auto); | |
| 527 | ||
| 528 | @media (prefers-reduced-motion: reduce), (forced-colors: active) { | |
| 529 | animation: none; | |
| 530 | will-change: auto; | |
| 531 | } | |
| 532 | } | |
| 533 | ||
| 534 | @utility live-activity-focus-aligned { | |
| 535 | width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width)); | |
| 536 | margin-left: var(--live-activity-focus-width); | |
| 537 | } | |
| 538 | ||
| 456 | 539 | @property --assistant-citation-highlight-opacity { |
| 457 | 540 | syntax: "<number>"; |
| 458 | 541 | inherits: true; |
apps/web/src/lib/visibleAnimation.test.ts created+154| ... | ... | @@ -0,0 +1,154 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { observeVisibleAnimation } from "./visibleAnimation"; | |
| 4 | ||
| 5 | let page = Object.assign(new EventTarget(), { visibilityState: "visible" }); | |
| 6 | let motion = Object.assign(new EventTarget(), { matches: false }); | |
| 7 | let observers: TestIntersectionObserver[] = []; | |
| 8 | let cleanups: Array<() => void> = []; | |
| 9 | ||
| 10 | class TestIntersectionObserver { | |
| 11 | constructor(private readonly callback: IntersectionObserverCallback) { | |
| 12 | observers.push(this); | |
| 13 | } | |
| 14 | ||
| 15 | observe = vi.fn(); | |
| 16 | unobserve = vi.fn(); | |
| 17 | disconnect = vi.fn(); | |
| 18 | ||
| 19 | report(target: Element, isIntersecting: boolean) { | |
| 20 | this.callback( | |
| 21 | [{ target, isIntersecting } as IntersectionObserverEntry], | |
| 22 | this as unknown as IntersectionObserver, | |
| 23 | ); | |
| 24 | } | |
| 25 | } | |
| 26 | ||
| 27 | function animationElement() { | |
| 28 | const properties = new Map<string, string>(); | |
| 29 | const element = { | |
| 30 | style: { setProperty: (name: string, value: string) => properties.set(name, value) }, | |
| 31 | } as unknown as HTMLElement; | |
| 32 | return { | |
| 33 | element, | |
| 34 | state: () => properties.get("--visible-animation-state"), | |
| 35 | willChange: () => properties.get("--visible-animation-will-change"), | |
| 36 | }; | |
| 37 | } | |
| 38 | ||
| 39 | function attach(element: HTMLElement) { | |
| 40 | const cleanup = observeVisibleAnimation(element); | |
| 41 | if (cleanup) cleanups.push(cleanup); | |
| 42 | return cleanup; | |
| 43 | } | |
| 44 | ||
| 45 | beforeEach(() => { | |
| 46 | page = Object.assign(new EventTarget(), { visibilityState: "visible" }); | |
| 47 | motion = Object.assign(new EventTarget(), { matches: false }); | |
| 48 | observers = []; | |
| 49 | cleanups = []; | |
| 50 | vi.stubGlobal("document", page); | |
| 51 | vi.stubGlobal("window", { matchMedia: () => motion }); | |
| 52 | vi.stubGlobal("IntersectionObserver", TestIntersectionObserver); | |
| 53 | }); | |
| 54 | ||
| 55 | afterEach(() => { | |
| 56 | for (const cleanup of cleanups) cleanup(); | |
| 57 | vi.unstubAllGlobals(); | |
| 58 | vi.restoreAllMocks(); | |
| 59 | }); | |
| 60 | ||
| 61 | describe("observeVisibleAnimation", () => { | |
| 62 | it("runs only intersecting animations in a visible document with motion enabled", () => { | |
| 63 | const first = animationElement(); | |
| 64 | const second = animationElement(); | |
| 65 | attach(first.element); | |
| 66 | attach(second.element); | |
| 67 | expect(first.state()).toBe("paused"); | |
| 68 | ||
| 69 | const observer = observers[0]!; | |
| 70 | observer.report(first.element, true); | |
| 71 | expect(first.state()).toBe("running"); | |
| 72 | expect(second.state()).toBe("paused"); | |
| 73 | ||
| 74 | page.visibilityState = "hidden"; | |
| 75 | page.dispatchEvent(new Event("visibilitychange")); | |
| 76 | observer.report(second.element, true); | |
| 77 | expect(first.state()).toBe("paused"); | |
| 78 | expect(first.willChange()).toBe("auto"); | |
| 79 | expect(second.state()).toBe("paused"); | |
| 80 | ||
| 81 | page.visibilityState = "visible"; | |
| 82 | page.dispatchEvent(new Event("visibilitychange")); | |
| 83 | expect(first.state()).toBe("running"); | |
| 84 | expect(second.state()).toBe("running"); | |
| 85 | ||
| 86 | observer.report(first.element, false); | |
| 87 | motion.matches = true; | |
| 88 | motion.dispatchEvent(new Event("change")); | |
| 89 | expect(first.state()).toBe("paused"); | |
| 90 | expect(second.state()).toBe("paused"); | |
| 91 | ||
| 92 | motion.matches = false; | |
| 93 | motion.dispatchEvent(new Event("change")); | |
| 94 | expect(first.state()).toBe("paused"); | |
| 95 | expect(second.state()).toBe("running"); | |
| 96 | }); | |
| 97 | ||
| 98 | it.each(["hidden", "reduced motion"])("starts paused with %s already active", (condition) => { | |
| 99 | page.visibilityState = condition === "hidden" ? "hidden" : "visible"; | |
| 100 | motion.matches = condition === "reduced motion"; | |
| 101 | const animation = animationElement(); | |
| 102 | attach(animation.element); | |
| 103 | observers[0]!.report(animation.element, true); | |
| 104 | expect(animation.state()).toBe("paused"); | |
| 105 | ||
| 106 | page.visibilityState = "visible"; | |
| 107 | motion.matches = false; | |
| 108 | page.dispatchEvent(new Event("visibilitychange")); | |
| 109 | expect(animation.state()).toBe("running"); | |
| 110 | }); | |
| 111 | ||
| 112 | it("shares observers, releases the final ref, and ignores late callbacks after remount", () => { | |
| 113 | const addVisibility = vi.spyOn(page, "addEventListener"); | |
| 114 | const removeVisibility = vi.spyOn(page, "removeEventListener"); | |
| 115 | const addMotion = vi.spyOn(motion, "addEventListener"); | |
| 116 | const removeMotion = vi.spyOn(motion, "removeEventListener"); | |
| 117 | const first = animationElement(); | |
| 118 | const second = animationElement(); | |
| 119 | const detachFirst = attach(first.element); | |
| 120 | const detachSecond = attach(second.element); | |
| 121 | expect(observers).toHaveLength(1); | |
| 122 | expect(addVisibility).toHaveBeenCalledTimes(1); | |
| 123 | expect(addMotion).toHaveBeenCalledTimes(1); | |
| 124 | ||
| 125 | const previousObserver = observers[0]!; | |
| 126 | detachFirst?.(); | |
| 127 | previousObserver.report(first.element, true); | |
| 128 | expect(first.state()).toBe("paused"); | |
| 129 | expect(previousObserver.unobserve).toHaveBeenCalledWith(first.element); | |
| 130 | expect(previousObserver.disconnect).not.toHaveBeenCalled(); | |
| 131 | ||
| 132 | detachSecond?.(); | |
| 133 | expect(previousObserver.disconnect).toHaveBeenCalledTimes(1); | |
| 134 | expect(removeVisibility).toHaveBeenCalledTimes(1); | |
| 135 | expect(removeMotion).toHaveBeenCalledTimes(1); | |
| 136 | ||
| 137 | attach(second.element); | |
| 138 | expect(observers).toHaveLength(2); | |
| 139 | detachSecond?.(); | |
| 140 | previousObserver.report(second.element, true); | |
| 141 | expect(second.state()).toBe("paused"); | |
| 142 | observers[1]!.report(second.element, true); | |
| 143 | expect(second.state()).toBe("running"); | |
| 144 | }); | |
| 145 | ||
| 146 | it("keeps unknown visibility static when IntersectionObserver is unavailable", () => { | |
| 147 | vi.stubGlobal("IntersectionObserver", undefined); | |
| 148 | const animation = animationElement(); | |
| 149 | attach(animation.element); | |
| 150 | expect(animation.state()).toBe("paused"); | |
| 151 | expect(animation.willChange()).toBe("auto"); | |
| 152 | expect(observers).toHaveLength(0); | |
| 153 | }); | |
| 154 | }); |
apps/web/src/lib/visibleAnimation.ts created+64| ... | ... | @@ -0,0 +1,64 @@ |
| 1 | interface ObservedAnimation { | |
| 2 | element: HTMLElement | SVGElement; | |
| 3 | intersecting: boolean; | |
| 4 | } | |
| 5 | ||
| 6 | const animations = new Map<Element, ObservedAnimation>(); | |
| 7 | let observer: IntersectionObserver | null = null; | |
| 8 | let reducedMotion: MediaQueryList | null = null; | |
| 9 | ||
| 10 | function updateAnimation(animation: ObservedAnimation) { | |
| 11 | const running = | |
| 12 | animation.intersecting && document.visibilityState === "visible" && !reducedMotion?.matches; | |
| 13 | animation.element.style.setProperty("--visible-animation-state", running ? "running" : "paused"); | |
| 14 | animation.element.style.setProperty( | |
| 15 | "--visible-animation-will-change", | |
| 16 | running ? "transform" : "auto", | |
| 17 | ); | |
| 18 | } | |
| 19 | ||
| 20 | function updateAnimations() { | |
| 21 | for (const animation of animations.values()) updateAnimation(animation); | |
| 22 | } | |
| 23 | ||
| 24 | /** Attach to a stable animation container. All refs share visibility and motion listeners. */ | |
| 25 | export function observeVisibleAnimation(element: HTMLElement | SVGElement | null) { | |
| 26 | if (element === null) return; | |
| 27 | element.style.setProperty("--visible-animation-state", "paused"); | |
| 28 | element.style.setProperty("--visible-animation-will-change", "auto"); | |
| 29 | if (typeof IntersectionObserver === "undefined") return; | |
| 30 | ||
| 31 | if (observer === null) { | |
| 32 | reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); | |
| 33 | reducedMotion.addEventListener("change", updateAnimations); | |
| 34 | document.addEventListener("visibilitychange", updateAnimations); | |
| 35 | observer = new IntersectionObserver((entries, source) => { | |
| 36 | if (source !== observer) return; | |
| 37 | for (const entry of entries) { | |
| 38 | const animation = animations.get(entry.target); | |
| 39 | if (!animation) continue; | |
| 40 | animation.intersecting = entry.isIntersecting; | |
| 41 | updateAnimation(animation); | |
| 42 | } | |
| 43 | }); | |
| 44 | } | |
| 45 | ||
| 46 | const animation = { element, intersecting: false }; | |
| 47 | animations.set(element, animation); | |
| 48 | observer.observe(element); | |
| 49 | ||
| 50 | return () => { | |
| 51 | if (animations.get(element) !== animation) return; | |
| 52 | animations.delete(element); | |
| 53 | observer?.unobserve(element); | |
| 54 | element.style.setProperty("--visible-animation-state", "paused"); | |
| 55 | element.style.setProperty("--visible-animation-will-change", "auto"); | |
| 56 | if (animations.size === 0) { | |
| 57 | observer?.disconnect(); | |
| 58 | observer = null; | |
| 59 | reducedMotion?.removeEventListener("change", updateAnimations); | |
| 60 | reducedMotion = null; | |
| 61 | document.removeEventListener("visibilitychange", updateAnimations); | |
| 62 | } | |
| 63 | }; | |
| 64 | } |