authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 19:06:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:06:21-07:00
logce4712d5b04fb998f79fe132245289191147e5d5
tree188e6ccfd1fe9239a056400778cd098416820d20
parent940e8233c227a186044078e99e45e1933eb525e4
signature Signed by PGP key B5690EEEBB952194

fix: restore UX after performance improvements (#9799)

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 typecheck52 run: vpr typecheck
5353
54 - name: Install browser secret helper build libraries54 - name: Install browser secret helper build libraries
55 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config55 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
5658
57 - name: Build desktop pipeline59 - name: Build desktop pipeline
58 run: vp run build:desktop60 run: vp run build:desktop
...@@ -89,7 +91,9 @@ jobs:...@@ -89,7 +91,9 @@ jobs:
89 run: vp run --filter @t3tools/desktop ensure:electron91 run: vp run --filter @t3tools/desktop ensure:electron
9092
91 - name: Install browser secret helper build libraries93 - name: Install browser secret helper build libraries
92 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config94 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
9397
94 - name: Test98 - name: Test
95 run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test99 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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { startHomeMotion } from "./homeMotion";
4
5class 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
22let observers: ObserverStub[] = [];
23class 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
37let page = Object.assign(new EventTarget(), { visibilityState: "visible", activeElement: null });
38let viewport = new EventTarget();
39let reduced = Object.assign(new EventTarget(), { matches: false });
40let fine = Object.assign(new EventTarget(), { matches: true });
41let frames = new Map<number, FrameRequestCallback>();
42let dispose: (() => void) | undefined;
43
44beforeEach(() => {
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
69afterEach(() => {
70 dispose?.();
71 dispose = undefined;
72 vi.useRealTimers();
73 vi.unstubAllGlobals();
74});
75
76function 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
90function movePointer(hero: ElementStub, x = 400, y = 600) {
91 hero.dispatchEvent(Object.assign(new Event("pointermove"), { clientX: x, clientY: y }));
92}
93
94describe("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. */
2export 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({
355355
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";
358359
359 type Platform = { os: "mac" | "win" | "linux"; label: string };360 type Platform = { os: "mac" | "win" | "linux"; label: string };
360361
...@@ -419,6 +420,14 @@ const screenshot = await getImage({...@@ -419,6 +420,14 @@ const screenshot = await getImage({
419 }420 }
420421
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>
423432
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; }
592601
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 }
600611
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 }
609622
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 }
623639
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 }
640660
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; }
647667
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 }
651672
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 }
9921023
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);
166161
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 reusing34 * `prepareTurnAttachments` acquires pending uploads (verifying and reusing
35 * persisted ones), hands the uploaded ids back to the attachment's durable35 * persisted ones), hands the uploaded ids back to the attachment's durable
36 * owner (queued outbox message or composer draft), and returns a release36 * owner (queued outbox message or composer draft), and leaves their cleanup
37 * handle for after the turn consumed the bytes. Nothing outside this module37 * 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 is38 * mints or deletes pending uploads. The local-file side of the lifecycle is
39 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which39 * 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}
155153
156export type PrepareTurnAttachmentsResult =154export 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 });
311308
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 };
7474
75 // Resolves true when hydration completed; false when the read failed (the75 // Readable messages can be used after a partial load. Only a complete load
76 // next call retries). Destructive callers (the attachment sweep) must not76 // 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}
4646
47export interface ThreadOutboxLoadResult {
48 readonly messages: ReadonlyArray<QueuedThreadMessage>;
49 readonly errors: ReadonlyArray<ThreadOutboxStorageError>;
50}
51
47export interface ThreadOutboxStorage {52export 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) {
69export const expoThreadOutboxStorage: ThreadOutboxStorage = {74export 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 all89 // 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";
13const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());13const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());
1414
15vi.mock("expo-file-system", () => {15vi.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 }
1854
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", () => {
2662
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";
54import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager";84import { createThreadOutboxManager, ThreadOutboxManagerError } from "./thread-outbox-manager";
55import { expoThreadOutboxStorage, type ThreadOutboxStorage } from "./thread-outbox-storage";85import {
86 expoThreadOutboxStorage,
87 ThreadOutboxStorageError,
88 type ThreadOutboxLoadResult,
89 type ThreadOutboxStorage,
90} from "./thread-outbox-storage";
5691
57function queuedMessage(input: {92function queuedMessage(input: {
58 readonly environmentId?: string;93 readonly environmentId?: string;
...@@ -73,7 +108,7 @@ function queuedMessage(input: {...@@ -73,7 +108,7 @@ function queuedMessage(input: {
73108
74describe("thread outbox", () => {109describe("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 });
96139
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);
101168
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 );
106180
...@@ -138,6 +212,59 @@ describe("thread outbox", () => {...@@ -138,6 +212,59 @@ describe("thread outbox", () => {
138 });212 });
139 });213 });
140214
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}
2323
24export function ensureThreadOutboxLoaded(): void {
25 void threadOutboxManager.load();
26}
27
28export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {24export 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 });
17501750
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}));
5656
57vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
58
57vi.mock("expo-file-system", () => ({59vi.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 });
242241
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 });
247245
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 });
276271
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";
15import * as Cause from "effect/Cause";15import * as Cause from "effect/Cause";
16import { AsyncResult } from "effect/unstable/reactivity";16import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useRef, useState } from "react";17import { useCallback, useEffect, useRef, useState } from "react";
18import { Alert } from "react-native";
1819
19import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";20import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
20import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";21import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
...@@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities";...@@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities";
26import { serverEnvironment } from "./server";27import { serverEnvironment } from "./server";
27import {28import {
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 );
584584
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";
569571
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 = modifiedAt583 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 }
608613
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 });
9595
96 // Replace one coalescing batch atomically. Both raw and projected payloads96 // 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 } });
16941694
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 }
17151715
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");
17241724
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 );
17321732
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 );
19652005
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;
19722012
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);
19802020
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);
19882028
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 );
78687908
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 };
725725
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 );
898913
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" };
902917
903 // A completion marker is queued alongside raw live events so it cannot918 // A completion marker is queued alongside live event metadata so it cannot
904 // overtake an event still waiting in the coalescing window. Split each919 // 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> = [];
912927
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});
105105
106describe("ChatMarkdown streaming", () => {106describe("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 <SuspenseShikiCodeBlock2881 <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 @@
1import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";1import { WorkerPoolContext, useWorkerPool } from "@pierre/diffs/react";
2import { WorkerPoolManager } from "@pierre/diffs/worker";
2import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";3import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
3import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
4import { useEffect, useMemo, useState, type ReactNode } from "react";5import {
6 useCallback,
7 useEffect,
8 useMemo,
9 useState,
10 useSyncExternalStore,
11 type ReactNode,
12} from "react";
5import { useTheme } from "../hooks/useTheme";13import { useTheme } from "../hooks/useTheme";
6import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";14import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
7import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";15import { 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}
1826
27const DIFF_WORKER_IDLE_TTL_MS = 30_000;
28let 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. */
37function 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
19function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {67function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
20 const workerPool = useWorkerPool();68 const workerPool = useWorkerPool();
2169
...@@ -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.
50function DiffWorkerReady({ children }: { children?: ReactNode }) {98function 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";
55104
56 useEffect(() => {105 useEffect(() => {
57 if (ready || !workerPool) return;106 if (ready || !workerPool) return;
58107
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 );
90160
91 return (161 return (
92 <WorkerPoolContextProvider162 <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 @@
1import { Component, type ReactNode } from "react";1import { Component, type ReactNode } from "react";
22
3interface RenderErrorBoundaryProps {
4 readonly children: ReactNode;
5 readonly fallback: ReactNode;
6 readonly resetKeys?: ReadonlyArray<unknown>;
7}
8
9interface RenderErrorBoundaryState {
10 readonly failed: boolean;
11 readonly resetKeys?: ReadonlyArray<unknown> | undefined;
12}
13
3export class RenderErrorBoundary extends Component<14export 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 }
833
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 @@
1import { LoaderCircleIcon } from "lucide-react";1import { LoaderCircleIcon } from "lucide-react";
2import { observeVisibleAnimation } from "../../lib/visibleAnimation";
2import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";3import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
3import { ComposerBanner } from "./ComposerBanner";4import { ComposerBanner } from "./ComposerBanner";
45
5export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) {6export 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 <span13 <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";
2import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react";2import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react";
3import { useId, useState } from "react";3import { useId, useState } from "react";
44
5import { observeVisibleAnimation } from "../../lib/visibleAnimation";
5import { serverUpdateStageLabel } from "../ServerUpdateAction";6import { serverUpdateStageLabel } from "../ServerUpdateAction";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7import { ComposerBanner } from "./ComposerBanner";8import { 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) => {};
28import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";28import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
29import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";29import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
30import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";30import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
31import { observeVisibleAnimation } from "../../lib/visibleAnimation";
31import {32import {
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 <span1654 <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({
19191929
1920const workEntryKey = (entry: TimelineWorkEntry) => entry.id;1930const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
19211931
1932function 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
1922const failedToolIconClassName = "text-tool-error-icon/40";1945const failedToolIconClassName = "text-tool-error-icon/40";
19231946
1924/** Image icons and the gradient computer-use mark cannot take a currentColor1947/** 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 <LiveActivityContent1978 <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 <span2024 <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 muted2036 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";
9import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";9import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
10import {10import {
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}
151152
153function renderViews(count: number) {
154 return (
155 <StrictMode>
156 <Views count={count} />
157 </StrictMode>
158 );
159}
160
152describe("StyledDiffCodeView", () => {161describe("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 });
233245
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);
254266
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 );
264276
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);
272298
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);
321347
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}
455456
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 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { observeVisibleAnimation } from "./visibleAnimation";
4
5let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
6let motion = Object.assign(new EventTarget(), { matches: false });
7let observers: TestIntersectionObserver[] = [];
8let cleanups: Array<() => void> = [];
9
10class 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
27function 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
39function attach(element: HTMLElement) {
40 const cleanup = observeVisibleAnimation(element);
41 if (cleanup) cleanups.push(cleanup);
42 return cleanup;
43}
44
45beforeEach(() => {
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
55afterEach(() => {
56 for (const cleanup of cleanups) cleanup();
57 vi.unstubAllGlobals();
58 vi.restoreAllMocks();
59});
60
61describe("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 @@
1interface ObservedAnimation {
2 element: HTMLElement | SVGElement;
3 intersecting: boolean;
4}
5
6const animations = new Map<Element, ObservedAnimation>();
7let observer: IntersectionObserver | null = null;
8let reducedMotion: MediaQueryList | null = null;
9
10function 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
20function 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. */
25export 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}