diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 0d5c054be3..4dd5ec1b97 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -52,6 +52,8 @@ Historical groups are indexed in the linked archive. This file migration changes | Active thread order and cross-section arrangement / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full eight-source list in [#413](https://github.com/pylon-code/pylon/pull/413), from #9729 through #10464 | Adopted saved active order across server, web and both mobile lists; final drag cues, cancellation and bulk-update performance fixes. Preserve Pylon migration lineage (57), archived snapshots, provider/Prime lifecycle, draft actions, attention/delegation states and older-server guards. Hidden keys remain reserved; no partial scope. | [Thread arrangement #413](https://github.com/pylon-code/pylon/pull/413); 550 focused tests, six package typechecks, scoped checks and integrated browser/iOS evidence. | +| Live activity motion / `6c583620ff7ad3235b135af7107c0543467eecfa` | `c7c1dfe4df99edf65a49d8a31b39ef1361f37f44` (#9709), `ce4712d5b04fb998f79fe132245289191147e5d5` (#9799); `ActivityShimmerOverlay` originates in `c5ba51d629b3813182cf3e161cc3f23b1e541dc3` (#9293) | Completes both sources' web animation scope. `107db1f07e` had ported #9709 without its shimmer overlay, leaving Thinking, worktree setup and compaction with no motion; #426 then added #9799's `animation-play-state` gate with no observer, which stopped the tool shine outright. Now the masked overlay sweeps label-only rows, `live-tool-shine` keeps tool labels, and both run off the shared observer, which gained Pylon's window-focus gate. #9293 stays unclassified apart from that component; its provider command is a separate group. Upstream's `toolIcon`/`ToolActivityIconView` (#9093) and failed-icon tinting (#9606) are deliberately excluded and remain unclassified. Cursor unchanged. | [Live activity motion #445](https://github.com/pylon-code/pylon/pull/445); 160 focused tests, web typecheck, scoped lint/format, and a new guard test that fails when gated CSS loses its observer. | + ## Deferred register Open decisions retain their original dates and revisit conditions. Check triggers once per integration cycle using the skill; detailed history is in the archive. diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index d427222307..c9564a51ef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1620,6 +1620,47 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("tool call failed"); }); + it("hands the live slot to a shimmering Thinking row when the only tool failed", () => { + const turnId = TurnId.make("turn-live"); + const markup = renderToStaticMarkup( + , + ); + + // A failed call never owns the live slot, so nothing here carries the tool shine. + expect(markup).toContain("Thinking"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("live-tool-shine"); + }); + it("renders initial thinking as the shared live activity row", () => { const turnId = TurnId.make("turn-live"); const markup = renderToStaticMarkup( @@ -1641,6 +1682,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Thinking"); expect(markup).toContain("lucide-brain"); expect(markup).toContain('data-timeline-row-id="live-activity-row"'); + // A label-only row sweeps the masked overlay; the shine belongs to tool labels. + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("live-tool-shine"); + }); + + it("shimmers the worktree setup label instead of the thinking row", () => { + const turnId = TurnId.make("turn-setup"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Setting up worktree…"); + expect(markup).toContain("live-activity-focus"); + expect(markup).not.toContain("Thinking"); }); it("keeps the completed command in the shared activity row with a past-tense label", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5b6f4e7fb8..1a53f027a0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -168,6 +168,7 @@ import { type ParsedPreviewAnnotation, } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; @@ -1712,12 +1713,21 @@ function WorkingTimelineRow({ row }: { row: Extract {isPreparingWorktree ? ( - "Setting up worktree…" + <> + Setting up worktree… + Setting up worktree… + ) : isCompacting ? ( - + <> + + + + + ) : row.createdAt ? ( <> Working for @@ -1742,7 +1752,7 @@ function ThinkingTimelineRow() { return (
{isPreparingWorktree || isCompacting ? null : ( - + )}
); @@ -1995,26 +2005,51 @@ function ExpandedWorkGroupEntries({ const workEntryKey = (entry: TimelineWorkEntry) => entry.id; +function ActivityShimmerOverlay({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + function LiveActivityRow({ label, iconName, failed = false, active = false, + shimmer = false, }: { label: string; iconName?: WorkEntryIconName; failed?: boolean; active?: boolean; + shimmer?: boolean; }) { + const animated = active && !failed; + const showShimmer = animated && shimmer; return ( -
+
+ {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.