+
+ {showShimmer ? (
+
+
+
+ ) : null}
);
}
@@ -2025,40 +2060,15 @@ function LiveActivityContent({
failed = false,
announceFailure = false,
active = false,
+ highlighted = false,
}: {
label: string;
iconName: WorkEntryIconName | undefined;
failed?: boolean;
announceFailure?: boolean;
active?: boolean;
+ highlighted?: boolean;
}) {
- const labelRef = useRef
(null);
- useEffect(() => {
- const element = labelRef.current;
- if (!active || !element) return;
- let visible = false;
- const update = () => {
- element.dataset.animationActive = String(visible && !document.hidden && document.hasFocus());
- };
- const pause = () => {
- element.dataset.animationActive = "false";
- };
- const observer = new IntersectionObserver(([entry]) => {
- visible = entry?.isIntersecting ?? false;
- update();
- });
- observer.observe(element);
- document.addEventListener("visibilitychange", update);
- window.addEventListener("focus", update);
- window.addEventListener("blur", pause);
- return () => {
- observer.disconnect();
- document.removeEventListener("visibilitychange", update);
- window.removeEventListener("focus", update);
- window.removeEventListener("blur", pause);
- delete element.dataset.animationActive;
- };
- }, [active]);
const isSpecialToolIcon = iconName === "browser" || iconName === "t3-code";
const resolvedIconName = iconName;
@@ -2067,24 +2077,25 @@ function LiveActivityContent({
className={cn(
"flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
resolvedIconName ? "px-0.5" : "px-1",
- "text-secondary-label",
+ highlighted ? "text-foreground" : "text-secondary-label",
)}
>
{resolvedIconName ? (
) : null}
-
- {label}
-
+ {label}
{failed && isSpecialToolIcon ? : null}
);
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 20b6a20861..c64fad2357 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -473,20 +473,18 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
}
@utility live-tool-shine {
- &[data-animation-active="true"] {
- @media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
- color: transparent;
- background-image:
- linear-gradient(to right, transparent, var(--contrast-foreground), transparent),
- linear-gradient(var(--contrast-secondary-label), var(--contrast-secondary-label));
- background-size:
- 4.5rem 100%,
- 100% 100%;
- background-repeat: no-repeat;
- background-clip: text;
- animation: live-tool-shine 2.2s steps(30) infinite;
- animation-play-state: var(--visible-animation-state, paused);
- }
+ @media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
+ color: transparent;
+ background-image:
+ linear-gradient(to right, transparent, var(--contrast-foreground), transparent),
+ linear-gradient(var(--contrast-secondary-label), var(--contrast-secondary-label));
+ background-size:
+ 4.5rem 100%,
+ 100% 100%;
+ background-repeat: no-repeat;
+ background-clip: text;
+ animation: live-tool-shine 2.2s steps(30) infinite;
+ animation-play-state: var(--visible-animation-state, paused);
}
}
diff --git a/apps/web/src/lib/visibleAnimation.test.ts b/apps/web/src/lib/visibleAnimation.test.ts
index 0cc7c9d835..72ecf6f89a 100644
--- a/apps/web/src/lib/visibleAnimation.test.ts
+++ b/apps/web/src/lib/visibleAnimation.test.ts
@@ -1,9 +1,17 @@
+// @effect-diagnostics nodeBuiltinImport:off - Regression coverage compares the gated CSS with its observer wiring.
+import * as NodeFS from "node:fs";
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { observeVisibleAnimation } from "./visibleAnimation";
-let page = Object.assign(new EventTarget(), { visibilityState: "visible" });
+let focused = true;
+let page = Object.assign(new EventTarget(), {
+ visibilityState: "visible",
+ hasFocus: () => focused,
+});
let motion = Object.assign(new EventTarget(), { matches: false });
+let host = Object.assign(new EventTarget(), { matchMedia: () => motion });
let observers: TestIntersectionObserver[] = [];
let cleanups: Array<() => void> = [];
@@ -43,12 +51,17 @@ function attach(element: HTMLElement) {
}
beforeEach(() => {
- page = Object.assign(new EventTarget(), { visibilityState: "visible" });
+ focused = true;
+ page = Object.assign(new EventTarget(), {
+ visibilityState: "visible",
+ hasFocus: () => focused,
+ });
motion = Object.assign(new EventTarget(), { matches: false });
+ host = Object.assign(new EventTarget(), { matchMedia: () => motion });
observers = [];
cleanups = [];
vi.stubGlobal("document", page);
- vi.stubGlobal("window", { matchMedia: () => motion });
+ vi.stubGlobal("window", host);
vi.stubGlobal("IntersectionObserver", TestIntersectionObserver);
});
@@ -95,25 +108,48 @@ describe("observeVisibleAnimation", () => {
expect(second.state()).toBe("running");
});
- it.each(["hidden", "reduced motion"])("starts paused with %s already active", (condition) => {
- page.visibilityState = condition === "hidden" ? "hidden" : "visible";
- motion.matches = condition === "reduced motion";
+ it("pauses an intersecting animation while the window is unfocused", () => {
const animation = animationElement();
attach(animation.element);
observers[0]!.report(animation.element, true);
+ expect(animation.state()).toBe("running");
+
+ focused = false;
+ host.dispatchEvent(new Event("blur"));
expect(animation.state()).toBe("paused");
+ expect(animation.willChange()).toBe("auto");
- page.visibilityState = "visible";
- motion.matches = false;
- page.dispatchEvent(new Event("visibilitychange"));
+ focused = true;
+ host.dispatchEvent(new Event("focus"));
expect(animation.state()).toBe("running");
});
+ it.each(["hidden", "unfocused", "reduced motion"])(
+ "starts paused with %s already active",
+ (condition) => {
+ page.visibilityState = condition === "hidden" ? "hidden" : "visible";
+ focused = condition !== "unfocused";
+ motion.matches = condition === "reduced motion";
+ const animation = animationElement();
+ attach(animation.element);
+ observers[0]!.report(animation.element, true);
+ expect(animation.state()).toBe("paused");
+
+ page.visibilityState = "visible";
+ focused = true;
+ motion.matches = false;
+ page.dispatchEvent(new Event("visibilitychange"));
+ expect(animation.state()).toBe("running");
+ },
+ );
+
it("shares observers, releases the final ref, and ignores late callbacks after remount", () => {
const addVisibility = vi.spyOn(page, "addEventListener");
const removeVisibility = vi.spyOn(page, "removeEventListener");
const addMotion = vi.spyOn(motion, "addEventListener");
const removeMotion = vi.spyOn(motion, "removeEventListener");
+ const addFocus = vi.spyOn(host, "addEventListener");
+ const removeFocus = vi.spyOn(host, "removeEventListener");
const first = animationElement();
const second = animationElement();
const detachFirst = attach(first.element);
@@ -121,6 +157,7 @@ describe("observeVisibleAnimation", () => {
expect(observers).toHaveLength(1);
expect(addVisibility).toHaveBeenCalledTimes(1);
expect(addMotion).toHaveBeenCalledTimes(1);
+ expect(addFocus).toHaveBeenCalledTimes(2);
const previousObserver = observers[0]!;
detachFirst?.();
@@ -133,6 +170,7 @@ describe("observeVisibleAnimation", () => {
expect(previousObserver.disconnect).toHaveBeenCalledTimes(1);
expect(removeVisibility).toHaveBeenCalledTimes(1);
expect(removeMotion).toHaveBeenCalledTimes(1);
+ expect(removeFocus).toHaveBeenCalledTimes(2);
attach(second.element);
expect(observers).toHaveLength(2);
@@ -152,3 +190,61 @@ describe("observeVisibleAnimation", () => {
expect(observers).toHaveLength(0);
});
});
+
+const webSourceRoot = new URL("../", import.meta.url);
+
+/** Utilities whose motion only runs once `observeVisibleAnimation` sets the state variable. */
+function gatedUtilities(css: string) {
+ const gated: string[] = [];
+ let utility: string | undefined;
+ let body = "";
+ for (const line of css.split("\n")) {
+ const opening = /^@utility ([\w-]+) \{$/.exec(line);
+ if (opening) {
+ utility = opening[1];
+ body = "";
+ } else if (utility === undefined) {
+ continue;
+ } else if (line === "}") {
+ if (body.includes("var(--visible-animation-state")) gated.push(utility);
+ utility = undefined;
+ } else {
+ body += line;
+ }
+ }
+ return gated;
+}
+
+function sourceFiles(directory: URL): URL[] {
+ return NodeFS.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
+ if (entry.isDirectory()) return sourceFiles(new URL(`${entry.name}/`, directory));
+ if (!/\.tsx?$/.test(entry.name) || entry.name.includes(".test.")) return [];
+ return [new URL(entry.name, directory)];
+ });
+}
+
+describe("gated animation utilities", () => {
+ it("are applied only by components that attach the observer", () => {
+ const css = NodeFS.readFileSync(new URL("index.css", webSourceRoot), "utf8");
+ const gated = gatedUtilities(css);
+ expect(gated.length).toBeGreaterThan(0);
+
+ const sources = sourceFiles(webSourceRoot).map((url) => ({
+ path: url.pathname.slice(webSourceRoot.pathname.length),
+ text: NodeFS.readFileSync(url, "utf8"),
+ }));
+
+ // A gated utility nobody observes paints a still frame: the animation stays
+ // parked on the `paused` fallback, which is how #426 silently stopped the shine.
+ const unobserved = gated.flatMap((utility) => {
+ const applied = new RegExp(`(? applied.test(text));
+ if (users.length === 0) return [`${utility} is never applied`];
+ return users
+ .filter(({ text }) => !text.includes("observeVisibleAnimation"))
+ .map(({ path }) => `${utility} in ${path} never attaches observeVisibleAnimation`);
+ });
+
+ expect(unobserved).toEqual([]);
+ });
+});
diff --git a/apps/web/src/lib/visibleAnimation.ts b/apps/web/src/lib/visibleAnimation.ts
index 7f5f0118c7..32cb7f9036 100644
--- a/apps/web/src/lib/visibleAnimation.ts
+++ b/apps/web/src/lib/visibleAnimation.ts
@@ -9,7 +9,10 @@ let reducedMotion: MediaQueryList | null = null;
function updateAnimation(animation: ObservedAnimation) {
const running =
- animation.intersecting && document.visibilityState === "visible" && !reducedMotion?.matches;
+ animation.intersecting &&
+ document.visibilityState === "visible" &&
+ document.hasFocus() &&
+ !reducedMotion?.matches;
animation.element.style.setProperty("--visible-animation-state", running ? "running" : "paused");
animation.element.style.setProperty(
"--visible-animation-will-change",
@@ -21,7 +24,8 @@ function updateAnimations() {
for (const animation of animations.values()) updateAnimation(animation);
}
-/** Attach to a stable animation container. All refs share visibility and motion listeners. */
+/** Attach to a stable animation container. All refs share the viewport, background-tab,
+ * window-focus and reduced-motion gates, and one set of listeners. */
export function observeVisibleAnimation(element: HTMLElement | SVGElement | null) {
if (element === null) return;
element.style.setProperty("--visible-animation-state", "paused");
@@ -32,6 +36,8 @@ export function observeVisibleAnimation(element: HTMLElement | SVGElement | null
reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedMotion.addEventListener("change", updateAnimations);
document.addEventListener("visibilitychange", updateAnimations);
+ window.addEventListener("focus", updateAnimations);
+ window.addEventListener("blur", updateAnimations);
observer = new IntersectionObserver((entries, source) => {
if (source !== observer) return;
for (const entry of entries) {
@@ -59,6 +65,8 @@ export function observeVisibleAnimation(element: HTMLElement | SVGElement | null
reducedMotion?.removeEventListener("change", updateAnimations);
reducedMotion = null;
document.removeEventListener("visibilitychange", updateAnimations);
+ window.removeEventListener("focus", updateAnimations);
+ window.removeEventListener("blur", updateAnimations);
}
};
}
diff --git a/docs/user/tool-activity.md b/docs/user/tool-activity.md
index db65d662a0..96a9648e37 100644
--- a/docs/user/tool-activity.md
+++ b/docs/user/tool-activity.md
@@ -25,4 +25,4 @@ On web and desktop, groups of tools that finish after the final reply stay below
Long run durations include hours, such as "Worked for 6h 59m 50s". Live timers use the same hour format.
-On web and desktop, only the active tool label shines. It stays still when offscreen, in a background tab or unfocused window, or with reduced motion or forced colors. Loading and server-update icons and the ultrathink border use static colors.
+On web and desktop, only the active tool label shines. It stays still when offscreen, in a background tab or unfocused window, or with reduced motion or forced colors. Loading spinners pause under the same conditions. Loading and server-update icons and the ultrathink border use static colors.