| 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,7 +52,9 @@ jobs: |
| 52 | run: vpr typecheck | 52 | run: vpr typecheck |
| 53 | 53 | ||
| 54 | - name: Install browser secret helper build libraries | 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 | - name: Build desktop pipeline | 59 | - name: Build desktop pipeline |
| 58 | run: vp run build:desktop | 60 | run: vp run build:desktop |
| ... | @@ -89,7 +91,9 @@ jobs: | ... | @@ -89,7 +91,9 @@ jobs: |
| 89 | run: vp run --filter @t3tools/desktop ensure:electron | 91 | run: vp run --filter @t3tools/desktop ensure:electron |
| 90 | 92 | ||
| 91 | - name: Install browser secret helper build libraries | 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 | - name: Test | 98 | - name: Test |
| 95 | run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test | 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,6 +355,7 @@ const screenshot = await getImage({ |
| 355 | 355 | ||
| 356 | <script> | 356 | <script> |
| 357 | import { fetchLatestRelease, RELEASES_URL, type ReleaseAsset } from "../lib/releases"; | 357 | import { fetchLatestRelease, RELEASES_URL, type ReleaseAsset } from "../lib/releases"; |
| 358 | import { startHomeMotion } from "../lib/homeMotion"; | ||
| 358 | 359 | ||
| 359 | type Platform = { os: "mac" | "win" | "linux"; label: string }; | 360 | type Platform = { os: "mac" | "win" | "linux"; label: string }; |
| 360 | 361 | ||
| ... | @@ -419,6 +420,14 @@ const screenshot = await getImage({ | ... | @@ -419,6 +420,14 @@ const screenshot = await getImage({ |
| 419 | } | 420 | } |
| 420 | 421 | ||
| 421 | init(); | 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 | </script> | 431 | </script> |
| 423 | 432 | ||
| 424 | <style> | 433 | <style> |
| ... | @@ -590,12 +599,14 @@ const screenshot = await getImage({ | ... | @@ -590,12 +599,14 @@ const screenshot = await getImage({ |
| 590 | :global([data-platform="linux"]) .dl-icon--linux { display: block; } | 599 | :global([data-platform="linux"]) .dl-icon--linux { display: block; } |
| 591 | :global(html:not([data-platform])) .dl-icon--apple { display: block; } | 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 | .hero-float { | 603 | .hero-float { |
| 595 | position: absolute; | 604 | position: absolute; |
| 596 | inset: 0; | 605 | inset: 0; |
| 597 | pointer-events: none; | 606 | pointer-events: none; |
| 598 | z-index: 1; | 607 | z-index: 1; |
| 608 | --px: 0px; | ||
| 609 | --py: 0px; | ||
| 599 | } | 610 | } |
| 600 | 611 | ||
| 601 | .hero-float-mark { | 612 | .hero-float-mark { |
| ... | @@ -605,6 +616,8 @@ const screenshot = await getImage({ | ... | @@ -605,6 +616,8 @@ const screenshot = await getImage({ |
| 605 | width: var(--s); | 616 | width: var(--s); |
| 606 | height: var(--s); | 617 | height: var(--s); |
| 607 | margin-left: calc(var(--s) / -2); | 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 | .hero-float-card { | 623 | .hero-float-card { |
| ... | @@ -618,7 +631,10 @@ const screenshot = await getImage({ | ... | @@ -618,7 +631,10 @@ const screenshot = await getImage({ |
| 618 | box-shadow: | 631 | box-shadow: |
| 619 | 0 20px 48px -16px rgba(0, 0, 0, 0.65), | 632 | 0 20px 48px -16px rgba(0, 0, 0, 0.65), |
| 620 | 0 2px 0 0 rgba(255, 255, 255, 0.04) inset; | 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 | .hero-float-card img { | 640 | .hero-float-card img { |
| ... | @@ -637,16 +653,21 @@ const screenshot = await getImage({ | ... | @@ -637,16 +653,21 @@ const screenshot = await getImage({ |
| 637 | from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } | 653 | from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); } |
| 638 | to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); } | 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; } | 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; } |
| 642 | .hf-antigravity { --x: min(300px, 19vw); --y: 20px; --s: 64px; --rot: -5deg; --d: 580ms; --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; } |
| 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); } | 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); } |
| 644 | .hf-codex { --x: min(560px, 38vw); --y: 128px; --s: 96px; --rot: 7deg; --d: 460ms; --fx: 160px; --fy: -40px; } | 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; } |
| 645 | .hf-grok { --x: max(-440px, -30vw); --y: 440px; --s: 80px; --rot: 4deg; --d: 540ms; --fx: -120px; --fy: 80px; } | 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; } |
| 646 | .hf-cursor { --x: min(440px, 30vw); --y: 440px; --s: 80px; --rot: -5deg; --d: 700ms; --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 | @media (prefers-reduced-motion: reduce) { | 668 | @media (prefers-reduced-motion: reduce) { |
| 649 | .hero-float-card { animation: none; transform: rotate(var(--rot)); } | 669 | .hero-float-card { animation: none; transform: rotate(var(--rot)); } |
| 670 | .hero-float-mark { translate: none; transition: none; } | ||
| 650 | } | 671 | } |
| 651 | 672 | ||
| 652 | @media (max-width: 1180px) { | 673 | @media (max-width: 1180px) { |
| ... | @@ -988,6 +1009,16 @@ const screenshot = await getImage({ | ... | @@ -988,6 +1009,16 @@ const screenshot = await getImage({ |
| 988 | width: 7px; height: 14px; | 1009 | width: 7px; height: 14px; |
| 989 | background: var(--fg); | 1010 | background: var(--fg); |
| 990 | margin-left: 4px; | 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 | .open-pitch { | 1024 | .open-pitch { |
apps/mobile/src/features/threads/use-project-actions.ts+1-6| ... | @@ -156,13 +156,8 @@ export function useCreateProjectThread() { | ... | @@ -156,13 +156,8 @@ export function useCreateProjectThread() { |
| 156 | ); | 156 | ); |
| 157 | return AsyncResult.failure(result.cause); | 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 | setPendingConnectionError(null); | 159 | setPendingConnectionError(null); |
| 165 | scheduleUnusedComposerAttachmentCleanup(input.initialAttachments); | 160 | scheduleUnusedComposerAttachmentCleanup(prepared.draftAttachments); |
| 166 | 161 | ||
| 167 | return mapAtomCommandResult(result, () => | 162 | return mapAtomCommandResult(result, () => |
| 168 | scopeThreadRef(input.project.environmentId, threadId), | 163 | scopeThreadRef(input.project.environmentId, threadId), |
apps/mobile/src/lib/attachmentUpload.ts+2-5| ... | @@ -33,8 +33,8 @@ import { uuidv4 } from "./uuid"; | ... | @@ -33,8 +33,8 @@ import { uuidv4 } from "./uuid"; |
| 33 | * This module owns the server side of a composer attachment's lifecycle. | 33 | * This module owns the server side of a composer attachment's lifecycle. |
| 34 | * `prepareTurnAttachments` acquires pending uploads (verifying and reusing | 34 | * `prepareTurnAttachments` acquires pending uploads (verifying and reusing |
| 35 | * persisted ones), hands the uploaded ids back to the attachment's durable | 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 | 36 | * owner (queued outbox message or composer draft), and leaves their cleanup |
| 37 | * handle for after the turn consumed the bytes. Nothing outside this module | 37 | * to that owner after it checks shared references. Nothing outside this module |
| 38 | * mints or deletes pending uploads. The local-file side of the lifecycle is | 38 | * mints or deletes pending uploads. The local-file side of the lifecycle is |
| 39 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which | 39 | * owned by `removeThreadOutboxMessage` / the composer draft mutators, which |
| 40 | * release files through `releaseUnusedComposerAttachmentFiles`. | 40 | * release files through `releaseUnusedComposerAttachmentFiles`. |
| ... | @@ -149,8 +149,6 @@ export interface PreparedTurnAttachments { | ... | @@ -149,8 +149,6 @@ export interface PreparedTurnAttachments { |
| 149 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; | 149 | readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>; |
| 150 | /** Every pending upload backing this turn (reused and newly minted). */ | 150 | /** Every pending upload backing this turn (reused and newly minted). */ |
| 151 | readonly pendingAttachmentIds: ReadonlyArray<string>; | 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 | export type PrepareTurnAttachmentsResult = | 154 | export type PrepareTurnAttachmentsResult = |
| ... | @@ -306,7 +304,6 @@ export async function prepareTurnAttachments(input: { | ... | @@ -306,7 +304,6 @@ export async function prepareTurnAttachments(input: { |
| 306 | attachments, | 304 | attachments, |
| 307 | draftAttachments, | 305 | draftAttachments, |
| 308 | pendingAttachmentIds, | 306 | pendingAttachmentIds, |
| 309 | releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds), | ||
| 310 | }); | 307 | }); |
| 311 | 308 | ||
| 312 | if (input.attachments.length === 0 || (files.length === 0 && !input.supportsImageUploads)) { | 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,7 +19,7 @@ vi.mock("./thread-outbox", async () => { |
| 19 | harness.manager = createThreadOutboxManager({ | 19 | harness.manager = createThreadOutboxManager({ |
| 20 | registry: appAtomRegistry, | 20 | registry: appAtomRegistry, |
| 21 | storage: { | 21 | storage: { |
| 22 | load: async () => [], | 22 | load: async () => ({ messages: [], errors: [] }), |
| 23 | write: async (message) => { | 23 | write: async (message) => { |
| 24 | const pending = harness.writeGates.shift(); | 24 | const pending = harness.writeGates.shift(); |
| 25 | if (pending) { | 25 | if (pending) { |
apps/mobile/src/state/thread-outbox-manager.ts+31-13| ... | @@ -72,16 +72,26 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -72,16 +72,26 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 72 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); | 72 | options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages)); |
| 73 | }; | 73 | }; |
| 74 | 74 | ||
| 75 | // Resolves true when hydration completed; false when the read failed (the | 75 | // Readable messages can be used after a partial load. Only a complete load |
| 76 | // next call retries). Destructive callers (the attachment sweep) must not | 76 | // returns true, so cleanup cannot delete files owned by unreadable records. |
| 77 | // treat a failed hydration as an empty queue. | 77 | // A later call retries failed reads without replacing live message objects. |
| 78 | const load = (): Promise<boolean> => { | 78 | const load = (): Promise<boolean> => { |
| 79 | if (loadPromise !== null) { | 79 | if (loadPromise !== null) { |
| 80 | return loadPromise; | 80 | return loadPromise; |
| 81 | } | 81 | } |
| 82 | loadPromise = serialize(async () => { | 82 | loadPromise = serialize(async () => { |
| 83 | const persistedMessages = await options.storage.load(); | 83 | const result = await options.storage.load(); |
| 84 | setMessages([...persistedMessages, ...currentMessages()]); | 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 | return true; | 95 | return true; |
| 86 | }).catch((cause) => { | 96 | }).catch((cause) => { |
| 87 | loadPromise = null; | 97 | loadPromise = null; |
| ... | @@ -275,15 +285,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { | ... | @@ -275,15 +285,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) { |
| 275 | // changes after this request must not enter the clear set. | 285 | // changes after this request must not enter the clear set. |
| 276 | const revisionsAtRequest = new Map(revisions); | 286 | const revisionsAtRequest = new Map(revisions); |
| 277 | return serialize(async () => { | 287 | return serialize(async () => { |
| 278 | const persisted = await options.storage.load().catch((cause) => { | 288 | const persisted = await options.storage |
| 279 | throw new ThreadOutboxManagerError({ | 289 | .load() |
| 280 | operation: "clear-environment-load", | 290 | .then((result) => { |
| 281 | environmentId, | 291 | if (result.errors.length > 0) { |
| 282 | threadId: null, | 292 | throw new AggregateError(result.errors, "Some queued messages could not be read."); |
| 283 | messageId: null, | 293 | } |
| 284 | cause, | 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 | const allMessages = flattenQueuedThreadMessages( | 305 | const allMessages = flattenQueuedThreadMessages( |
| 288 | groupQueuedThreadMessages([...persisted, ...currentMessages()]), | 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,7 +19,7 @@ vi.mock("./thread-outbox", async () => { |
| 19 | harness.manager = createThreadOutboxManager({ | 19 | harness.manager = createThreadOutboxManager({ |
| 20 | registry: appAtomRegistry, | 20 | registry: appAtomRegistry, |
| 21 | storage: { | 21 | storage: { |
| 22 | load: async () => [], | 22 | load: async () => ({ messages: [], errors: [] }), |
| 23 | write: async () => undefined, | 23 | write: async () => undefined, |
| 24 | remove: async () => undefined, | 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,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 | export interface ThreadOutboxStorage { | 52 | export interface ThreadOutboxStorage { |
| 48 | readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>; | 53 | readonly load: () => Promise<ThreadOutboxLoadResult>; |
| 49 | readonly write: (message: QueuedThreadMessage) => Promise<void>; | 54 | readonly write: (message: QueuedThreadMessage) => Promise<void>; |
| 50 | readonly remove: (message: QueuedThreadMessage) => Promise<void>; | 55 | readonly remove: (message: QueuedThreadMessage) => Promise<void>; |
| 51 | } | 56 | } |
| ... | @@ -69,6 +74,7 @@ async function getMessageFile(messageId: MessageId) { | ... | @@ -69,6 +74,7 @@ async function getMessageFile(messageId: MessageId) { |
| 69 | export const expoThreadOutboxStorage: ThreadOutboxStorage = { | 74 | export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 70 | load: async () => { | 75 | load: async () => { |
| 71 | const messages: QueuedThreadMessage[] = []; | 76 | const messages: QueuedThreadMessage[] = []; |
| 77 | const errors: ThreadOutboxStorageError[] = []; | ||
| 72 | try { | 78 | try { |
| 73 | const { File } = await import("expo-file-system"); | 79 | const { File } = await import("expo-file-system"); |
| 74 | const directory = await getOutboxDirectory(); | 80 | const directory = await getOutboxDirectory(); |
| ... | @@ -80,20 +86,21 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { | ... | @@ -80,20 +86,21 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 80 | try { | 86 | try { |
| 81 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); | 87 | messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown)); |
| 82 | } catch (cause) { | 88 | } catch (cause) { |
| 83 | // A partial queue hides attachment owners from cleanup. Keep all | 89 | // Recover readable messages without treating their attachment |
| 84 | // records untouched until every persisted message can be read. | 90 | // owners as the complete inventory needed for cleanup. |
| 85 | throw new ThreadOutboxStorageError({ | 91 | errors.push( |
| 86 | operation: "read-message", | 92 | new ThreadOutboxStorageError({ |
| 87 | environmentId: null, | 93 | operation: "read-message", |
| 88 | threadId: null, | 94 | environmentId: null, |
| 89 | messageId: null, | 95 | threadId: null, |
| 90 | fileName: entry.name, | 96 | messageId: null, |
| 91 | cause, | 97 | fileName: entry.name, |
| 92 | }); | 98 | cause, |
| 99 | }), | ||
| 100 | ); | ||
| 93 | } | 101 | } |
| 94 | } | 102 | } |
| 95 | } catch (cause) { | 103 | } catch (cause) { |
| 96 | if (cause instanceof ThreadOutboxStorageError) throw cause; | ||
| 97 | throw new ThreadOutboxStorageError({ | 104 | throw new ThreadOutboxStorageError({ |
| 98 | operation: "load", | 105 | operation: "load", |
| 99 | environmentId: null, | 106 | environmentId: null, |
| ... | @@ -103,7 +110,7 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { | ... | @@ -103,7 +110,7 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = { |
| 103 | cause, | 110 | cause, |
| 104 | }); | 111 | }); |
| 105 | } | 112 | } |
| 106 | return messages; | 113 | return { messages, errors }; |
| 107 | }, | 114 | }, |
| 108 | write: async (message) => { | 115 | write: async (message) => { |
| 109 | const fileName = messageFileName(message.messageId); | 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,8 +13,44 @@ import { onTestFinished, vi } from "vite-plus/test"; |
| 13 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); | 13 | const outboxFiles = vi.hoisted(() => new Map<string, string | Error>()); |
| 14 | 14 | ||
| 15 | vi.mock("expo-file-system", () => { | 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 | class File { | 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 | async text(): Promise<string> { | 55 | async text(): Promise<string> { |
| 20 | const contents = outboxFiles.get(this.name); | 56 | const contents = outboxFiles.get(this.name); |
| ... | @@ -26,13 +62,7 @@ vi.mock("expo-file-system", () => { | ... | @@ -26,13 +62,7 @@ vi.mock("expo-file-system", () => { |
| 26 | 62 | ||
| 27 | return { | 63 | return { |
| 28 | File, | 64 | File, |
| 29 | Directory: class { | 65 | Directory, |
| 30 | create() {} | ||
| 31 | |||
| 32 | list() { | ||
| 33 | return Array.from(outboxFiles.keys(), (name) => new File(name)); | ||
| 34 | } | ||
| 35 | }, | ||
| 36 | Paths: { document: "/documents" }, | 66 | Paths: { document: "/documents" }, |
| 37 | }; | 67 | }; |
| 38 | }); | 68 | }); |
| ... | @@ -52,7 +82,12 @@ import { | ... | @@ -52,7 +82,12 @@ import { |
| 52 | type QueuedThreadMessage, | 82 | type QueuedThreadMessage, |
| 53 | } from "./thread-outbox-model"; | 83 | } from "./thread-outbox-model"; |
| 54 | import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager"; | 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 | function queuedMessage(input: { | 92 | function queuedMessage(input: { |
| 58 | readonly environmentId?: string; | 93 | readonly environmentId?: string; |
| ... | @@ -73,7 +108,7 @@ function queuedMessage(input: { | ... | @@ -73,7 +108,7 @@ function queuedMessage(input: { |
| 73 | 108 | ||
| 74 | describe("thread outbox", () => { | 109 | describe("thread outbox", () => { |
| 75 | it.each(["read", "json", "schema"] as const)( | 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 | async (failure) => { | 112 | async (failure) => { |
| 78 | onTestFinished(() => outboxFiles.clear()); | 113 | onTestFinished(() => outboxFiles.clear()); |
| 79 | const first = queuedMessage({ | 114 | const first = queuedMessage({ |
| ... | @@ -81,10 +116,11 @@ describe("thread outbox", () => { | ... | @@ -81,10 +116,11 @@ describe("thread outbox", () => { |
| 81 | createdAt: "2026-06-08T10:00:01.000Z", | 116 | createdAt: "2026-06-08T10:00:01.000Z", |
| 82 | }); | 117 | }); |
| 83 | const second = queuedMessage({ | 118 | const second = queuedMessage({ |
| 119 | environmentId: "environment-2", | ||
| 84 | messageId: "message-2", | 120 | messageId: "message-2", |
| 85 | createdAt: "2026-06-08T10:00:02.000Z", | 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 | outboxFiles.set( | 124 | outboxFiles.set( |
| 89 | "message-2.json", | 125 | "message-2.json", |
| 90 | failure === "read" | 126 | failure === "read" |
| ... | @@ -93,14 +129,52 @@ describe("thread outbox", () => { | ... | @@ -93,14 +129,52 @@ describe("thread outbox", () => { |
| 93 | ? "{" | 129 | ? "{" |
| 94 | : JSON.stringify({ ...second, schemaVersion: 999 }), | 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({ | 140 | const registry = AtomRegistry.make(); |
| 98 | operation: "read-message", | 141 | onTestFinished(() => registry.dispose()); |
| 99 | fileName: "message-2.json", | 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 | outboxFiles.set("message-2.json", JSON.stringify(encodeQueuedThreadMessage(second))); | 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,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 | it("groups messages by scoped thread and preserves creation order", () => { | 268 | it("groups messages by scoped thread and preserves creation order", () => { |
| 142 | const later = queuedMessage({ | 269 | const later = queuedMessage({ |
| 143 | messageId: "message-2", | 270 | messageId: "message-2", |
| ... | @@ -335,7 +462,7 @@ describe("thread outbox", () => { | ... | @@ -335,7 +462,7 @@ describe("thread outbox", () => { |
| 335 | const manager = createThreadOutboxManager({ | 462 | const manager = createThreadOutboxManager({ |
| 336 | registry, | 463 | registry, |
| 337 | storage: { | 464 | storage: { |
| 338 | load: async () => [], | 465 | load: async () => ({ messages: [], errors: [] }), |
| 339 | write: async () => undefined, | 466 | write: async () => undefined, |
| 340 | remove: async () => undefined, | 467 | remove: async () => undefined, |
| 341 | }, | 468 | }, |
| ... | @@ -382,7 +509,7 @@ describe("thread outbox", () => { | ... | @@ -382,7 +509,7 @@ describe("thread outbox", () => { |
| 382 | if (loadCalls === 1) { | 509 | if (loadCalls === 1) { |
| 383 | await initialLoadBlocked; | 510 | await initialLoadBlocked; |
| 384 | } | 511 | } |
| 385 | return [...stored.values()]; | 512 | return { messages: [...stored.values()], errors: [] }; |
| 386 | }, | 513 | }, |
| 387 | write: async () => undefined, | 514 | write: async () => undefined, |
| 388 | remove: async (candidate) => { | 515 | remove: async (candidate) => { |
| ... | @@ -418,7 +545,7 @@ describe("thread outbox", () => { | ... | @@ -418,7 +545,7 @@ describe("thread outbox", () => { |
| 418 | load: async () => { | 545 | load: async () => { |
| 419 | loadCalls += 1; | 546 | loadCalls += 1; |
| 420 | if (loadCalls === 1) throw loadCause; | 547 | if (loadCalls === 1) throw loadCause; |
| 421 | return []; | 548 | return { messages: [], errors: [] }; |
| 422 | }, | 549 | }, |
| 423 | write: async () => undefined, | 550 | write: async () => undefined, |
| 424 | remove: async () => undefined, | 551 | remove: async () => undefined, |
| ... | @@ -451,7 +578,7 @@ describe("thread outbox", () => { | ... | @@ -451,7 +578,7 @@ describe("thread outbox", () => { |
| 451 | const removalCause = new Error("remove failed"); | 578 | const removalCause = new Error("remove failed"); |
| 452 | let failRemoval = true; | 579 | let failRemoval = true; |
| 453 | const storage: ThreadOutboxStorage = { | 580 | const storage: ThreadOutboxStorage = { |
| 454 | load: async () => [...stored.values()], | 581 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 455 | write: async (message) => { | 582 | write: async (message) => { |
| 456 | stored.set(message.messageId, message); | 583 | stored.set(message.messageId, message); |
| 457 | }, | 584 | }, |
| ... | @@ -501,7 +628,7 @@ describe("thread outbox", () => { | ... | @@ -501,7 +628,7 @@ describe("thread outbox", () => { |
| 501 | const manager = createThreadOutboxManager({ | 628 | const manager = createThreadOutboxManager({ |
| 502 | registry, | 629 | registry, |
| 503 | storage: { | 630 | storage: { |
| 504 | load: async () => [], | 631 | load: async () => ({ messages: [], errors: [] }), |
| 505 | write: async () => writeBlocked, | 632 | write: async () => writeBlocked, |
| 506 | remove: async () => undefined, | 633 | remove: async () => undefined, |
| 507 | }, | 634 | }, |
| ... | @@ -530,7 +657,7 @@ describe("thread outbox", () => { | ... | @@ -530,7 +657,7 @@ describe("thread outbox", () => { |
| 530 | const manager = createThreadOutboxManager({ | 657 | const manager = createThreadOutboxManager({ |
| 531 | registry, | 658 | registry, |
| 532 | storage: { | 659 | storage: { |
| 533 | load: async () => [], | 660 | load: async () => ({ messages: [], errors: [] }), |
| 534 | write: async () => { | 661 | write: async () => { |
| 535 | throw writeCause; | 662 | throw writeCause; |
| 536 | }, | 663 | }, |
| ... | @@ -561,7 +688,7 @@ describe("thread outbox", () => { | ... | @@ -561,7 +688,7 @@ describe("thread outbox", () => { |
| 561 | const manager = createThreadOutboxManager({ | 688 | const manager = createThreadOutboxManager({ |
| 562 | registry, | 689 | registry, |
| 563 | storage: { | 690 | storage: { |
| 564 | load: async () => [], | 691 | load: async () => ({ messages: [], errors: [] }), |
| 565 | write: async () => { | 692 | write: async () => { |
| 566 | throw new Error("disk full"); | 693 | throw new Error("disk full"); |
| 567 | }, | 694 | }, |
| ... | @@ -593,7 +720,7 @@ describe("thread outbox", () => { | ... | @@ -593,7 +720,7 @@ describe("thread outbox", () => { |
| 593 | const manager = createThreadOutboxManager({ | 720 | const manager = createThreadOutboxManager({ |
| 594 | registry, | 721 | registry, |
| 595 | storage: { | 722 | storage: { |
| 596 | load: async () => [], | 723 | load: async () => ({ messages: [], errors: [] }), |
| 597 | write: async () => { | 724 | write: async () => { |
| 598 | if (failNextWrite) { | 725 | if (failNextWrite) { |
| 599 | failNextWrite = false; | 726 | failNextWrite = false; |
| ... | @@ -630,7 +757,7 @@ describe("thread outbox", () => { | ... | @@ -630,7 +757,7 @@ describe("thread outbox", () => { |
| 630 | const manager = createThreadOutboxManager({ | 757 | const manager = createThreadOutboxManager({ |
| 631 | registry, | 758 | registry, |
| 632 | storage: { | 759 | storage: { |
| 633 | load: async () => [], | 760 | load: async () => ({ messages: [], errors: [] }), |
| 634 | write: async () => undefined, | 761 | write: async () => undefined, |
| 635 | remove: async () => undefined, | 762 | remove: async () => undefined, |
| 636 | }, | 763 | }, |
| ... | @@ -654,7 +781,7 @@ describe("thread outbox", () => { | ... | @@ -654,7 +781,7 @@ describe("thread outbox", () => { |
| 654 | const registry = AtomRegistry.make(); | 781 | const registry = AtomRegistry.make(); |
| 655 | const stored = new Map<MessageId, QueuedThreadMessage>(); | 782 | const stored = new Map<MessageId, QueuedThreadMessage>(); |
| 656 | const storage: ThreadOutboxStorage = { | 783 | const storage: ThreadOutboxStorage = { |
| 657 | load: async () => [...stored.values()], | 784 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 658 | write: async (message) => { | 785 | write: async (message) => { |
| 659 | stored.set(message.messageId, message); | 786 | stored.set(message.messageId, message); |
| 660 | }, | 787 | }, |
| ... | @@ -689,7 +816,7 @@ describe("thread outbox", () => { | ... | @@ -689,7 +816,7 @@ describe("thread outbox", () => { |
| 689 | const manager = createThreadOutboxManager({ | 816 | const manager = createThreadOutboxManager({ |
| 690 | registry, | 817 | registry, |
| 691 | storage: { | 818 | storage: { |
| 692 | load: async () => [], | 819 | load: async () => ({ messages: [], errors: [] }), |
| 693 | write: async (message) => { | 820 | write: async (message) => { |
| 694 | writes.push(message.text); | 821 | writes.push(message.text); |
| 695 | }, | 822 | }, |
| ... | @@ -733,7 +860,7 @@ describe("thread outbox", () => { | ... | @@ -733,7 +860,7 @@ describe("thread outbox", () => { |
| 733 | const manager = createThreadOutboxManager({ | 860 | const manager = createThreadOutboxManager({ |
| 734 | registry, | 861 | registry, |
| 735 | storage: { | 862 | storage: { |
| 736 | load: async () => [], | 863 | load: async () => ({ messages: [], errors: [] }), |
| 737 | write: async (message) => { | 864 | write: async (message) => { |
| 738 | writes.push(message.text); | 865 | writes.push(message.text); |
| 739 | if (message.text === "stale upload") { | 866 | if (message.text === "stale upload") { |
| ... | @@ -777,7 +904,7 @@ describe("thread outbox", () => { | ... | @@ -777,7 +904,7 @@ describe("thread outbox", () => { |
| 777 | const manager = createThreadOutboxManager({ | 904 | const manager = createThreadOutboxManager({ |
| 778 | registry, | 905 | registry, |
| 779 | storage: { | 906 | storage: { |
| 780 | load: async () => [...stored.values()], | 907 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 781 | write: async (message) => { | 908 | write: async (message) => { |
| 782 | stored.set(message.messageId, message); | 909 | stored.set(message.messageId, message); |
| 783 | }, | 910 | }, |
| ... | @@ -826,7 +953,7 @@ describe("thread outbox", () => { | ... | @@ -826,7 +953,7 @@ describe("thread outbox", () => { |
| 826 | const manager = createThreadOutboxManager({ | 953 | const manager = createThreadOutboxManager({ |
| 827 | registry, | 954 | registry, |
| 828 | storage: { | 955 | storage: { |
| 829 | load: async () => [...stored.values()], | 956 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 830 | write: async (message) => { | 957 | write: async (message) => { |
| 831 | if (message === retried) { | 958 | if (message === retried) { |
| 832 | replacementWriteStarted.resolve(); | 959 | replacementWriteStarted.resolve(); |
| ... | @@ -876,7 +1003,7 @@ describe("thread outbox", () => { | ... | @@ -876,7 +1003,7 @@ describe("thread outbox", () => { |
| 876 | const manager = createThreadOutboxManager({ | 1003 | const manager = createThreadOutboxManager({ |
| 877 | registry, | 1004 | registry, |
| 878 | storage: { | 1005 | storage: { |
| 879 | load: async () => [...stored.values()], | 1006 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 880 | write: async (message) => { | 1007 | write: async (message) => { |
| 881 | stored.set(message.messageId, message); | 1008 | stored.set(message.messageId, message); |
| 882 | }, | 1009 | }, |
| ... | @@ -915,7 +1042,7 @@ describe("thread outbox", () => { | ... | @@ -915,7 +1042,7 @@ describe("thread outbox", () => { |
| 915 | const manager = createThreadOutboxManager({ | 1042 | const manager = createThreadOutboxManager({ |
| 916 | registry, | 1043 | registry, |
| 917 | storage: { | 1044 | storage: { |
| 918 | load: async () => [...stored.values()], | 1045 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 919 | write: async (message) => { | 1046 | write: async (message) => { |
| 920 | stored.set(message.messageId, message); | 1047 | stored.set(message.messageId, message); |
| 921 | }, | 1048 | }, |
| ... | @@ -979,7 +1106,7 @@ describe("thread outbox", () => { | ... | @@ -979,7 +1106,7 @@ describe("thread outbox", () => { |
| 979 | const manager = createThreadOutboxManager({ | 1106 | const manager = createThreadOutboxManager({ |
| 980 | registry, | 1107 | registry, |
| 981 | storage: { | 1108 | storage: { |
| 982 | load: async () => [...stored.values()], | 1109 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 983 | write: async (message) => { | 1110 | write: async (message) => { |
| 984 | stored.set(message.messageId, message); | 1111 | stored.set(message.messageId, message); |
| 985 | }, | 1112 | }, |
| ... | @@ -1022,7 +1149,7 @@ describe("thread outbox", () => { | ... | @@ -1022,7 +1149,7 @@ describe("thread outbox", () => { |
| 1022 | const manager = createThreadOutboxManager({ | 1149 | const manager = createThreadOutboxManager({ |
| 1023 | registry, | 1150 | registry, |
| 1024 | storage: { | 1151 | storage: { |
| 1025 | load: async () => [...stored.values()], | 1152 | load: async () => ({ messages: [...stored.values()], errors: [] }), |
| 1026 | write: async (message) => { | 1153 | write: async (message) => { |
| 1027 | stored.set(message.messageId, message); | 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,10 +21,6 @@ export async function flushThreadOutbox(): Promise<void> { |
| 21 | await flushThreadOutboxWrites(); | 21 | await flushThreadOutboxWrites(); |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | export function ensureThreadOutboxLoaded(): void { | ||
| 25 | void threadOutboxManager.load(); | ||
| 26 | } | ||
| 27 | |||
| 28 | export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { | 24 | export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> { |
| 29 | return threadOutboxManager.enqueue(message); | 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,7 +1748,92 @@ describe("mobile composer drafts", () => { |
| 1748 | expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]); | 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 | it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { | 1837 | it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => { |
| 1753 | const file = { | 1838 | const file = { |
| 1754 | id: "file-cold-start", | 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,6 +54,8 @@ const harness = vi.hoisted(() => ({ |
| 54 | })(), | 54 | })(), |
| 55 | })); | 55 | })); |
| 56 | 56 | ||
| 57 | vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } })); | ||
| 58 | |||
| 57 | vi.mock("expo-file-system", () => ({ | 59 | vi.mock("expo-file-system", () => ({ |
| 58 | Directory: harness.draftFile.Directory, | 60 | Directory: harness.draftFile.Directory, |
| 59 | File: harness.draftFile.File, | 61 | File: harness.draftFile.File, |
| ... | @@ -113,7 +115,7 @@ vi.mock("./thread-outbox", async () => { | ... | @@ -113,7 +115,7 @@ vi.mock("./thread-outbox", async () => { |
| 113 | harness.manager = createThreadOutboxManager({ | 115 | harness.manager = createThreadOutboxManager({ |
| 114 | registry: appAtomRegistry, | 116 | registry: appAtomRegistry, |
| 115 | storage: { | 117 | storage: { |
| 116 | load: async () => [], | 118 | load: async () => ({ messages: [], errors: [] }), |
| 117 | write: async () => undefined, | 119 | write: async () => undefined, |
| 118 | remove: (message) => harness.removeOutboxMessage(message), | 120 | remove: (message) => harness.removeOutboxMessage(message), |
| 119 | }, | 121 | }, |
| ... | @@ -122,7 +124,6 @@ vi.mock("./thread-outbox", async () => { | ... | @@ -122,7 +124,6 @@ vi.mock("./thread-outbox", async () => { |
| 122 | return { | 124 | return { |
| 123 | threadOutboxManager: manager, | 125 | threadOutboxManager: manager, |
| 124 | flushThreadOutbox: async () => undefined, | 126 | flushThreadOutbox: async () => undefined, |
| 125 | ensureThreadOutboxLoaded: () => undefined, | ||
| 126 | confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message), | 127 | confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message), |
| 127 | updateThreadOutboxMessage: (message: never, expectedRevision?: number) => | 128 | updateThreadOutboxMessage: (message: never, expectedRevision?: number) => |
| 128 | manager.update(message, expectedRevision), | 129 | manager.update(message, expectedRevision), |
| ... | @@ -219,7 +220,6 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -219,7 +220,6 @@ describe("thread outbox attachment preparation", () => { |
| 219 | ); | 220 | ); |
| 220 | const preparationStarted = Promise.withResolvers<void>(); | 221 | const preparationStarted = Promise.withResolvers<void>(); |
| 221 | const preparationBarrier = Promise.withResolvers<PreparedTurnAttachments>(); | 222 | const preparationBarrier = Promise.withResolvers<PreparedTurnAttachments>(); |
| 222 | const releaseUploads = vi.fn(async () => undefined); | ||
| 223 | harness.prepareTurnAttachments.mockImplementationOnce(async () => { | 223 | harness.prepareTurnAttachments.mockImplementationOnce(async () => { |
| 224 | preparationStarted.resolve(); | 224 | preparationStarted.resolve(); |
| 225 | return preparationBarrier.promise; | 225 | return preparationBarrier.promise; |
| ... | @@ -237,12 +237,10 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -237,12 +237,10 @@ describe("thread outbox attachment preparation", () => { |
| 237 | attachments: [], | 237 | attachments: [], |
| 238 | draftAttachments: message.attachments, | 238 | draftAttachments: message.attachments, |
| 239 | pendingAttachmentIds: ["pending-reused-upload"], | 239 | pendingAttachmentIds: ["pending-reused-upload"], |
| 240 | releaseUploads, | ||
| 241 | }); | 240 | }); |
| 242 | 241 | ||
| 243 | await expect(preparation).resolves.toEqual({ status: "abandoned" }); | 242 | await expect(preparation).resolves.toEqual({ status: "abandoned" }); |
| 244 | expect(remainingMessages()).toEqual([edited]); | 243 | expect(remainingMessages()).toEqual([edited]); |
| 245 | expect(releaseUploads).not.toHaveBeenCalled(); | ||
| 246 | }); | 244 | }); |
| 247 | 245 | ||
| 248 | it("keeps an unchanged queued payload ready after attachment reuse", async () => { | 246 | it("keeps an unchanged queued payload ready after attachment reuse", async () => { |
| ... | @@ -254,13 +252,11 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -254,13 +252,11 @@ describe("thread outbox attachment preparation", () => { |
| 254 | }), | 252 | }), |
| 255 | "pending-reused-upload", | 253 | "pending-reused-upload", |
| 256 | ); | 254 | ); |
| 257 | const releaseUploads = vi.fn(async () => undefined); | ||
| 258 | harness.prepareTurnAttachments.mockResolvedValueOnce({ | 255 | harness.prepareTurnAttachments.mockResolvedValueOnce({ |
| 259 | status: "ready", | 256 | status: "ready", |
| 260 | attachments: [], | 257 | attachments: [], |
| 261 | draftAttachments: message.attachments, | 258 | draftAttachments: message.attachments, |
| 262 | pendingAttachmentIds: ["pending-reused-upload"], | 259 | pendingAttachmentIds: ["pending-reused-upload"], |
| 263 | releaseUploads, | ||
| 264 | }); | 260 | }); |
| 265 | await harness.manager.enqueue(message); | 261 | await harness.manager.enqueue(message); |
| 266 | const revision = harness.manager.revisionOf(message.messageId); | 262 | const revision = harness.manager.revisionOf(message.messageId); |
| ... | @@ -271,7 +267,6 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -271,7 +267,6 @@ describe("thread outbox attachment preparation", () => { |
| 271 | persistedMessage: message, | 267 | persistedMessage: message, |
| 272 | deliveryRevision: revision, | 268 | deliveryRevision: revision, |
| 273 | }); | 269 | }); |
| 274 | expect(releaseUploads).not.toHaveBeenCalled(); | ||
| 275 | }); | 270 | }); |
| 276 | 271 | ||
| 277 | it("uses the known next revision after persisting uploaded references", async () => { | 272 | it("uses the known next revision after persisting uploaded references", async () => { |
| ... | @@ -296,7 +291,6 @@ describe("thread outbox attachment preparation", () => { | ... | @@ -296,7 +291,6 @@ describe("thread outbox attachment preparation", () => { |
| 296 | attachments: [], | 291 | attachments: [], |
| 297 | draftAttachments: uploadedAttachments, | 292 | draftAttachments: uploadedAttachments, |
| 298 | pendingAttachmentIds: ["pending-new-upload"], | 293 | pendingAttachmentIds: ["pending-new-upload"], |
| 299 | releaseUploads: async () => undefined, | ||
| 300 | }; | 294 | }; |
| 301 | }); | 295 | }); |
| 302 | await harness.manager.enqueue(message); | 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,6 +15,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 15 | import * as Cause from "effect/Cause"; | 15 | import * as Cause from "effect/Cause"; |
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | 16 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 17 | import { useCallback, useEffect, useRef, useState } from "react"; | 17 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 18 | import { Alert } from "react-native"; | ||
| 18 | 19 | ||
| 19 | import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; | 20 | import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities"; |
| 20 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; | 21 | import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; |
| ... | @@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities"; | ... | @@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities"; |
| 26 | import { serverEnvironment } from "./server"; | 27 | import { serverEnvironment } from "./server"; |
| 27 | import { | 28 | import { |
| 28 | confirmThreadOutboxMessageQueued, | 29 | confirmThreadOutboxMessageQueued, |
| 29 | ensureThreadOutboxLoaded, | ||
| 30 | threadOutboxManager, | 30 | threadOutboxManager, |
| 31 | threadOutboxRevision, | 31 | threadOutboxRevision, |
| 32 | updateThreadOutboxMessage, | 32 | updateThreadOutboxMessage, |
| ... | @@ -583,8 +583,21 @@ export function useThreadOutboxDrain(): void { | ... | @@ -583,8 +583,21 @@ export function useThreadOutboxDrain(): void { |
| 583 | ); | 583 | ); |
| 584 | 584 | ||
| 585 | useEffect(() => { | 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 | return () => { | 599 | return () => { |
| 600 | mounted = false; | ||
| 588 | for (const timer of retryTimersRef.current.values()) { | 601 | for (const timer of retryTimersRef.current.values()) { |
| 589 | clearTimeout(timer); | 602 | clearTimeout(timer); |
| 590 | } | 603 | } |
| ... | @@ -766,12 +779,6 @@ export function useThreadOutboxDrain(): void { | ... | @@ -766,12 +779,6 @@ export function useThreadOutboxDrain(): void { |
| 766 | (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; | 779 | (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed"; |
| 767 | if (delivered) { | 780 | if (delivered) { |
| 768 | acknowledgedExistingThreadMessageIdsRef.current.delete(persistedMessage.messageId); | 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 | return delivered; | 783 | return delivered; |
| 777 | }, | 784 | }, |
| ... | @@ -904,13 +911,7 @@ export function useThreadOutboxDrain(): void { | ... | @@ -904,13 +911,7 @@ export function useThreadOutboxDrain(): void { |
| 904 | // payload as a duplicate creation. Hand it to the thread's composer. | 911 | // payload as a duplicate creation. Hand it to the thread's composer. |
| 905 | return recoverEditedCreationAfterDelivery(persistedMessage); | 912 | return recoverEditedCreationAfterDelivery(persistedMessage); |
| 906 | } | 913 | } |
| 907 | if (outcome === "removed") { | 914 | return 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 | }, | 915 | }, |
| 915 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], | 916 | [makeDeliveryHelpers, restoreQueuedMessage, startTurn], |
| 916 | ); | 917 | ); |
apps/server/src/http.ts+9-7| ... | @@ -566,15 +566,20 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( | ... | @@ -566,15 +566,20 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( |
| 566 | } | 566 | } |
| 567 | } | 567 | } |
| 568 | const fileInfo = opened.info; | 568 | const fileInfo = opened.info; |
| 569 | const mimeType = Mime.getType(filePath) ?? "application/octet-stream"; | ||
| 570 | const isHtml = mimeType === "text/html"; | ||
| 569 | 571 | ||
| 570 | // A hash-like name is not enough: custom static files can use the same naming pattern. | 572 | // A hash-like name is not enough: custom static files can use the same naming pattern. |
| 571 | const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/"); | 573 | const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/"); |
| 572 | const immutable = | 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 | const headers: Record<string, string> = { | 578 | const headers: Record<string, string> = { |
| 575 | "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache", | 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 | const etag = modifiedAt | 583 | const etag = modifiedAt |
| 579 | ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"` | 584 | ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"` |
| 580 | : undefined; | 585 | : undefined; |
| ... | @@ -599,17 +604,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( | ... | @@ -599,17 +604,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( |
| 599 | : ifModifiedSince !== undefined && | 604 | : ifModifiedSince !== undefined && |
| 600 | modifiedAt !== undefined && | 605 | modifiedAt !== undefined && |
| 601 | Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince); | 606 | Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince); |
| 602 | if (unchanged) { | 607 | if (!isHtml && unchanged) { |
| 603 | return HttpServerResponse.empty({ | 608 | return HttpServerResponse.empty({ |
| 604 | status: 304, | 609 | status: 304, |
| 605 | headers: { ...headers, Vary: "Accept-Encoding" }, | 610 | headers: { ...headers, Vary: "Accept-Encoding" }, |
| 606 | }); | 611 | }); |
| 607 | } | 612 | } |
| 608 | 613 | ||
| 609 | const contentType = | 614 | const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; |
| 610 | path.extname(filePath) === ".html" | ||
| 611 | ? "text/html; charset=utf-8" | ||
| 612 | : (Mime.getType(filePath) ?? "application/octet-stream"); | ||
| 613 | // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. | 615 | // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. |
| 614 | // HEAD still passes through compression, which selects headers without reading the stream. | 616 | // HEAD still passes through compression, which selects headers without reading the stream. |
| 615 | return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { | 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,7 +93,7 @@ export const makeLiveStreamBudget = Effect.fn("makeLiveStreamBudget")(function* |
| 93 | return Effect.succeed(item); | 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 | // count against the same budget, and discarded updates release their charge. | 97 | // count against the same budget, and discarded updates release their charge. |
| 98 | const replace = <A extends object>( | 98 | const replace = <A extends object>( |
| 99 | previous: ReadonlyArray<RetainedLiveItem<unknown>>, | 99 | previous: ReadonlyArray<RetainedLiveItem<unknown>>, |
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+3-2| ... | @@ -130,7 +130,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales | ... | @@ -130,7 +130,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales |
| 130 | pendingUpdates, | 130 | pendingUpdates, |
| 131 | coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({ | 131 | coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({ |
| 132 | kind: "event" as const, | 132 | kind: "event" as const, |
| 133 | event: projectActivityEvent(event), | 133 | event, |
| 134 | })), | 134 | })), |
| 135 | (item) => item.event, | 135 | (item) => item.event, |
| 136 | ); | 136 | ); |
| ... | @@ -167,7 +167,8 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales | ... | @@ -167,7 +167,8 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales |
| 167 | Effect.gen(function* () { | 167 | Effect.gen(function* () { |
| 168 | yield* budget.check; | 168 | yield* budget.check; |
| 169 | if (input.kind === "event") { | 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 | Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), | 172 | Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))), |
| 172 | Effect.uninterruptible, | 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,14 +1688,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1688 | const fileSystem = yield* FileSystem.FileSystem; | 1688 | const fileSystem = yield* FileSystem.FileSystem; |
| 1689 | const path = yield* Path.Path; | 1689 | const path = yield* Path.Path; |
| 1690 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" }); | 1690 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" }); |
| 1691 | const indexPath = path.join(staticDir, "index.html"); | 1691 | const assetPath = path.join(staticDir, "app.js"); |
| 1692 | yield* fileSystem.writeFileString(indexPath, "<html>first build</html>"); | 1692 | yield* fileSystem.writeFileString(assetPath, 'export const build = "first";'); |
| 1693 | yield* buildAppUnderTest({ config: { staticDir } }); | 1693 | yield* buildAppUnderTest({ config: { staticDir } }); |
| 1694 | 1694 | ||
| 1695 | const initial = yield* HttpClient.get("/"); | 1695 | const initial = yield* HttpClient.get("/app.js"); |
| 1696 | assert.equal(initial.status, 200); | 1696 | assert.equal(initial.status, 200); |
| 1697 | assert.equal(initial.headers["cache-control"], "no-cache"); | 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 | const etag = initial.headers.etag; | 1699 | const etag = initial.headers.etag; |
| 1700 | assert.isDefined(etag); | 1700 | assert.isDefined(etag); |
| 1701 | assert.isDefined(initial.headers["last-modified"]); | 1701 | assert.isDefined(initial.headers["last-modified"]); |
| ... | @@ -1706,30 +1706,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1706,30 +1706,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1706 | { "if-none-match": "*" }, | 1706 | { "if-none-match": "*" }, |
| 1707 | { "if-modified-since": initial.headers["last-modified"]! }, | 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 | assert.equal(response.status, 304); | 1710 | assert.equal(response.status, 304); |
| 1711 | assert.equal(response.headers.etag, etag); | 1711 | assert.equal(response.headers.etag, etag); |
| 1712 | assert.equal(response.headers["cache-control"], "no-cache"); | 1712 | assert.equal(response.headers["cache-control"], "no-cache"); |
| 1713 | assert.equal(yield* response.text, ""); | 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 | headers: { | 1717 | headers: { |
| 1718 | "if-none-match": '"another-build"', | 1718 | "if-none-match": '"another-build"', |
| 1719 | "if-modified-since": initial.headers["last-modified"]!, | 1719 | "if-modified-since": initial.headers["last-modified"]!, |
| 1720 | }, | 1720 | }, |
| 1721 | }); | 1721 | }); |
| 1722 | assert.equal(mismatched.status, 200); | 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>"); | 1725 | yield* fileSystem.writeFileString(assetPath, 'export const build = "the next build";'); |
| 1726 | const changed = yield* HttpClient.get("/", { headers: { "if-none-match": etag! } }); | 1726 | const changed = yield* HttpClient.get("/app.js", { headers: { "if-none-match": etag! } }); |
| 1727 | assert.equal(changed.status, 200); | 1727 | assert.equal(changed.status, 200); |
| 1728 | assert.notEqual(changed.headers.etag, etag); | 1728 | assert.notEqual(changed.headers.etag, etag); |
| 1729 | assert.include(yield* changed.text, "next build"); | 1729 | assert.include(yield* changed.text, "next build"); |
| 1730 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () => | 1773 | it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () => |
| 1734 | Effect.gen(function* () { | 1774 | Effect.gen(function* () { |
| 1735 | const fileSystem = yield* FileSystem.FileSystem; | 1775 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -1917,8 +1957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1917,8 +1957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1917 | const fileSystem = yield* FileSystem.FileSystem; | 1957 | const fileSystem = yield* FileSystem.FileSystem; |
| 1918 | const path = yield* Path.Path; | 1958 | const path = yield* Path.Path; |
| 1919 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" }); | 1959 | const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" }); |
| 1920 | const filePath = path.join(staticDir, "index.html"); | 1960 | const filePath = path.join(staticDir, "app.txt"); |
| 1921 | const body = "<p>file content</p>".repeat(1024); | 1961 | const body = "file content\n".repeat(1024); |
| 1922 | yield* fileSystem.writeFileString(filePath, body); | 1962 | yield* fileSystem.writeFileString(filePath, body); |
| 1923 | const closed = yield* Queue.unbounded<FileSystem.File>(); | 1963 | const closed = yield* Queue.unbounded<FileSystem.File>(); |
| 1924 | const blocked = yield* Deferred.make<void>(); | 1964 | const blocked = yield* Deferred.make<void>(); |
| ... | @@ -1963,14 +2003,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1963,14 +2003,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1963 | Effect.provideService(FileSystem.FileSystem, trackedFileSystem), | 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 | assert.equal(yield* get.text, body); | 2007 | assert.equal(yield* get.text, body); |
| 1968 | yield* Queue.take(closed); | 2008 | yield* Queue.take(closed); |
| 1969 | assert.equal(active.size, 0); | 2009 | assert.equal(active.size, 0); |
| 1970 | assert.isAbove(bodyReads, 0); | 2010 | assert.isAbove(bodyReads, 0); |
| 1971 | const readsAfterGet = bodyReads; | 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 | assert.equal(head.status, 200); | 2014 | assert.equal(head.status, 200); |
| 1975 | assert.equal(head.headers["content-encoding"], "gzip"); | 2015 | assert.equal(head.headers["content-encoding"], "gzip"); |
| 1976 | assert.equal(yield* head.text, ""); | 2016 | assert.equal(yield* head.text, ""); |
| ... | @@ -1978,7 +2018,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1978,7 +2018,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1978 | assert.equal(active.size, 0); | 2018 | assert.equal(active.size, 0); |
| 1979 | assert.equal(bodyReads, readsAfterGet); | 2019 | assert.equal(bodyReads, readsAfterGet); |
| 1980 | 2020 | ||
| 1981 | const unchanged = yield* HttpClient.get("/", { | 2021 | const unchanged = yield* HttpClient.get("/app.txt", { |
| 1982 | headers: { "if-none-match": get.headers.etag! }, | 2022 | headers: { "if-none-match": get.headers.etag! }, |
| 1983 | }); | 2023 | }); |
| 1984 | assert.equal(unchanged.status, 304); | 2024 | assert.equal(unchanged.status, 304); |
| ... | @@ -1987,7 +2027,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1987,7 +2027,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1987 | assert.equal(bodyReads, readsAfterGet); | 2027 | assert.equal(bodyReads, readsAfterGet); |
| 1988 | 2028 | ||
| 1989 | blockAfterOpen = true; | 2029 | blockAfterOpen = true; |
| 1990 | const cancelled = yield* HttpClient.get("/").pipe(Effect.forkChild); | 2030 | const cancelled = yield* HttpClient.get("/app.txt").pipe(Effect.forkChild); |
| 1991 | yield* Deferred.await(blocked); | 2031 | yield* Deferred.await(blocked); |
| 1992 | assert.equal(active.size, 1); | 2032 | assert.equal(active.size, 1); |
| 1993 | yield* Fiber.interrupt(cancelled); | 2033 | yield* Fiber.interrupt(cancelled); |
| ... | @@ -7866,6 +7906,104 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -7866,6 +7906,104 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7866 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("coalesces buffered live tool updates to the latest state", () => | 8007 | it.effect("coalesces buffered live tool updates to the latest state", () => |
| 7870 | Effect.gen(function* () { | 8008 | Effect.gen(function* () { |
| 7871 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; | 8009 | const thread = makeDefaultOrchestrationReadModel().threads[0]!; |
apps/server/src/ws.ts+27-11| ... | @@ -38,7 +38,7 @@ import { | ... | @@ -38,7 +38,7 @@ import { |
| 38 | OrchestrationSearchThreadsError, | 38 | OrchestrationSearchThreadsError, |
| 39 | OrchestrationGetTurnDiffError, | 39 | OrchestrationGetTurnDiffError, |
| 40 | ORCHESTRATION_WS_METHODS, | 40 | ORCHESTRATION_WS_METHODS, |
| 41 | type ProjectId, | 41 | ProjectId, |
| 42 | type ProjectEntriesFailure, | 42 | type ProjectEntriesFailure, |
| 43 | type ProjectFileFailure, | 43 | type ProjectFileFailure, |
| 44 | type ProjectFileOperation, | 44 | type ProjectFileOperation, |
| ... | @@ -723,19 +723,33 @@ const makeWsRpcLayer = ( | ... | @@ -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 | const toShellStreamEvent = ( | 740 | const toShellStreamEvent = ( |
| 727 | event: OrchestrationEvent, | 741 | event: ShellEvent, |
| 728 | ): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => { | 742 | ): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => { |
| 729 | switch (event.type) { | 743 | switch (event.type) { |
| 730 | case "project.created": | 744 | case "project.created": |
| 731 | case "project.meta-updated": | 745 | case "project.meta-updated": |
| 732 | return projectUpsertOrRemove(event.payload.projectId, event.sequence); | 746 | return projectUpsertOrRemove(ProjectId.make(event.aggregateId), event.sequence); |
| 733 | case "project.deleted": | 747 | case "project.deleted": |
| 734 | return Effect.succeed( | 748 | return Effect.succeed( |
| 735 | Option.some({ | 749 | Option.some({ |
| 736 | kind: "project-removed" as const, | 750 | kind: "project-removed" as const, |
| 737 | sequence: event.sequence, | 751 | sequence: event.sequence, |
| 738 | projectId: event.payload.projectId, | 752 | projectId: ProjectId.make(event.aggregateId), |
| 739 | }), | 753 | }), |
| 740 | ); | 754 | ); |
| 741 | case "thread.deleted": | 755 | case "thread.deleted": |
| ... | @@ -744,11 +758,11 @@ const makeWsRpcLayer = ( | ... | @@ -744,11 +758,11 @@ const makeWsRpcLayer = ( |
| 744 | Option.some({ | 758 | Option.some({ |
| 745 | kind: "thread-removed" as const, | 759 | kind: "thread-removed" as const, |
| 746 | sequence: event.sequence, | 760 | sequence: event.sequence, |
| 747 | threadId: event.payload.threadId, | 761 | threadId: ThreadId.make(event.aggregateId), |
| 748 | }), | 762 | }), |
| 749 | ); | 763 | ); |
| 750 | case "thread.unarchived": | 764 | case "thread.unarchived": |
| 751 | return threadUpsertOrRemove(event.payload.threadId, event.sequence); | 765 | return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); |
| 752 | default: | 766 | default: |
| 753 | if (event.aggregateKind !== "thread") { | 767 | if (event.aggregateKind !== "thread") { |
| 754 | return Effect.succeed(Option.none()); | 768 | return Effect.succeed(Option.none()); |
| ... | @@ -862,13 +876,13 @@ const makeWsRpcLayer = ( | ... | @@ -862,13 +876,13 @@ const makeWsRpcLayer = ( |
| 862 | // item. The refetch runs with bounded concurrency (order-preserving). | 876 | // item. The refetch runs with bounded concurrency (order-preserving). |
| 863 | const SHELL_REFETCH_CONCURRENCY = 8; | 877 | const SHELL_REFETCH_CONCURRENCY = 8; |
| 864 | const coalesceShellEvents = ( | 878 | const coalesceShellEvents = ( |
| 865 | events: ReadonlyArray<OrchestrationEvent>, | 879 | events: ReadonlyArray<ShellEvent>, |
| 866 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamEvent>, never, never> => | 880 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamEvent>, never, never> => |
| 867 | Effect.gen(function* () { | 881 | Effect.gen(function* () { |
| 868 | if (events.length === 0) { | 882 | if (events.length === 0) { |
| 869 | return []; | 883 | return []; |
| 870 | } | 884 | } |
| 871 | const latestByAggregate = new Map<string, OrchestrationEvent>(); | 885 | const latestByAggregate = new Map<string, ShellEvent>(); |
| 872 | for (const event of events) { | 886 | for (const event of events) { |
| 873 | latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); | 887 | latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event); |
| 874 | } | 888 | } |
| ... | @@ -891,16 +905,17 @@ const makeWsRpcLayer = ( | ... | @@ -891,16 +905,17 @@ const makeWsRpcLayer = ( |
| 891 | stream: Stream.Stream<OrchestrationEvent, E, R>, | 905 | stream: Stream.Stream<OrchestrationEvent, E, R>, |
| 892 | ): Stream.Stream<OrchestrationShellStreamEvent, E, R> => | 906 | ): Stream.Stream<OrchestrationShellStreamEvent, E, R> => |
| 893 | stream.pipe( | 907 | stream.pipe( |
| 908 | Stream.map(toShellEvent), | ||
| 894 | Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), | 909 | Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW), |
| 895 | Stream.mapEffect(coalesceShellEvents), | 910 | Stream.mapEffect(coalesceShellEvents), |
| 896 | Stream.flatMap((items) => Stream.fromIterable(items)), | 911 | Stream.flatMap((items) => Stream.fromIterable(items)), |
| 897 | ); | 912 | ); |
| 898 | 913 | ||
| 899 | type ShellLiveInput = | 914 | type ShellLiveInput = |
| 900 | | { readonly kind: "event"; readonly event: OrchestrationEvent } | 915 | | { readonly kind: "event"; readonly event: ShellEvent } |
| 901 | | { readonly kind: "synchronized" }; | 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 | // overtake an event still waiting in the coalescing window. Split each | 919 | // overtake an event still waiting in the coalescing window. Split each |
| 905 | // batch at markers and coalesce only the event segments on either side. | 920 | // batch at markers and coalesce only the event segments on either side. |
| 906 | const coalesceShellLiveInputs = ( | 921 | const coalesceShellLiveInputs = ( |
| ... | @@ -908,7 +923,7 @@ const makeWsRpcLayer = ( | ... | @@ -908,7 +923,7 @@ const makeWsRpcLayer = ( |
| 908 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamItem>, never, never> => | 923 | ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamItem>, never, never> => |
| 909 | Effect.gen(function* () { | 924 | Effect.gen(function* () { |
| 910 | const output: Array<OrchestrationShellStreamItem> = []; | 925 | const output: Array<OrchestrationShellStreamItem> = []; |
| 911 | let pendingEvents: Array<OrchestrationEvent> = []; | 926 | let pendingEvents: Array<ShellEvent> = []; |
| 912 | 927 | ||
| 913 | for (const input of inputs) { | 928 | for (const input of inputs) { |
| 914 | if (input.kind === "event") { | 929 | if (input.kind === "event") { |
| ... | @@ -1430,6 +1445,7 @@ const makeWsRpcLayer = ( | ... | @@ -1430,6 +1445,7 @@ const makeWsRpcLayer = ( |
| 1430 | ); | 1445 | ); |
| 1431 | yield* Effect.forkScoped( | 1446 | yield* Effect.forkScoped( |
| 1432 | orchestrationEngine.streamDomainEvents.pipe( | 1447 | orchestrationEngine.streamDomainEvents.pipe( |
| 1448 | Stream.map(toShellEvent), | ||
| 1433 | Stream.runForEach((event) => | 1449 | Stream.runForEach((event) => |
| 1434 | liveBudget.retain({ kind: "event" as const, event }, event).pipe( | 1450 | liveBudget.retain({ kind: "event" as const, event }, event).pipe( |
| 1435 | Effect.flatMap((item) => Queue.offer(liveBuffer, item)), | 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,6 +104,50 @@ describe("ChatMarkdown favicon privacy", () => { |
| 104 | }); | 104 | }); |
| 105 | 105 | ||
| 106 | describe("ChatMarkdown streaming", () => { | 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 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { | 151 | it("preserves code controls and details without highlighting an unchanged fence again", async () => { |
| 108 | const highlighter = await getSyntaxHighlighterPromise("text"); | 152 | const highlighter = await getSyntaxHighlighterPromise("text"); |
| 109 | const highlight = vi.spyOn(highlighter, "codeToHtml"); | 153 | const highlight = vi.spyOn(highlighter, "codeToHtml"); |
apps/web/src/components/ChatMarkdown.tsx+4-1| ... | @@ -2873,7 +2873,10 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -2873,7 +2873,10 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 2873 | fenceTitle={fenceTitle} | 2873 | fenceTitle={fenceTitle} |
| 2874 | theme={resolvedTheme} | 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 | <Suspense fallback={<pre {...props}>{children}</pre>}> | 2880 | <Suspense fallback={<pre {...props}>{children}</pre>}> |
| 2878 | <SuspenseShikiCodeBlock | 2881 | <SuspenseShikiCodeBlock |
| 2879 | className={codeBlock.className} | 2882 | className={codeBlock.className} |
apps/web/src/components/DiffWorkerPoolProvider.tsx+78-30| ... | @@ -1,7 +1,15 @@ | ... | @@ -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 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; | 3 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; |
| 3 | import * as Schema from "effect/Schema"; | 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 | import { useTheme } from "../hooks/useTheme"; | 13 | import { useTheme } from "../hooks/useTheme"; |
| 6 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 14 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 7 | import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; | 15 | import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; |
| ... | @@ -16,6 +24,46 @@ export class DiffWorkerError extends Schema.TaggedErrorClass<DiffWorkerError>()( | ... | @@ -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 | function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { | 67 | function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { |
| 20 | const workerPool = useWorkerPool(); | 68 | const workerPool = useWorkerPool(); |
| 21 | 69 | ||
| ... | @@ -49,16 +97,17 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { | ... | @@ -49,16 +97,17 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { |
| 49 | // Plain-text views do not queue a highlight task that could retry a blank first render. | 97 | // Plain-text views do not queue a highlight task that could retry a blank first render. |
| 50 | function DiffWorkerReady({ children }: { children?: ReactNode }) { | 98 | function DiffWorkerReady({ children }: { children?: ReactNode }) { |
| 51 | const workerPool = useWorkerPool(); | 99 | const workerPool = useWorkerPool(); |
| 52 | const [ready, setReady] = useState( | 100 | const [readyPool, setReadyPool] = useState<WorkerPoolManager>(); |
| 53 | () => !workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool(), | 101 | const ready = workerPool |
| 54 | ); | 102 | ? readyPool === workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool() |
| 103 | : typeof window === "undefined"; | ||
| 55 | 104 | ||
| 56 | useEffect(() => { | 105 | useEffect(() => { |
| 57 | if (ready || !workerPool) return; | 106 | if (ready || !workerPool) return; |
| 58 | 107 | ||
| 59 | let mounted = true; | 108 | let mounted = true; |
| 60 | const finish = () => { | 109 | const finish = () => { |
| 61 | if (mounted) setReady(true); | 110 | if (mounted) setReadyPool(workerPool); |
| 62 | }; | 111 | }; |
| 63 | // Failed pools use Pierre's existing main-thread highlighter. | 112 | // Failed pools use Pierre's existing main-thread highlighter. |
| 64 | void workerPool.initialize().then(finish, finish); | 113 | void workerPool.initialize().then(finish, finish); |
| ... | @@ -87,33 +136,32 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { | ... | @@ -87,33 +136,32 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { |
| 87 | typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | 136 | typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); |
| 88 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); | 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 | return ( | 161 | return ( |
| 92 | <WorkerPoolContextProvider | 162 | <WorkerPoolContext value={workerPool}> |
| 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 | > | ||
| 115 | <DiffWorkerThemeSync themeName={diffThemeName} /> | 163 | <DiffWorkerThemeSync themeName={diffThemeName} /> |
| 116 | <DiffWorkerReady>{children}</DiffWorkerReady> | 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,10 +1,35 @@ |
| 1 | import { Component, type ReactNode } from "react"; | 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 | export class RenderErrorBoundary extends Component< | 14 | export class RenderErrorBoundary extends Component< |
| 4 | { readonly children: ReactNode; readonly fallback: ReactNode }, | 15 | RenderErrorBoundaryProps, |
| 5 | { readonly failed: boolean } | 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 | static getDerivedStateFromError() { | 34 | static getDerivedStateFromError() { |
| 10 | return { failed: true }; | 35 | return { failed: true }; |
apps/web/src/components/chat/ComposerActivityStatus.tsx+3-2| ... | @@ -1,12 +1,13 @@ | ... | @@ -1,12 +1,13 @@ |
| 1 | import { LoaderCircleIcon } from "lucide-react"; | 1 | import { LoaderCircleIcon } from "lucide-react"; |
| 2 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | ||
| 2 | import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; | 3 | import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync"; |
| 3 | import { ComposerBanner } from "./ComposerBanner"; | 4 | import { ComposerBanner } from "./ComposerBanner"; |
| 4 | 5 | ||
| 5 | export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { | 6 | export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) { |
| 6 | return ( | 7 | return ( |
| 7 | <ComposerBanner.Row> | 8 | <ComposerBanner.Row> |
| 8 | <ComposerBanner.Icon> | 9 | <ComposerBanner.Icon ref={observeVisibleAnimation}> |
| 9 | <LoaderCircleIcon /> | 10 | <LoaderCircleIcon className="motion-safe:visible-animate-spin" /> |
| 10 | </ComposerBanner.Icon> | 11 | </ComposerBanner.Icon> |
| 11 | <ComposerBanner.Content> | 12 | <ComposerBanner.Content> |
| 12 | <span | 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,6 +2,7 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server"; |
| 2 | import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; | 2 | import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react"; |
| 3 | import { useId, useState } from "react"; | 3 | import { useId, useState } from "react"; |
| 4 | 4 | ||
| 5 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | ||
| 5 | import { serverUpdateStageLabel } from "../ServerUpdateAction"; | 6 | import { serverUpdateStageLabel } from "../ServerUpdateAction"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | import { ComposerBanner } from "./ComposerBanner"; | 8 | import { ComposerBanner } from "./ComposerBanner"; |
| ... | @@ -12,7 +13,13 @@ export function ComposerServerUpdateIcon({ | ... | @@ -12,7 +13,13 @@ export function ComposerServerUpdateIcon({ |
| 12 | readonly status: ServerUpdateState["status"]; | 13 | readonly status: ServerUpdateState["status"]; |
| 13 | }) { | 14 | }) { |
| 14 | if (status === "running") { | 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 | if (status === "failed") { | 24 | if (status === "failed") { |
| 18 | return <CircleAlertIcon aria-hidden className="text-error" />; | 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,6 +28,7 @@ const NOOP_OPEN_ATTACHMENT = (_attachment: ChatFileAttachment) => {}; |
| 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 28 | import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; | 29 | import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; |
| 30 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; | 30 | import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon"; |
| 31 | import { observeVisibleAnimation } from "../../lib/visibleAnimation"; | ||
| 31 | import { | 32 | import { |
| 32 | createContext, | 33 | createContext, |
| 33 | Fragment, | 34 | Fragment, |
| ... | @@ -1652,12 +1653,21 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin | ... | @@ -1652,12 +1653,21 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin |
| 1652 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> | 1653 | <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums"> |
| 1653 | <span | 1654 | <span |
| 1654 | key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"} | 1655 | key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"} |
| 1656 | ref={isPreparingWorktree || isCompacting ? observeVisibleAnimation : undefined} | ||
| 1655 | className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none" | 1657 | className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none" |
| 1656 | > | 1658 | > |
| 1657 | {isPreparingWorktree ? ( | 1659 | {isPreparingWorktree ? ( |
| 1658 | "Setting up worktree…" | 1660 | <> |
| 1661 | Setting up worktree… | ||
| 1662 | <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay> | ||
| 1663 | </> | ||
| 1659 | ) : isCompacting ? ( | 1664 | ) : isCompacting ? ( |
| 1660 | <CompactingLabel /> | 1665 | <> |
| 1666 | <CompactingLabel /> | ||
| 1667 | <ActivityShimmerOverlay> | ||
| 1668 | <CompactingLabel /> | ||
| 1669 | </ActivityShimmerOverlay> | ||
| 1670 | </> | ||
| 1661 | ) : row.createdAt ? ( | 1671 | ) : row.createdAt ? ( |
| 1662 | <> | 1672 | <> |
| 1663 | Working for <WorkingTimer createdAt={row.createdAt} /> | 1673 | Working for <WorkingTimer createdAt={row.createdAt} /> |
| ... | @@ -1677,7 +1687,7 @@ function ThinkingTimelineRow() { | ... | @@ -1677,7 +1687,7 @@ function ThinkingTimelineRow() { |
| 1677 | return ( | 1687 | return ( |
| 1678 | <div className="min-h-7"> | 1688 | <div className="min-h-7"> |
| 1679 | {isPreparingWorktree || isCompacting ? null : ( | 1689 | {isPreparingWorktree || isCompacting ? null : ( |
| 1680 | <LiveActivityRow label="Thinking" iconName="brain" /> | 1690 | <LiveActivityRow label="Thinking" iconName="brain" active shimmer /> |
| 1681 | )} | 1691 | )} |
| 1682 | </div> | 1692 | </div> |
| 1683 | ); | 1693 | ); |
| ... | @@ -1919,6 +1929,19 @@ function ExpandedWorkGroupEntries({ | ... | @@ -1919,6 +1929,19 @@ function ExpandedWorkGroupEntries({ |
| 1919 | 1929 | ||
| 1920 | const workEntryKey = (entry: TimelineWorkEntry) => entry.id; | 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 | const failedToolIconClassName = "text-tool-error-icon/40"; | 1945 | const failedToolIconClassName = "text-tool-error-icon/40"; |
| 1923 | 1946 | ||
| 1924 | /** Image icons and the gradient computer-use mark cannot take a currentColor | 1947 | /** Image icons and the gradient computer-use mark cannot take a currentColor |
| ... | @@ -1936,23 +1959,35 @@ function LiveActivityRow({ | ... | @@ -1936,23 +1959,35 @@ function LiveActivityRow({ |
| 1936 | toolIcon, | 1959 | toolIcon, |
| 1937 | failed = false, | 1960 | failed = false, |
| 1938 | active = false, | 1961 | active = false, |
| 1962 | shimmer = false, | ||
| 1939 | }: { | 1963 | }: { |
| 1940 | label: string; | 1964 | label: string; |
| 1941 | iconName?: WorkEntryIconName; | 1965 | iconName?: WorkEntryIconName; |
| 1942 | toolIcon?: ToolActivityIcon | undefined; | 1966 | toolIcon?: ToolActivityIcon | undefined; |
| 1943 | failed?: boolean; | 1967 | failed?: boolean; |
| 1944 | active?: boolean; | 1968 | active?: boolean; |
| 1969 | shimmer?: boolean; | ||
| 1945 | }) { | 1970 | }) { |
| 1971 | const animated = active && !failed; | ||
| 1972 | const showShimmer = animated && shimmer; | ||
| 1946 | return ( | 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 | <LiveActivityContent | 1978 | <LiveActivityContent |
| 1949 | label={label} | 1979 | label={label} |
| 1950 | iconName={iconName} | 1980 | iconName={iconName} |
| 1951 | toolIcon={toolIcon} | 1981 | toolIcon={toolIcon} |
| 1952 | failed={failed} | 1982 | failed={failed} |
| 1953 | announceFailure={failed} | 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 | </div> | 1991 | </div> |
| 1957 | ); | 1992 | ); |
| 1958 | } | 1993 | } |
| ... | @@ -1964,6 +1999,7 @@ function LiveActivityContent({ | ... | @@ -1964,6 +1999,7 @@ function LiveActivityContent({ |
| 1964 | failed = false, | 1999 | failed = false, |
| 1965 | announceFailure = false, | 2000 | announceFailure = false, |
| 1966 | active = false, | 2001 | active = false, |
| 2002 | highlighted = false, | ||
| 1967 | }: { | 2003 | }: { |
| 1968 | label: string; | 2004 | label: string; |
| 1969 | iconName: WorkEntryIconName | undefined; | 2005 | iconName: WorkEntryIconName | undefined; |
| ... | @@ -1971,6 +2007,7 @@ function LiveActivityContent({ | ... | @@ -1971,6 +2007,7 @@ function LiveActivityContent({ |
| 1971 | failed?: boolean; | 2007 | failed?: boolean; |
| 1972 | announceFailure?: boolean; | 2008 | announceFailure?: boolean; |
| 1973 | active?: boolean; | 2009 | active?: boolean; |
| 2010 | highlighted?: boolean; | ||
| 1974 | }) { | 2011 | }) { |
| 1975 | const showTrailingFailureMark = | 2012 | const showTrailingFailureMark = |
| 1976 | failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); | 2013 | failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon); |
| ... | @@ -1980,14 +2017,14 @@ function LiveActivityContent({ | ... | @@ -1980,14 +2017,14 @@ function LiveActivityContent({ |
| 1980 | className={cn( | 2017 | className={cn( |
| 1981 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", | 2018 | "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5", |
| 1982 | iconName ? "px-0.5" : "px-1", | 2019 | iconName ? "px-0.5" : "px-1", |
| 1983 | "text-secondary-label", | 2020 | highlighted ? "text-foreground" : "text-secondary-label", |
| 1984 | )} | 2021 | )} |
| 1985 | > | 2022 | > |
| 1986 | {iconName ? ( | 2023 | {iconName ? ( |
| 1987 | <span | 2024 | <span |
| 1988 | className={cn( | 2025 | className={cn( |
| 1989 | "flex size-6 shrink-0 items-center justify-center", | 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 | role={announceFailure ? "img" : undefined} | 2029 | role={announceFailure ? "img" : undefined} |
| 1993 | aria-label={announceFailure ? "Tool call failed" : undefined} | 2030 | aria-label={announceFailure ? "Tool call failed" : undefined} |
| ... | @@ -1996,7 +2033,7 @@ function LiveActivityContent({ | ... | @@ -1996,7 +2033,7 @@ function LiveActivityContent({ |
| 1996 | icon={toolIcon} | 2033 | icon={toolIcon} |
| 1997 | fallbackName={iconName} | 2034 | fallbackName={iconName} |
| 1998 | className="block size-4 shrink-0 stroke-[1.8]" | 2035 | className="block size-4 shrink-0 stroke-[1.8]" |
| 1999 | muted | 2036 | muted={!highlighted} |
| 2000 | /> | 2037 | /> |
| 2001 | </span> | 2038 | </span> |
| 2002 | ) : null} | 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,6 +9,7 @@ import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react"; |
| 9 | import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; | 9 | import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; |
| 10 | import { | 10 | import { |
| 11 | act, | 11 | act, |
| 12 | StrictMode, | ||
| 12 | useEffect, | 13 | useEffect, |
| 13 | useImperativeHandle, | 14 | useImperativeHandle, |
| 14 | useLayoutEffect, | 15 | useLayoutEffect, |
| ... | @@ -149,6 +150,14 @@ function Views({ count }: { count: number }) { | ... | @@ -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 | describe("StyledDiffCodeView", () => { | 161 | describe("StyledDiffCodeView", () => { |
| 153 | beforeEach(() => { | 162 | beforeEach(() => { |
| 154 | testState.codeViewClassName = null; | 163 | testState.codeViewClassName = null; |
| ... | @@ -208,6 +217,7 @@ describe("code-view worker lifecycle", () => { | ... | @@ -208,6 +217,7 @@ describe("code-view worker lifecycle", () => { |
| 208 | vi.stubGlobal("window", {}); | 217 | vi.stubGlobal("window", {}); |
| 209 | vi.stubGlobal("navigator", { hardwareConcurrency: 2 }); | 218 | vi.stubGlobal("navigator", { hardwareConcurrency: 2 }); |
| 210 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 219 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 220 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | ||
| 211 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => | 221 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => |
| 212 | setImmediate(() => callback(0)), | 222 | setImmediate(() => callback(0)), |
| 213 | ); | 223 | ); |
| ... | @@ -226,14 +236,16 @@ describe("code-view worker lifecycle", () => { | ... | @@ -226,14 +236,16 @@ describe("code-view worker lifecycle", () => { |
| 226 | afterEach(async () => { | 236 | afterEach(async () => { |
| 227 | await act(async () => renderer?.unmount()); | 237 | await act(async () => renderer?.unmount()); |
| 228 | renderer = undefined; | 238 | renderer = undefined; |
| 239 | await vi.runOnlyPendingTimersAsync(); | ||
| 229 | await Promise.all(testState.terminations); | 240 | await Promise.all(testState.terminations); |
| 241 | vi.useRealTimers(); | ||
| 230 | vi.restoreAllMocks(); | 242 | vi.restoreAllMocks(); |
| 231 | vi.unstubAllGlobals(); | 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 | await act(async () => { | 247 | await act(async () => { |
| 236 | renderer = create(<Views count={0} />); | 248 | renderer = create(renderViews(0)); |
| 237 | }); | 249 | }); |
| 238 | const mounted = renderer!; | 250 | const mounted = renderer!; |
| 239 | const button = mounted.root.findByType("button"); | 251 | const button = mounted.root.findByType("button"); |
| ... | @@ -241,7 +253,7 @@ describe("code-view worker lifecycle", () => { | ... | @@ -241,7 +253,7 @@ describe("code-view worker lifecycle", () => { |
| 241 | expect(testState.pools.size).toBe(0); | 253 | expect(testState.pools.size).toBe(0); |
| 242 | await act(async () => { | 254 | await act(async () => { |
| 243 | (button.props as { onClick(): void }).onClick(); | 255 | (button.props as { onClick(): void }).onClick(); |
| 244 | mounted.update(<Views count={1} />); | 256 | mounted.update(renderViews(1)); |
| 245 | }); | 257 | }); |
| 246 | const pool = [...testState.pools.keys()][0]!; | 258 | const pool = [...testState.pools.keys()][0]!; |
| 247 | await act(async () => testState.pools.get(pool)); | 259 | await act(async () => testState.pools.get(pool)); |
| ... | @@ -252,25 +264,39 @@ describe("code-view worker lifecycle", () => { | ... | @@ -252,25 +264,39 @@ describe("code-view worker lifecycle", () => { |
| 252 | ); | 264 | ); |
| 253 | expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0); | 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 | await act(async () => pool.primeFileHighlightCache(files[1]!)); | 268 | await act(async () => pool.primeFileHighlightCache(files[1]!)); |
| 257 | expect(testState.pools.size).toBe(1); | 269 | expect(testState.pools.size).toBe(1); |
| 258 | expect(testState.workers).toHaveLength(2); | 270 | expect(testState.workers).toHaveLength(2); |
| 259 | expect(testState.renderPools).toEqual([pool, pool]); | 271 | expect(new Set(testState.renderPools)).toEqual(new Set([pool])); |
| 260 | expect(pool.getFileResultCache(files[1]!)).toBeDefined(); | 272 | expect(pool.getFileResultCache(files[1]!)).toBeDefined(); |
| 261 | expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain( | 273 | expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain( |
| 262 | "answer", | 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 | expect(pool.getStats().totalWorkers).toBe(2); | 282 | expect(pool.getStats().totalWorkers).toBe(2); |
| 267 | expect(testState.terminations).toHaveLength(0); | 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 | await Promise.all(testState.terminations); | 295 | await Promise.all(testState.terminations); |
| 270 | expect(pool.getStats().totalWorkers).toBe(0); | 296 | expect(pool.getStats().totalWorkers).toBe(0); |
| 271 | expect(testState.terminations).toHaveLength(2); | 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 | const reopenedPool = [...testState.pools.keys()][1]!; | 300 | const reopenedPool = [...testState.pools.keys()][1]!; |
| 275 | await act(async () => testState.pools.get(reopenedPool)); | 301 | await act(async () => testState.pools.get(reopenedPool)); |
| 276 | expect(reopenedPool.isInitialized()).toBe(true); | 302 | expect(reopenedPool.isInitialized()).toBe(true); |
| ... | @@ -285,7 +311,7 @@ describe("code-view worker lifecycle", () => { | ... | @@ -285,7 +311,7 @@ describe("code-view worker lifecycle", () => { |
| 285 | it("renders through Pierre's main-thread fallback when worker creation fails", async () => { | 311 | it("renders through Pierre's main-thread fallback when worker creation fails", async () => { |
| 286 | testState.failWorkers = true; | 312 | testState.failWorkers = true; |
| 287 | await act(async () => { | 313 | await act(async () => { |
| 288 | renderer = create(<Views count={1} />); | 314 | renderer = create(renderViews(1)); |
| 289 | }); | 315 | }); |
| 290 | const pool = [...testState.pools.keys()][0]!; | 316 | const pool = [...testState.pools.keys()][0]!; |
| 291 | await act(async () => { | 317 | await act(async () => { |
| ... | @@ -309,7 +335,7 @@ describe("code-view worker lifecycle", () => { | ... | @@ -309,7 +335,7 @@ describe("code-view worker lifecycle", () => { |
| 309 | testState.onInitializationHeld = resolve; | 335 | testState.onInitializationHeld = resolve; |
| 310 | }); | 336 | }); |
| 311 | await act(async () => { | 337 | await act(async () => { |
| 312 | renderer = create(<Views count={2} />); | 338 | renderer = create(renderViews(2)); |
| 313 | }); | 339 | }); |
| 314 | const pool = [...testState.pools.keys()][0]!; | 340 | const pool = [...testState.pools.keys()][0]!; |
| 315 | const pending = testState.pools.get(pool)!; | 341 | const pending = testState.pools.get(pool)!; |
| ... | @@ -319,10 +345,11 @@ describe("code-view worker lifecycle", () => { | ... | @@ -319,10 +345,11 @@ describe("code-view worker lifecycle", () => { |
| 319 | expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2); | 345 | expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2); |
| 320 | expect(testState.renderPools).toHaveLength(0); | 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 | expect(pool.getStats().totalWorkers).toBe(2); | 349 | expect(pool.getStats().totalWorkers).toBe(2); |
| 324 | expect(testState.terminations).toHaveLength(0); | 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 | await pending; | 353 | await pending; |
| 327 | await Promise.all(testState.terminations); | 354 | await Promise.all(testState.terminations); |
| 328 | await act(async () => { | 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,9 +450,92 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil |
| 450 | background-repeat: no-repeat; | 450 | background-repeat: no-repeat; |
| 451 | background-clip: text; | 451 | background-clip: text; |
| 452 | animation: live-tool-shine 2.2s steps(30) infinite; | 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 | @property --assistant-citation-highlight-opacity { | 539 | @property --assistant-citation-highlight-opacity { |
| 457 | syntax: "<number>"; | 540 | syntax: "<number>"; |
| 458 | inherits: true; | 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 | } | ||