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:
5252 run: vpr typecheck
5353
5454 - name: Install browser secret helper build libraries
55 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
55 run: |
56 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
57 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
5658
5759 - name: Build desktop pipeline
5860 run: vp run build:desktop
......@@ -89,7 +91,9 @@ jobs:
8991 run: vp run --filter @t3tools/desktop ensure:electron
9092
9193 - name: Install browser secret helper build libraries
92 run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
94 run: |
95 sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources
96 sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config
9397
9498 - name: Test
9599 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({
355355
356356<script>
357357 import { fetchLatestRelease, RELEASES_URL, type ReleaseAsset } from "../lib/releases";
358 import { startHomeMotion } from "../lib/homeMotion";
358359
359360 type Platform = { os: "mac" | "win" | "linux"; label: string };
360361
......@@ -419,6 +420,14 @@ const screenshot = await getImage({
419420 }
420421
421422 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 }
422431</script>
423432
424433<style>
......@@ -590,12 +599,14 @@ const screenshot = await getImage({
590599 :global([data-platform="linux"]) .dl-icon--linux { display: block; }
591600 :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. */
594603 .hero-float {
595604 position: absolute;
596605 inset: 0;
597606 pointer-events: none;
598607 z-index: 1;
608 --px: 0px;
609 --py: 0px;
599610 }
600611
601612 .hero-float-mark {
......@@ -605,6 +616,8 @@ const screenshot = await getImage({
605616 width: var(--s);
606617 height: var(--s);
607618 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);
608621 }
609622
610623 .hero-float-card {
......@@ -618,7 +631,10 @@ const screenshot = await getImage({
618631 box-shadow:
619632 0 20px 48px -16px rgba(0, 0, 0, 0.65),
620633 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);
622638 }
623639
624640 .hero-float-card img {
......@@ -637,16 +653,21 @@ const screenshot = await getImage({
637653 from { opacity: 0; transform: translate(var(--fx), var(--fy)) rotate(calc(var(--rot) + 24deg)) scale(0.6); }
638654 to { opacity: 1; transform: translate(0, 0) rotate(var(--rot)) scale(1); }
639655 }
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; }
642 .hf-antigravity { --x: min(300px, 19vw); --y: 20px; --s: 64px; --rot: -5deg; --d: 580ms; --fx: 40px; --fy: -80px; }
643 .hf-claude { --x: max(-560px, -38vw); --y: 128px; --s: 96px; --rot: -8deg; --d: 380ms; --fx: -160px; --fy: -40px; --tint: rgba(217, 119, 87, 0.22); }
644 .hf-codex { --x: min(560px, 38vw); --y: 128px; --s: 96px; --rot: 7deg; --d: 460ms; --fx: 160px; --fy: -40px; }
645 .hf-grok { --x: max(-440px, -30vw); --y: 440px; --s: 80px; --rot: 4deg; --d: 540ms; --fx: -120px; --fy: 80px; }
646 .hf-cursor { --x: min(440px, 30vw); --y: 440px; --s: 80px; --rot: -5deg; --d: 700ms; --fx: 120px; --fy: 80px; }
661 .hf-opencode { --x: max(-300px, -19vw); --y: 20px; --s: 64px; --rot: 6deg; --depth: 0.6; --d: 620ms; --dur: 8.5s; --dx: 2px; --dy: -8px; --wob: 3deg; --fx: -40px; --fy: -80px; }
662 .hf-antigravity { --x: min(300px, 19vw); --y: 20px; --s: 64px; --rot: -5deg; --depth: 0.6; --d: 580ms; --dur: 9.8s; --dx: -2px; --dy: -9px; --wob: -3deg; --fx: 40px; --fy: -80px; }
663 .hf-claude { --x: max(-560px, -38vw); --y: 128px; --s: 96px; --rot: -8deg; --depth: 1.2; --d: 380ms; --dur: 10.5s; --dx: 4px; --dy: -12px; --wob: -2deg; --fx: -160px; --fy: -40px; --tint: rgba(217, 119, 87, 0.22); }
664 .hf-codex { --x: min(560px, 38vw); --y: 128px; --s: 96px; --rot: 7deg; --depth: 1.2; --d: 460ms; --dur: 9.2s; --dx: -4px; --dy: -10px; --wob: 2deg; --fx: 160px; --fy: -40px; }
665 .hf-grok { --x: max(-440px, -30vw); --y: 440px; --s: 80px; --rot: 4deg; --depth: 0.9; --d: 540ms; --dur: 11.5s; --dx: 3px; --dy: -9px; --wob: -3deg; --fx: -120px; --fy: 80px; }
666 .hf-cursor { --x: min(440px, 30vw); --y: 440px; --s: 80px; --rot: -5deg; --depth: 0.9; --d: 700ms; --dur: 7.8s; --dx: -3px; --dy: -11px; --wob: 3deg; --fx: 120px; --fy: 80px; }
647667
648668 @media (prefers-reduced-motion: reduce) {
649669 .hero-float-card { animation: none; transform: rotate(var(--rot)); }
670 .hero-float-mark { translate: none; transition: none; }
650671 }
651672
652673 @media (max-width: 1180px) {
......@@ -988,6 +1009,16 @@ const screenshot = await getImage({
9881009 width: 7px; height: 14px;
9891010 background: var(--fg);
9901011 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; }
9911022 }
9921023
9931024 .open-pitch {
apps/mobile/src/features/threads/use-project-actions.ts+1-6
......@@ -156,13 +156,8 @@ export function useCreateProjectThread() {
156156 );
157157 return AsyncResult.failure(result.cause);
158158 }
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 });
164159 setPendingConnectionError(null);
165 scheduleUnusedComposerAttachmentCleanup(input.initialAttachments);
160 scheduleUnusedComposerAttachmentCleanup(prepared.draftAttachments);
166161
167162 return mapAtomCommandResult(result, () =>
168163 scopeThreadRef(input.project.environmentId, threadId),
apps/mobile/src/lib/attachmentUpload.ts+2-5
......@@ -33,8 +33,8 @@ import { uuidv4 } from "./uuid";
3333 * This module owns the server side of a composer attachment's lifecycle.
3434 * `prepareTurnAttachments` acquires pending uploads (verifying and reusing
3535 * persisted ones), hands the uploaded ids back to the attachment's durable
36 * owner (queued outbox message or composer draft), and returns a release
37 * handle for after the turn consumed the bytes. Nothing outside this module
36 * owner (queued outbox message or composer draft), and leaves their cleanup
37 * to that owner after it checks shared references. Nothing outside this module
3838 * mints or deletes pending uploads. The local-file side of the lifecycle is
3939 * owned by `removeThreadOutboxMessage` / the composer draft mutators, which
4040 * release files through `releaseUnusedComposerAttachmentFiles`.
......@@ -149,8 +149,6 @@ export interface PreparedTurnAttachments {
149149 readonly draftAttachments: ReadonlyArray<DraftComposerAttachment>;
150150 /** Every pending upload backing this turn (reused and newly minted). */
151151 readonly pendingAttachmentIds: ReadonlyArray<string>;
152 /** Deletes all pending uploads once the delivered turn holds the bytes. */
153 readonly releaseUploads: () => Promise<void>;
154152}
155153
156154export type PrepareTurnAttachmentsResult =
......@@ -306,7 +304,6 @@ export async function prepareTurnAttachments(input: {
306304 attachments,
307305 draftAttachments,
308306 pendingAttachmentIds,
309 releaseUploads: () => releasePendingAttachmentUploads(environmentId, pendingAttachmentIds),
310307 });
311308
312309 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 () => {
1919 harness.manager = createThreadOutboxManager({
2020 registry: appAtomRegistry,
2121 storage: {
22 load: async () => [],
22 load: async () => ({ messages: [], errors: [] }),
2323 write: async (message) => {
2424 const pending = harness.writeGates.shift();
2525 if (pending) {
apps/mobile/src/state/thread-outbox-manager.ts+31-13
......@@ -72,16 +72,26 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
7272 options.registry.set(queuedMessagesByThreadKeyAtom, groupQueuedThreadMessages(messages));
7373 };
7474
75 // Resolves true when hydration completed; false when the read failed (the
76 // next call retries). Destructive callers (the attachment sweep) must not
77 // treat a failed hydration as an empty queue.
75 // Readable messages can be used after a partial load. Only a complete load
76 // returns true, so cleanup cannot delete files owned by unreadable records.
77 // A later call retries failed reads without replacing live message objects.
7878 const load = (): Promise<boolean> => {
7979 if (loadPromise !== null) {
8080 return loadPromise;
8181 }
8282 loadPromise = serialize(async () => {
83 const persistedMessages = await options.storage.load();
84 setMessages([...persistedMessages, ...currentMessages()]);
83 const result = await options.storage.load();
84 const current = currentMessages();
85 const currentIds = new Set(current.map((message) => message.messageId));
86 const recovered = result.messages.filter(
87 (message) => !currentIds.has(message.messageId) && !revisions.has(message.messageId),
88 );
89 // Accepted edits and removals win over a later disk read. Retaining
90 // current objects also keeps retries from restarting the drain.
91 if (recovered.length > 0) setMessages([...recovered, ...current]);
92 if (result.errors.length > 0) {
93 throw new AggregateError(result.errors, "Some queued messages could not be read.");
94 }
8595 return true;
8696 }).catch((cause) => {
8797 loadPromise = null;
......@@ -275,15 +285,23 @@ export function createThreadOutboxManager(options: ThreadOutboxManagerOptions) {
275285 // changes after this request must not enter the clear set.
276286 const revisionsAtRequest = new Map(revisions);
277287 return serialize(async () => {
278 const persisted = await options.storage.load().catch((cause) => {
279 throw new ThreadOutboxManagerError({
280 operation: "clear-environment-load",
281 environmentId,
282 threadId: null,
283 messageId: null,
284 cause,
288 const persisted = await options.storage
289 .load()
290 .then((result) => {
291 if (result.errors.length > 0) {
292 throw new AggregateError(result.errors, "Some queued messages could not be read.");
293 }
294 return result.messages;
295 })
296 .catch((cause) => {
297 throw new ThreadOutboxManagerError({
298 operation: "clear-environment-load",
299 environmentId,
300 threadId: null,
301 messageId: null,
302 cause,
303 });
285304 });
286 });
287305 const allMessages = flattenQueuedThreadMessages(
288306 groupQueuedThreadMessages([...persisted, ...currentMessages()]),
289307 );
apps/mobile/src/state/thread-outbox-removal.test.ts+1-1
......@@ -19,7 +19,7 @@ vi.mock("./thread-outbox", async () => {
1919 harness.manager = createThreadOutboxManager({
2020 registry: appAtomRegistry,
2121 storage: {
22 load: async () => [],
22 load: async () => ({ messages: [], errors: [] }),
2323 write: async () => undefined,
2424 remove: async () => undefined,
2525 },
apps/mobile/src/state/thread-outbox-storage.ts+20-13
......@@ -44,8 +44,13 @@ export class ThreadOutboxStorageError extends Schema.TaggedErrorClass<ThreadOutb
4444 }
4545}
4646
47export interface ThreadOutboxLoadResult {
48 readonly messages: ReadonlyArray<QueuedThreadMessage>;
49 readonly errors: ReadonlyArray<ThreadOutboxStorageError>;
50}
51
4752export interface ThreadOutboxStorage {
48 readonly load: () => Promise<ReadonlyArray<QueuedThreadMessage>>;
53 readonly load: () => Promise<ThreadOutboxLoadResult>;
4954 readonly write: (message: QueuedThreadMessage) => Promise<void>;
5055 readonly remove: (message: QueuedThreadMessage) => Promise<void>;
5156}
......@@ -69,6 +74,7 @@ async function getMessageFile(messageId: MessageId) {
6974export const expoThreadOutboxStorage: ThreadOutboxStorage = {
7075 load: async () => {
7176 const messages: QueuedThreadMessage[] = [];
77 const errors: ThreadOutboxStorageError[] = [];
7278 try {
7379 const { File } = await import("expo-file-system");
7480 const directory = await getOutboxDirectory();
......@@ -80,20 +86,21 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
8086 try {
8187 messages.push(decodeQueuedThreadMessage(JSON.parse(await entry.text()) as unknown));
8288 } catch (cause) {
83 // A partial queue hides attachment owners from cleanup. Keep all
84 // records untouched until every persisted message can be read.
85 throw new ThreadOutboxStorageError({
86 operation: "read-message",
87 environmentId: null,
88 threadId: null,
89 messageId: null,
90 fileName: entry.name,
91 cause,
92 });
89 // Recover readable messages without treating their attachment
90 // owners as the complete inventory needed for cleanup.
91 errors.push(
92 new ThreadOutboxStorageError({
93 operation: "read-message",
94 environmentId: null,
95 threadId: null,
96 messageId: null,
97 fileName: entry.name,
98 cause,
99 }),
100 );
93101 }
94102 }
95103 } catch (cause) {
96 if (cause instanceof ThreadOutboxStorageError) throw cause;
97104 throw new ThreadOutboxStorageError({
98105 operation: "load",
99106 environmentId: null,
......@@ -103,7 +110,7 @@ export const expoThreadOutboxStorage: ThreadOutboxStorage = {
103110 cause,
104111 });
105112 }
106 return messages;
113 return { messages, errors };
107114 },
108115 write: async (message) => {
109116 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";
1313const outboxFiles = vi.hoisted(() => new Map<string, string | Error>());
1414
1515vi.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
1624 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
1955 async text(): Promise<string> {
2056 const contents = outboxFiles.get(this.name);
......@@ -26,13 +62,7 @@ vi.mock("expo-file-system", () => {
2662
2763 return {
2864 File,
29 Directory: class {
30 create() {}
31
32 list() {
33 return Array.from(outboxFiles.keys(), (name) => new File(name));
34 }
35 },
65 Directory,
3666 Paths: { document: "/documents" },
3767 };
3868});
......@@ -52,7 +82,12 @@ import {
5282 type QueuedThreadMessage,
5383} from "./thread-outbox-model";
5484import { 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
5792function queuedMessage(input: {
5893 readonly environmentId?: string;
......@@ -73,7 +108,7 @@ function queuedMessage(input: {
73108
74109describe("thread outbox", () => {
75110 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",
77112 async (failure) => {
78113 onTestFinished(() => outboxFiles.clear());
79114 const first = queuedMessage({
......@@ -81,10 +116,11 @@ describe("thread outbox", () => {
81116 createdAt: "2026-06-08T10:00:01.000Z",
82117 });
83118 const second = queuedMessage({
119 environmentId: "environment-2",
84120 messageId: "message-2",
85121 createdAt: "2026-06-08T10:00:02.000Z",
86122 });
87 outboxFiles.set("message-1.json", JSON.stringify(encodeQueuedThreadMessage(first)));
123 // Put the unreadable record first to check that later records still load.
88124 outboxFiles.set(
89125 "message-2.json",
90126 failure === "read"
......@@ -93,14 +129,52 @@ describe("thread outbox", () => {
93129 ? "{"
94130 : JSON.stringify({ ...second, schemaVersion: 999 }),
95131 );
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({
98 operation: "read-message",
99 fileName: "message-2.json",
140 const registry = AtomRegistry.make();
141 onTestFinished(() => registry.dispose());
142 const manager = createThreadOutboxManager({
143 registry,
144 storage: expoThreadOutboxStorage,
145 warn: () => {},
146 });
147 await expect(manager.load()).resolves.toBe(false);
148 const recovered = registry.get(manager.queuedMessagesByThreadKeyAtom)[
149 "environment-1:thread-1"
150 ]![0]!;
151 expect(recovered).toEqual(first);
152 await expect(manager.confirmQueued(recovered)).resolves.toBe(true);
153 const edited = { ...recovered, text: "Edited after recovery" };
154 await expect(manager.update(edited)).resolves.toBe(true);
155 const beforeRetry = registry.get(manager.queuedMessagesByThreadKeyAtom);
156 await expect(manager.load()).resolves.toBe(false);
157 expect(registry.get(manager.queuedMessagesByThreadKeyAtom)).toBe(beforeRetry);
158 await expect(manager.clearEnvironment(first.environmentId)).rejects.toMatchObject({
159 operation: "clear-environment-load",
100160 });
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
102169 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 });
104178 },
105179 );
106180
......@@ -138,6 +212,59 @@ describe("thread outbox", () => {
138212 });
139213 });
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
141268 it("groups messages by scoped thread and preserves creation order", () => {
142269 const later = queuedMessage({
143270 messageId: "message-2",
......@@ -335,7 +462,7 @@ describe("thread outbox", () => {
335462 const manager = createThreadOutboxManager({
336463 registry,
337464 storage: {
338 load: async () => [],
465 load: async () => ({ messages: [], errors: [] }),
339466 write: async () => undefined,
340467 remove: async () => undefined,
341468 },
......@@ -382,7 +509,7 @@ describe("thread outbox", () => {
382509 if (loadCalls === 1) {
383510 await initialLoadBlocked;
384511 }
385 return [...stored.values()];
512 return { messages: [...stored.values()], errors: [] };
386513 },
387514 write: async () => undefined,
388515 remove: async (candidate) => {
......@@ -418,7 +545,7 @@ describe("thread outbox", () => {
418545 load: async () => {
419546 loadCalls += 1;
420547 if (loadCalls === 1) throw loadCause;
421 return [];
548 return { messages: [], errors: [] };
422549 },
423550 write: async () => undefined,
424551 remove: async () => undefined,
......@@ -451,7 +578,7 @@ describe("thread outbox", () => {
451578 const removalCause = new Error("remove failed");
452579 let failRemoval = true;
453580 const storage: ThreadOutboxStorage = {
454 load: async () => [...stored.values()],
581 load: async () => ({ messages: [...stored.values()], errors: [] }),
455582 write: async (message) => {
456583 stored.set(message.messageId, message);
457584 },
......@@ -501,7 +628,7 @@ describe("thread outbox", () => {
501628 const manager = createThreadOutboxManager({
502629 registry,
503630 storage: {
504 load: async () => [],
631 load: async () => ({ messages: [], errors: [] }),
505632 write: async () => writeBlocked,
506633 remove: async () => undefined,
507634 },
......@@ -530,7 +657,7 @@ describe("thread outbox", () => {
530657 const manager = createThreadOutboxManager({
531658 registry,
532659 storage: {
533 load: async () => [],
660 load: async () => ({ messages: [], errors: [] }),
534661 write: async () => {
535662 throw writeCause;
536663 },
......@@ -561,7 +688,7 @@ describe("thread outbox", () => {
561688 const manager = createThreadOutboxManager({
562689 registry,
563690 storage: {
564 load: async () => [],
691 load: async () => ({ messages: [], errors: [] }),
565692 write: async () => {
566693 throw new Error("disk full");
567694 },
......@@ -593,7 +720,7 @@ describe("thread outbox", () => {
593720 const manager = createThreadOutboxManager({
594721 registry,
595722 storage: {
596 load: async () => [],
723 load: async () => ({ messages: [], errors: [] }),
597724 write: async () => {
598725 if (failNextWrite) {
599726 failNextWrite = false;
......@@ -630,7 +757,7 @@ describe("thread outbox", () => {
630757 const manager = createThreadOutboxManager({
631758 registry,
632759 storage: {
633 load: async () => [],
760 load: async () => ({ messages: [], errors: [] }),
634761 write: async () => undefined,
635762 remove: async () => undefined,
636763 },
......@@ -654,7 +781,7 @@ describe("thread outbox", () => {
654781 const registry = AtomRegistry.make();
655782 const stored = new Map<MessageId, QueuedThreadMessage>();
656783 const storage: ThreadOutboxStorage = {
657 load: async () => [...stored.values()],
784 load: async () => ({ messages: [...stored.values()], errors: [] }),
658785 write: async (message) => {
659786 stored.set(message.messageId, message);
660787 },
......@@ -689,7 +816,7 @@ describe("thread outbox", () => {
689816 const manager = createThreadOutboxManager({
690817 registry,
691818 storage: {
692 load: async () => [],
819 load: async () => ({ messages: [], errors: [] }),
693820 write: async (message) => {
694821 writes.push(message.text);
695822 },
......@@ -733,7 +860,7 @@ describe("thread outbox", () => {
733860 const manager = createThreadOutboxManager({
734861 registry,
735862 storage: {
736 load: async () => [],
863 load: async () => ({ messages: [], errors: [] }),
737864 write: async (message) => {
738865 writes.push(message.text);
739866 if (message.text === "stale upload") {
......@@ -777,7 +904,7 @@ describe("thread outbox", () => {
777904 const manager = createThreadOutboxManager({
778905 registry,
779906 storage: {
780 load: async () => [...stored.values()],
907 load: async () => ({ messages: [...stored.values()], errors: [] }),
781908 write: async (message) => {
782909 stored.set(message.messageId, message);
783910 },
......@@ -826,7 +953,7 @@ describe("thread outbox", () => {
826953 const manager = createThreadOutboxManager({
827954 registry,
828955 storage: {
829 load: async () => [...stored.values()],
956 load: async () => ({ messages: [...stored.values()], errors: [] }),
830957 write: async (message) => {
831958 if (message === retried) {
832959 replacementWriteStarted.resolve();
......@@ -876,7 +1003,7 @@ describe("thread outbox", () => {
8761003 const manager = createThreadOutboxManager({
8771004 registry,
8781005 storage: {
879 load: async () => [...stored.values()],
1006 load: async () => ({ messages: [...stored.values()], errors: [] }),
8801007 write: async (message) => {
8811008 stored.set(message.messageId, message);
8821009 },
......@@ -915,7 +1042,7 @@ describe("thread outbox", () => {
9151042 const manager = createThreadOutboxManager({
9161043 registry,
9171044 storage: {
918 load: async () => [...stored.values()],
1045 load: async () => ({ messages: [...stored.values()], errors: [] }),
9191046 write: async (message) => {
9201047 stored.set(message.messageId, message);
9211048 },
......@@ -979,7 +1106,7 @@ describe("thread outbox", () => {
9791106 const manager = createThreadOutboxManager({
9801107 registry,
9811108 storage: {
982 load: async () => [...stored.values()],
1109 load: async () => ({ messages: [...stored.values()], errors: [] }),
9831110 write: async (message) => {
9841111 stored.set(message.messageId, message);
9851112 },
......@@ -1022,7 +1149,7 @@ describe("thread outbox", () => {
10221149 const manager = createThreadOutboxManager({
10231150 registry,
10241151 storage: {
1025 load: async () => [...stored.values()],
1152 load: async () => ({ messages: [...stored.values()], errors: [] }),
10261153 write: async (message) => {
10271154 stored.set(message.messageId, message);
10281155 },
apps/mobile/src/state/thread-outbox.ts-4
......@@ -21,10 +21,6 @@ export async function flushThreadOutbox(): Promise<void> {
2121 await flushThreadOutboxWrites();
2222}
2323
24export function ensureThreadOutboxLoaded(): void {
25 void threadOutboxManager.load();
26}
27
2824export function enqueueThreadOutboxMessage(message: QueuedThreadMessage): Promise<void> {
2925 return threadOutboxManager.enqueue(message);
3026}
apps/mobile/src/state/use-composer-drafts.test.ts+86-1
......@@ -1748,7 +1748,92 @@ describe("mobile composer drafts", () => {
17481748 expect(composerAttachmentCleanupMocks.remove.mock.calls).toEqual([[first.fileUri]]);
17491749 });
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
17521837 it("hydrates persisted drafts before a cold-start sweep deletes their files", async () => {
17531838 const file = {
17541839 id: "file-cold-start",
apps/mobile/src/state/use-thread-outbox-drain.test.ts+3-9
......@@ -54,6 +54,8 @@ const harness = vi.hoisted(() => ({
5454 })(),
5555}));
5656
57vi.mock("react-native", () => ({ Alert: { alert: vi.fn() } }));
58
5759vi.mock("expo-file-system", () => ({
5860 Directory: harness.draftFile.Directory,
5961 File: harness.draftFile.File,
......@@ -113,7 +115,7 @@ vi.mock("./thread-outbox", async () => {
113115 harness.manager = createThreadOutboxManager({
114116 registry: appAtomRegistry,
115117 storage: {
116 load: async () => [],
118 load: async () => ({ messages: [], errors: [] }),
117119 write: async () => undefined,
118120 remove: (message) => harness.removeOutboxMessage(message),
119121 },
......@@ -122,7 +124,6 @@ vi.mock("./thread-outbox", async () => {
122124 return {
123125 threadOutboxManager: manager,
124126 flushThreadOutbox: async () => undefined,
125 ensureThreadOutboxLoaded: () => undefined,
126127 confirmThreadOutboxMessageQueued: (message: never) => manager.confirmQueued(message),
127128 updateThreadOutboxMessage: (message: never, expectedRevision?: number) =>
128129 manager.update(message, expectedRevision),
......@@ -219,7 +220,6 @@ describe("thread outbox attachment preparation", () => {
219220 );
220221 const preparationStarted = Promise.withResolvers<void>();
221222 const preparationBarrier = Promise.withResolvers<PreparedTurnAttachments>();
222 const releaseUploads = vi.fn(async () => undefined);
223223 harness.prepareTurnAttachments.mockImplementationOnce(async () => {
224224 preparationStarted.resolve();
225225 return preparationBarrier.promise;
......@@ -237,12 +237,10 @@ describe("thread outbox attachment preparation", () => {
237237 attachments: [],
238238 draftAttachments: message.attachments,
239239 pendingAttachmentIds: ["pending-reused-upload"],
240 releaseUploads,
241240 });
242241
243242 await expect(preparation).resolves.toEqual({ status: "abandoned" });
244243 expect(remainingMessages()).toEqual([edited]);
245 expect(releaseUploads).not.toHaveBeenCalled();
246244 });
247245
248246 it("keeps an unchanged queued payload ready after attachment reuse", async () => {
......@@ -254,13 +252,11 @@ describe("thread outbox attachment preparation", () => {
254252 }),
255253 "pending-reused-upload",
256254 );
257 const releaseUploads = vi.fn(async () => undefined);
258255 harness.prepareTurnAttachments.mockResolvedValueOnce({
259256 status: "ready",
260257 attachments: [],
261258 draftAttachments: message.attachments,
262259 pendingAttachmentIds: ["pending-reused-upload"],
263 releaseUploads,
264260 });
265261 await harness.manager.enqueue(message);
266262 const revision = harness.manager.revisionOf(message.messageId);
......@@ -271,7 +267,6 @@ describe("thread outbox attachment preparation", () => {
271267 persistedMessage: message,
272268 deliveryRevision: revision,
273269 });
274 expect(releaseUploads).not.toHaveBeenCalled();
275270 });
276271
277272 it("uses the known next revision after persisting uploaded references", async () => {
......@@ -296,7 +291,6 @@ describe("thread outbox attachment preparation", () => {
296291 attachments: [],
297292 draftAttachments: uploadedAttachments,
298293 pendingAttachmentIds: ["pending-new-upload"],
299 releaseUploads: async () => undefined,
300294 };
301295 });
302296 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";
1515import * as Cause from "effect/Cause";
1616import { AsyncResult } from "effect/unstable/reactivity";
1717import { useCallback, useEffect, useRef, useState } from "react";
18import { Alert } from "react-native";
1819
1920import { scopedProjectKey, scopedThreadKey } from "../lib/scopedEntities";
2021import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
......@@ -26,7 +27,6 @@ import { useProjects, useServerConfigs, useThreadShells } from "./entities";
2627import { serverEnvironment } from "./server";
2728import {
2829 confirmThreadOutboxMessageQueued,
29 ensureThreadOutboxLoaded,
3030 threadOutboxManager,
3131 threadOutboxRevision,
3232 updateThreadOutboxMessage,
......@@ -583,8 +583,21 @@ export function useThreadOutboxDrain(): void {
583583 );
584584
585585 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();
587599 return () => {
600 mounted = false;
588601 for (const timer of retryTimersRef.current.values()) {
589602 clearTimeout(timer);
590603 }
......@@ -766,12 +779,6 @@ export function useThreadOutboxDrain(): void {
766779 (await completeQueuedMessageDelivery(persistedMessage, deliveryRevision)) === "removed";
767780 if (delivered) {
768781 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 });
775782 }
776783 return delivered;
777784 },
......@@ -904,13 +911,7 @@ export function useThreadOutboxDrain(): void {
904911 // payload as a duplicate creation. Hand it to the thread's composer.
905912 return recoverEditedCreationAfterDelivery(persistedMessage);
906913 }
907 if (outcome === "removed") {
908 await prepared.releaseUploads().catch((error) => {
909 console.warn("[thread-outbox] could not delete consumed pending uploads", error);
910 });
911 return true;
912 }
913 return false;
914 return outcome === "removed";
914915 },
915916 [makeDeliveryHelpers, restoreQueuedMessage, startTurn],
916917 );
apps/server/src/http.ts+9-7
......@@ -566,15 +566,20 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
566566 }
567567 }
568568 const fileInfo = opened.info;
569 const mimeType = Mime.getType(filePath) ?? "application/octet-stream";
570 const isHtml = mimeType === "text/html";
569571
570572 // A hash-like name is not enough: custom static files can use the same naming pattern.
571573 const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/");
572574 const immutable =
573 /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && immutableBuildAssets.has(relativePath);
575 !isHtml &&
576 /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) &&
577 immutableBuildAssets.has(relativePath);
574578 const headers: Record<string, string> = {
575579 "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
576580 };
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);
578583 const etag = modifiedAt
579584 ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"`
580585 : undefined;
......@@ -599,17 +604,14 @@ const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
599604 : ifModifiedSince !== undefined &&
600605 modifiedAt !== undefined &&
601606 Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince);
602 if (unchanged) {
607 if (!isHtml && unchanged) {
603608 return HttpServerResponse.empty({
604609 status: 304,
605610 headers: { ...headers, Vary: "Accept-Encoding" },
606611 });
607612 }
608613
609 const contentType =
610 path.extname(filePath) === ".html"
611 ? "text/html; charset=utf-8"
612 : (Mime.getType(filePath) ?? "application/octet-stream");
614 const contentType = isHtml ? "text/html; charset=utf-8" : mimeType;
613615 // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation.
614616 // HEAD still passes through compression, which selects headers without reading the stream.
615617 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*
9393 return Effect.succeed(item);
9494 });
9595
96 // Replace one coalescing batch atomically. Both raw and projected payloads
96 // Replace one coalescing batch atomically. Queued and coalesced payloads
9797 // count against the same budget, and discarded updates release their charge.
9898 const replace = <A extends object>(
9999 previous: ReadonlyArray<RetainedLiveItem<unknown>>,
apps/server/src/orchestration/ThreadLiveEventCoalescer.ts+3-2
......@@ -130,7 +130,7 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales
130130 pendingUpdates,
131131 coalesceLiveToolUpdatedEvents(pendingUpdates.map((item) => item.value)).map((event) => ({
132132 kind: "event" as const,
133 event: projectActivityEvent(event),
133 event,
134134 })),
135135 (item) => item.event,
136136 );
......@@ -167,7 +167,8 @@ export const makeThreadLiveEventCoalescer = Effect.fn("makeThreadLiveEventCoales
167167 Effect.gen(function* () {
168168 yield* budget.check;
169169 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(
171172 Effect.tap((item) => Effect.sync(() => pendingUpdates.push(item))),
172173 Effect.uninterruptible,
173174 );
apps/server/src/server.test.ts+153-15
......@@ -1688,14 +1688,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
16881688 const fileSystem = yield* FileSystem.FileSystem;
16891689 const path = yield* Path.Path;
16901690 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" });
1691 const indexPath = path.join(staticDir, "index.html");
1692 yield* fileSystem.writeFileString(indexPath, "<html>first build</html>");
1691 const assetPath = path.join(staticDir, "app.js");
1692 yield* fileSystem.writeFileString(assetPath, 'export const build = "first";');
16931693 yield* buildAppUnderTest({ config: { staticDir } });
16941694
1695 const initial = yield* HttpClient.get("/");
1695 const initial = yield* HttpClient.get("/app.js");
16961696 assert.equal(initial.status, 200);
16971697 assert.equal(initial.headers["cache-control"], "no-cache");
1698 assert.include(yield* initial.text, "first build");
1698 assert.include(yield* initial.text, "first");
16991699 const etag = initial.headers.etag;
17001700 assert.isDefined(etag);
17011701 assert.isDefined(initial.headers["last-modified"]);
......@@ -1706,30 +1706,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
17061706 { "if-none-match": "*" },
17071707 { "if-modified-since": initial.headers["last-modified"]! },
17081708 ]) {
1709 const response = yield* HttpClient.get("/", { headers });
1709 const response = yield* HttpClient.get("/app.js", { headers });
17101710 assert.equal(response.status, 304);
17111711 assert.equal(response.headers.etag, etag);
17121712 assert.equal(response.headers["cache-control"], "no-cache");
17131713 assert.equal(yield* response.text, "");
17141714 }
17151715
1716 const mismatched = yield* HttpClient.get("/", {
1716 const mismatched = yield* HttpClient.get("/app.js", {
17171717 headers: {
17181718 "if-none-match": '"another-build"',
17191719 "if-modified-since": initial.headers["last-modified"]!,
17201720 },
17211721 });
17221722 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>");
1726 const changed = yield* HttpClient.get("/", { headers: { "if-none-match": etag! } });
1725 yield* fileSystem.writeFileString(assetPath, 'export const build = "the next build";');
1726 const changed = yield* HttpClient.get("/app.js", { headers: { "if-none-match": etag! } });
17271727 assert.equal(changed.status, 200);
17281728 assert.notEqual(changed.headers.etag, etag);
17291729 assert.include(yield* changed.text, "next build");
17301730 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
17311731 );
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
17331773 it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () =>
17341774 Effect.gen(function* () {
17351775 const fileSystem = yield* FileSystem.FileSystem;
......@@ -1917,8 +1957,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19171957 const fileSystem = yield* FileSystem.FileSystem;
19181958 const path = yield* Path.Path;
19191959 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" });
1920 const filePath = path.join(staticDir, "index.html");
1921 const body = "<p>file content</p>".repeat(1024);
1960 const filePath = path.join(staticDir, "app.txt");
1961 const body = "file content\n".repeat(1024);
19221962 yield* fileSystem.writeFileString(filePath, body);
19231963 const closed = yield* Queue.unbounded<FileSystem.File>();
19241964 const blocked = yield* Deferred.make<void>();
......@@ -1963,14 +2003,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19632003 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
19642004 );
19652005
1966 const get = yield* HttpClient.get("/");
2006 const get = yield* HttpClient.get("/app.txt");
19672007 assert.equal(yield* get.text, body);
19682008 yield* Queue.take(closed);
19692009 assert.equal(active.size, 0);
19702010 assert.isAbove(bodyReads, 0);
19712011 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" } });
19742014 assert.equal(head.status, 200);
19752015 assert.equal(head.headers["content-encoding"], "gzip");
19762016 assert.equal(yield* head.text, "");
......@@ -1978,7 +2018,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19782018 assert.equal(active.size, 0);
19792019 assert.equal(bodyReads, readsAfterGet);
19802020
1981 const unchanged = yield* HttpClient.get("/", {
2021 const unchanged = yield* HttpClient.get("/app.txt", {
19822022 headers: { "if-none-match": get.headers.etag! },
19832023 });
19842024 assert.equal(unchanged.status, 304);
......@@ -1987,7 +2027,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
19872027 assert.equal(bodyReads, readsAfterGet);
19882028
19892029 blockAfterOpen = true;
1990 const cancelled = yield* HttpClient.get("/").pipe(Effect.forkChild);
2030 const cancelled = yield* HttpClient.get("/app.txt").pipe(Effect.forkChild);
19912031 yield* Deferred.await(blocked);
19922032 assert.equal(active.size, 1);
19932033 yield* Fiber.interrupt(cancelled);
......@@ -7866,6 +7906,104 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
78667906 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
78677907 );
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
78698007 it.effect("coalesces buffered live tool updates to the latest state", () =>
78708008 Effect.gen(function* () {
78718009 const thread = makeDefaultOrchestrationReadModel().threads[0]!;
apps/server/src/ws.ts+27-11
......@@ -38,7 +38,7 @@ import {
3838 OrchestrationSearchThreadsError,
3939 OrchestrationGetTurnDiffError,
4040 ORCHESTRATION_WS_METHODS,
41 type ProjectId,
41 ProjectId,
4242 type ProjectEntriesFailure,
4343 type ProjectFileFailure,
4444 type ProjectFileOperation,
......@@ -723,19 +723,33 @@ const makeWsRpcLayer = (
723723 });
724724 };
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
726740 const toShellStreamEvent = (
727 event: OrchestrationEvent,
741 event: ShellEvent,
728742 ): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => {
729743 switch (event.type) {
730744 case "project.created":
731745 case "project.meta-updated":
732 return projectUpsertOrRemove(event.payload.projectId, event.sequence);
746 return projectUpsertOrRemove(ProjectId.make(event.aggregateId), event.sequence);
733747 case "project.deleted":
734748 return Effect.succeed(
735749 Option.some({
736750 kind: "project-removed" as const,
737751 sequence: event.sequence,
738 projectId: event.payload.projectId,
752 projectId: ProjectId.make(event.aggregateId),
739753 }),
740754 );
741755 case "thread.deleted":
......@@ -744,11 +758,11 @@ const makeWsRpcLayer = (
744758 Option.some({
745759 kind: "thread-removed" as const,
746760 sequence: event.sequence,
747 threadId: event.payload.threadId,
761 threadId: ThreadId.make(event.aggregateId),
748762 }),
749763 );
750764 case "thread.unarchived":
751 return threadUpsertOrRemove(event.payload.threadId, event.sequence);
765 return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence);
752766 default:
753767 if (event.aggregateKind !== "thread") {
754768 return Effect.succeed(Option.none());
......@@ -862,13 +876,13 @@ const makeWsRpcLayer = (
862876 // item. The refetch runs with bounded concurrency (order-preserving).
863877 const SHELL_REFETCH_CONCURRENCY = 8;
864878 const coalesceShellEvents = (
865 events: ReadonlyArray<OrchestrationEvent>,
879 events: ReadonlyArray<ShellEvent>,
866880 ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamEvent>, never, never> =>
867881 Effect.gen(function* () {
868882 if (events.length === 0) {
869883 return [];
870884 }
871 const latestByAggregate = new Map<string, OrchestrationEvent>();
885 const latestByAggregate = new Map<string, ShellEvent>();
872886 for (const event of events) {
873887 latestByAggregate.set(`${event.aggregateKind}:${event.aggregateId}`, event);
874888 }
......@@ -891,16 +905,17 @@ const makeWsRpcLayer = (
891905 stream: Stream.Stream<OrchestrationEvent, E, R>,
892906 ): Stream.Stream<OrchestrationShellStreamEvent, E, R> =>
893907 stream.pipe(
908 Stream.map(toShellEvent),
894909 Stream.groupedWithin(SHELL_COALESCE_MAX_CHUNK, SHELL_COALESCE_WINDOW),
895910 Stream.mapEffect(coalesceShellEvents),
896911 Stream.flatMap((items) => Stream.fromIterable(items)),
897912 );
898913
899914 type ShellLiveInput =
900 | { readonly kind: "event"; readonly event: OrchestrationEvent }
915 | { readonly kind: "event"; readonly event: ShellEvent }
901916 | { readonly kind: "synchronized" };
902917
903 // A completion marker is queued alongside raw live events so it cannot
918 // A completion marker is queued alongside live event metadata so it cannot
904919 // overtake an event still waiting in the coalescing window. Split each
905920 // batch at markers and coalesce only the event segments on either side.
906921 const coalesceShellLiveInputs = (
......@@ -908,7 +923,7 @@ const makeWsRpcLayer = (
908923 ): Effect.Effect<ReadonlyArray<OrchestrationShellStreamItem>, never, never> =>
909924 Effect.gen(function* () {
910925 const output: Array<OrchestrationShellStreamItem> = [];
911 let pendingEvents: Array<OrchestrationEvent> = [];
926 let pendingEvents: Array<ShellEvent> = [];
912927
913928 for (const input of inputs) {
914929 if (input.kind === "event") {
......@@ -1430,6 +1445,7 @@ const makeWsRpcLayer = (
14301445 );
14311446 yield* Effect.forkScoped(
14321447 orchestrationEngine.streamDomainEvents.pipe(
1448 Stream.map(toShellEvent),
14331449 Stream.runForEach((event) =>
14341450 liveBudget.retain({ kind: "event" as const, event }, event).pipe(
14351451 Effect.flatMap((item) => Queue.offer(liveBuffer, item)),
apps/web/src/components/ChatMarkdown.test.tsx+44
......@@ -104,6 +104,50 @@ describe("ChatMarkdown favicon privacy", () => {
104104});
105105
106106describe("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
107151 it("preserves code controls and details without highlighting an unchanged fence again", async () => {
108152 const highlighter = await getSyntaxHighlighterPromise("text");
109153 const highlight = vi.spyOn(highlighter, "codeToHtml");
apps/web/src/components/ChatMarkdown.tsx+4-1
......@@ -2873,7 +2873,10 @@ const CHAT_MARKDOWN_COMPONENTS = {
28732873 fenceTitle={fenceTitle}
28742874 theme={resolvedTheme}
28752875 >
2876 <RenderErrorBoundary fallback={<pre {...props}>{children}</pre>}>
2876 <RenderErrorBoundary
2877 resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]}
2878 fallback={<pre {...props}>{children}</pre>}
2879 >
28772880 <Suspense fallback={<pre {...props}>{children}</pre>}>
28782881 <SuspenseShikiCodeBlock
28792882 className={codeBlock.className}
apps/web/src/components/DiffWorkerPoolProvider.tsx+78-30
......@@ -1,7 +1,15 @@
1import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
1import { WorkerPoolContext, useWorkerPool } from "@pierre/diffs/react";
2import { WorkerPoolManager } from "@pierre/diffs/worker";
23import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
34import * 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";
513import { useTheme } from "../hooks/useTheme";
614import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
715import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";
......@@ -16,6 +24,46 @@ export class DiffWorkerError extends Schema.TaggedErrorClass<DiffWorkerError>()(
1624 }
1725}
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
1967function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
2068 const workerPool = useWorkerPool();
2169
......@@ -49,16 +97,17 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
4997// Plain-text views do not queue a highlight task that could retry a blank first render.
5098function DiffWorkerReady({ children }: { children?: ReactNode }) {
5199 const workerPool = useWorkerPool();
52 const [ready, setReady] = useState(
53 () => !workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool(),
54 );
100 const [readyPool, setReadyPool] = useState<WorkerPoolManager>();
101 const ready = workerPool
102 ? readyPool === workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool()
103 : typeof window === "undefined";
55104
56105 useEffect(() => {
57106 if (ready || !workerPool) return;
58107
59108 let mounted = true;
60109 const finish = () => {
61 if (mounted) setReady(true);
110 if (mounted) setReadyPool(workerPool);
62111 };
63112 // Failed pools use Pierre's existing main-thread highlighter.
64113 void workerPool.initialize().then(finish, finish);
......@@ -87,33 +136,32 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
87136 typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4);
88137 return Math.max(2, Math.min(6, Math.floor(cores / 2)));
89138 }, []);
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
91161 return (
92 <WorkerPoolContextProvider
93 poolOptions={{
94 workerFactory: () => {
95 try {
96 return new DiffsWorker();
97 } catch (cause) {
98 throw new DiffWorkerError({
99 operation: "create-worker",
100 themeName: diffThemeName,
101 cause,
102 });
103 }
104 },
105 poolSize: workerPoolSize,
106 totalASTLRUCacheSize: 240,
107 }}
108 highlighterOptions={{
109 theme: diffThemeName,
110 preferredHighlighter: PREFERRED_HIGHLIGHTER,
111 tokenizeMaxLineLength: 1_000,
112 useTokenTransformer: true,
113 }}
114 >
162 <WorkerPoolContext value={workerPool}>
115163 <DiffWorkerThemeSync themeName={diffThemeName} />
116164 <DiffWorkerReady>{children}</DiffWorkerReady>
117 </WorkerPoolContextProvider>
165 </WorkerPoolContext>
118166 );
119167}
apps/web/src/components/RenderErrorBoundary.tsx+28-3
......@@ -1,10 +1,35 @@
11import { 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
314export class RenderErrorBoundary extends Component<
4 { readonly children: ReactNode; readonly fallback: ReactNode },
5 { readonly failed: boolean }
15 RenderErrorBoundaryProps,
16 RenderErrorBoundaryState
617> {
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
934 static getDerivedStateFromError() {
1035 return { failed: true };
apps/web/src/components/chat/ComposerActivityStatus.tsx+3-2
......@@ -1,12 +1,13 @@
11import { LoaderCircleIcon } from "lucide-react";
2import { observeVisibleAnimation } from "../../lib/visibleAnimation";
23import { threadSyncLabel, type ThreadSyncPhase } from "../../threadSync";
34import { ComposerBanner } from "./ComposerBanner";
45
56export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase }) {
67 return (
78 <ComposerBanner.Row>
8 <ComposerBanner.Icon>
9 <LoaderCircleIcon />
9 <ComposerBanner.Icon ref={observeVisibleAnimation}>
10 <LoaderCircleIcon className="motion-safe:visible-animate-spin" />
1011 </ComposerBanner.Icon>
1112 <ComposerBanner.Content>
1213 <span
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+8-1
......@@ -2,6 +2,7 @@ import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
22import { CircleAlertIcon, DownloadIcon, LoaderCircleIcon } from "lucide-react";
33import { useId, useState } from "react";
44
5import { observeVisibleAnimation } from "../../lib/visibleAnimation";
56import { serverUpdateStageLabel } from "../ServerUpdateAction";
67import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78import { ComposerBanner } from "./ComposerBanner";
......@@ -12,7 +13,13 @@ export function ComposerServerUpdateIcon({
1213 readonly status: ServerUpdateState["status"];
1314}) {
1415 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 );
1623 }
1724 if (status === "failed") {
1825 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) => {};
2828import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList";
2929import { toolActivityFaviconUrl } from "@t3tools/shared/favicon";
3030import { getProjectFaviconCacheKey } from "@t3tools/shared/projectFavicon";
31import { observeVisibleAnimation } from "../../lib/visibleAnimation";
3132import {
3233 createContext,
3334 Fragment,
......@@ -1652,12 +1653,21 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
16521653 <div className="flex h-6 min-w-0 items-baseline px-1 text-sm leading-relaxed text-muted-foreground tabular-nums">
16531654 <span
16541655 key={isPreparingWorktree ? "setup" : isCompacting ? "compacting" : "working"}
1656 ref={isPreparingWorktree || isCompacting ? observeVisibleAnimation : undefined}
16551657 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
16561658 >
16571659 {isPreparingWorktree ? (
1658 "Setting up worktree…"
1660 <>
1661 Setting up worktree…
1662 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1663 </>
16591664 ) : isCompacting ? (
1660 <CompactingLabel />
1665 <>
1666 <CompactingLabel />
1667 <ActivityShimmerOverlay>
1668 <CompactingLabel />
1669 </ActivityShimmerOverlay>
1670 </>
16611671 ) : row.createdAt ? (
16621672 <>
16631673 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1677,7 +1687,7 @@ function ThinkingTimelineRow() {
16771687 return (
16781688 <div className="min-h-7">
16791689 {isPreparingWorktree || isCompacting ? null : (
1680 <LiveActivityRow label="Thinking" iconName="brain" />
1690 <LiveActivityRow label="Thinking" iconName="brain" active shimmer />
16811691 )}
16821692 </div>
16831693 );
......@@ -1919,6 +1929,19 @@ function ExpandedWorkGroupEntries({
19191929
19201930const 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
19221945const failedToolIconClassName = "text-tool-error-icon/40";
19231946
19241947/** Image icons and the gradient computer-use mark cannot take a currentColor
......@@ -1936,23 +1959,35 @@ function LiveActivityRow({
19361959 toolIcon,
19371960 failed = false,
19381961 active = false,
1962 shimmer = false,
19391963}: {
19401964 label: string;
19411965 iconName?: WorkEntryIconName;
19421966 toolIcon?: ToolActivityIcon | undefined;
19431967 failed?: boolean;
19441968 active?: boolean;
1969 shimmer?: boolean;
19451970}) {
1971 const animated = active && !failed;
1972 const showShimmer = animated && shimmer;
19461973 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 >
19481978 <LiveActivityContent
19491979 label={label}
19501980 iconName={iconName}
19511981 toolIcon={toolIcon}
19521982 failed={failed}
19531983 announceFailure={failed}
1954 active={active}
1984 active={animated && !shimmer}
19551985 />
1986 {showShimmer ? (
1987 <ActivityShimmerOverlay>
1988 <LiveActivityContent label={label} iconName={iconName} toolIcon={toolIcon} highlighted />
1989 </ActivityShimmerOverlay>
1990 ) : null}
19561991 </div>
19571992 );
19581993}
......@@ -1964,6 +1999,7 @@ function LiveActivityContent({
19641999 failed = false,
19652000 announceFailure = false,
19662001 active = false,
2002 highlighted = false,
19672003}: {
19682004 label: string;
19692005 iconName: WorkEntryIconName | undefined;
......@@ -1971,6 +2007,7 @@ function LiveActivityContent({
19712007 failed?: boolean;
19722008 announceFailure?: boolean;
19732009 active?: boolean;
2010 highlighted?: boolean;
19742011}) {
19752012 const showTrailingFailureMark =
19762013 failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon);
......@@ -1980,14 +2017,14 @@ function LiveActivityContent({
19802017 className={cn(
19812018 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
19822019 iconName ? "px-0.5" : "px-1",
1983 "text-secondary-label",
2020 highlighted ? "text-foreground" : "text-secondary-label",
19842021 )}
19852022 >
19862023 {iconName ? (
19872024 <span
19882025 className={cn(
19892026 "flex size-6 shrink-0 items-center justify-center",
1990 failed ? failedToolIconClassName : "text-icon-muted",
2027 failed ? failedToolIconClassName : highlighted ? "text-foreground" : "text-icon-muted",
19912028 )}
19922029 role={announceFailure ? "img" : undefined}
19932030 aria-label={announceFailure ? "Tool call failed" : undefined}
......@@ -1996,7 +2033,7 @@ function LiveActivityContent({
19962033 icon={toolIcon}
19972034 fallbackName={iconName}
19982035 className="block size-4 shrink-0 stroke-[1.8]"
1999 muted
2036 muted={!highlighted}
20002037 />
20012038 </span>
20022039 ) : null}
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+39-12
......@@ -9,6 +9,7 @@ import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react";
99import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
1010import {
1111 act,
12 StrictMode,
1213 useEffect,
1314 useImperativeHandle,
1415 useLayoutEffect,
......@@ -149,6 +150,14 @@ function Views({ count }: { count: number }) {
149150 );
150151}
151152
153function renderViews(count: number) {
154 return (
155 <StrictMode>
156 <Views count={count} />
157 </StrictMode>
158 );
159}
160
152161describe("StyledDiffCodeView", () => {
153162 beforeEach(() => {
154163 testState.codeViewClassName = null;
......@@ -208,6 +217,7 @@ describe("code-view worker lifecycle", () => {
208217 vi.stubGlobal("window", {});
209218 vi.stubGlobal("navigator", { hardwareConcurrency: 2 });
210219 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
220 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
211221 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
212222 setImmediate(() => callback(0)),
213223 );
......@@ -226,14 +236,16 @@ describe("code-view worker lifecycle", () => {
226236 afterEach(async () => {
227237 await act(async () => renderer?.unmount());
228238 renderer = undefined;
239 await vi.runOnlyPendingTimersAsync();
229240 await Promise.all(testState.terminations);
241 vi.useRealTimers();
230242 vi.restoreAllMocks();
231243 vi.unstubAllGlobals();
232244 });
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 () => {
235247 await act(async () => {
236 renderer = create(<Views count={0} />);
248 renderer = create(renderViews(0));
237249 });
238250 const mounted = renderer!;
239251 const button = mounted.root.findByType("button");
......@@ -241,7 +253,7 @@ describe("code-view worker lifecycle", () => {
241253 expect(testState.pools.size).toBe(0);
242254 await act(async () => {
243255 (button.props as { onClick(): void }).onClick();
244 mounted.update(<Views count={1} />);
256 mounted.update(renderViews(1));
245257 });
246258 const pool = [...testState.pools.keys()][0]!;
247259 await act(async () => testState.pools.get(pool));
......@@ -252,25 +264,39 @@ describe("code-view worker lifecycle", () => {
252264 );
253265 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)));
256268 await act(async () => pool.primeFileHighlightCache(files[1]!));
257269 expect(testState.pools.size).toBe(1);
258270 expect(testState.workers).toHaveLength(2);
259 expect(testState.renderPools).toEqual([pool, pool]);
271 expect(new Set(testState.renderPools)).toEqual(new Set([pool]));
260272 expect(pool.getFileResultCache(files[1]!)).toBeDefined();
261273 expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain(
262274 "answer",
263275 );
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);
266282 expect(pool.getStats().totalWorkers).toBe(2);
267283 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);
269295 await Promise.all(testState.terminations);
270296 expect(pool.getStats().totalWorkers).toBe(0);
271297 expect(testState.terminations).toHaveLength(2);
272298
273 await act(async () => mounted.update(<Views count={1} />));
299 await act(async () => mounted.update(renderViews(1)));
274300 const reopenedPool = [...testState.pools.keys()][1]!;
275301 await act(async () => testState.pools.get(reopenedPool));
276302 expect(reopenedPool.isInitialized()).toBe(true);
......@@ -285,7 +311,7 @@ describe("code-view worker lifecycle", () => {
285311 it("renders through Pierre's main-thread fallback when worker creation fails", async () => {
286312 testState.failWorkers = true;
287313 await act(async () => {
288 renderer = create(<Views count={1} />);
314 renderer = create(renderViews(1));
289315 });
290316 const pool = [...testState.pools.keys()][0]!;
291317 await act(async () => {
......@@ -309,7 +335,7 @@ describe("code-view worker lifecycle", () => {
309335 testState.onInitializationHeld = resolve;
310336 });
311337 await act(async () => {
312 renderer = create(<Views count={2} />);
338 renderer = create(renderViews(2));
313339 });
314340 const pool = [...testState.pools.keys()][0]!;
315341 const pending = testState.pools.get(pool)!;
......@@ -319,10 +345,11 @@ describe("code-view worker lifecycle", () => {
319345 expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2);
320346 expect(testState.renderPools).toHaveLength(0);
321347
322 await act(async () => renderer!.update(<Views count={1} />));
348 await act(async () => renderer!.update(renderViews(1)));
323349 expect(pool.getStats().totalWorkers).toBe(2);
324350 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);
326353 await pending;
327354 await Promise.all(testState.terminations);
328355 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
450450 background-repeat: no-repeat;
451451 background-clip: text;
452452 animation: live-tool-shine 2.2s steps(30) infinite;
453 animation-play-state: var(--visible-animation-state, paused);
453454 }
454455}
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
456539@property --assistant-citation-highlight-opacity {
457540 syntax: "<number>";
458541 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}