authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 09:12:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 09:12:28-07:00
logc7c1dfe4df99edf65a49d8a31b39ef1361f37f44
tree225c1fad845e5917f553ab4df56613fea9ef55b0
parentc75299ee2085a121bceb6df76796e971fe92b5b6
signature Signed by PGP key B5690EEEBB952194

perf(web): stop continuous chat status animations (#9709)


6 files changed, 20 insertions(+), 159 deletions(-)

apps/web/src/components/chat/ComposerActivityStatus.tsx+1-1
......@@ -6,7 +6,7 @@ export function ComposerActivityRow({ phase }: { readonly phase: ThreadSyncPhase
66 return (
77 <ComposerBanner.Row>
88 <ComposerBanner.Icon>
9 <LoaderCircleIcon className="motion-safe:animate-spin" />
9 <LoaderCircleIcon />
1010 </ComposerBanner.Icon>
1111 <ComposerBanner.Content>
1212 <span
apps/web/src/components/chat/ComposerServerUpdateStatus.tsx+1-1
......@@ -12,7 +12,7 @@ export function ComposerServerUpdateIcon({
1212 readonly status: ServerUpdateState["status"];
1313}) {
1414 if (status === "running") {
15 return <LoaderCircleIcon aria-hidden className="motion-safe:animate-spin" />;
15 return <LoaderCircleIcon aria-hidden />;
1616 }
1717 if (status === "failed") {
1818 return <CircleAlertIcon aria-hidden className="text-error" />;
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-3
......@@ -1203,7 +1203,7 @@ describe("MessagesTimeline", () => {
12031203 expect(markup).not.toContain('aria-label="Hidden work includes a failure"');
12041204 });
12051205
1206 it("shows the animated one-line label for a live tool group", () => {
1206 it("shows the one-line label for a live tool group", () => {
12071207 const turnId = TurnId.make("turn-live");
12081208 const markup = renderToStaticMarkup(
12091209 <MessagesTimeline
......@@ -1240,7 +1240,6 @@ describe("MessagesTimeline", () => {
12401240
12411241 expect(markup).toContain("Working for");
12421242 expect(markup).toContain("Running pnpm");
1243 expect(markup).toContain("live-activity-focus");
12441243 });
12451244
12461245 it("scopes a live row failure to the tool named by the row", () => {
......@@ -1358,7 +1357,6 @@ describe("MessagesTimeline", () => {
13581357
13591358 expect(markup).toContain("Running pnpm");
13601359 expect(markup).toContain("lucide-terminal");
1361 expect(markup).toContain("live-activity-focus");
13621360 expect(markup).not.toContain("Ran pnpm");
13631361 expect(markup).not.toContain("Thinking");
13641362 expect(markup).not.toContain('data-timeline-row-kind="thinking"');
apps/web/src/components/chat/MessagesTimeline.tsx+13-60
......@@ -1614,17 +1614,9 @@ function WorkingTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "workin
16141614 className="relative shrink-0 overflow-hidden whitespace-nowrap transition-opacity duration-150 starting:opacity-0 motion-reduce:transition-none"
16151615 >
16161616 {isPreparingWorktree ? (
1617 <>
1618 Setting up worktree…
1619 <ActivityShimmerOverlay>Setting up worktree…</ActivityShimmerOverlay>
1620 </>
1617 "Setting up worktree…"
16211618 ) : isCompacting ? (
1622 <>
1623 <CompactingLabel />
1624 <ActivityShimmerOverlay>
1625 <CompactingLabel />
1626 </ActivityShimmerOverlay>
1627 </>
1619 <CompactingLabel />
16281620 ) : row.createdAt ? (
16291621 <>
16301622 Working for <WorkingTimer createdAt={row.createdAt} />
......@@ -1878,19 +1870,6 @@ function ExpandedWorkGroupEntries({
18781870
18791871const workEntryKey = (entry: TimelineWorkEntry) => entry.id;
18801872
1881function ActivityShimmerOverlay({ children }: { children: ReactNode }) {
1882 return (
1883 <span
1884 aria-hidden
1885 className="live-activity-focus pointer-events-none absolute inset-y-0 select-none"
1886 >
1887 <span className="live-activity-focus-counter block">
1888 <span className="live-activity-focus-aligned block text-foreground">{children}</span>
1889 </span>
1890 </span>
1891 );
1892}
1893
18941873const failedToolIconClassName = "text-tool-error-icon/40";
18951874
18961875/** Image icons and the gradient computer-use mark cannot take a currentColor
......@@ -1914,7 +1893,7 @@ function LiveActivityRow({
19141893 failed?: boolean;
19151894}) {
19161895 return (
1917 <div className="relative min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
1896 <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
19181897 <LiveActivityContent
19191898 label={label}
19201899 iconName={iconName}
......@@ -1922,15 +1901,6 @@ function LiveActivityRow({
19221901 failed={failed}
19231902 announceFailure={failed}
19241903 />
1925 <ActivityShimmerOverlay>
1926 <LiveActivityContent
1927 label={label}
1928 iconName={iconName}
1929 toolIcon={toolIcon}
1930 failed={failed}
1931 highlighted
1932 />
1933 </ActivityShimmerOverlay>
19341904 </div>
19351905 );
19361906}
......@@ -1941,14 +1911,12 @@ function LiveActivityContent({
19411911 toolIcon,
19421912 failed = false,
19431913 announceFailure = false,
1944 highlighted = false,
19451914}: {
19461915 label: string;
19471916 iconName: WorkEntryIconName | undefined;
19481917 toolIcon?: ToolActivityIcon | undefined;
19491918 failed?: boolean;
19501919 announceFailure?: boolean;
1951 highlighted?: boolean;
19521920}) {
19531921 const showTrailingFailureMark =
19541922 failed && iconName !== undefined && !toolIconAcceptsTint(iconName, toolIcon);
......@@ -1958,14 +1926,14 @@ function LiveActivityContent({
19581926 className={cn(
19591927 "flex min-h-6 min-w-0 items-center gap-1.5 py-0.5",
19601928 iconName ? "px-0.5" : "px-1",
1961 highlighted ? "text-foreground" : "text-secondary-label",
1929 "text-secondary-label",
19621930 )}
19631931 >
19641932 {iconName ? (
19651933 <span
19661934 className={cn(
19671935 "flex size-6 shrink-0 items-center justify-center",
1968 highlighted ? "text-foreground" : failed ? failedToolIconClassName : "text-icon-muted",
1936 failed ? failedToolIconClassName : "text-icon-muted",
19691937 )}
19701938 role={announceFailure ? "img" : undefined}
19711939 aria-label={announceFailure ? "Tool call failed" : undefined}
......@@ -1974,16 +1942,13 @@ function LiveActivityContent({
19741942 icon={toolIcon}
19751943 fallbackName={iconName}
19761944 className="block size-4 shrink-0 stroke-[1.8]"
1977 muted={!highlighted}
1945 muted
19781946 />
19791947 </span>
19801948 ) : null}
19811949 <span className="min-w-0 flex-1 truncate">{label}</span>
19821950 {showTrailingFailureMark ? (
1983 <XIcon
1984 aria-hidden
1985 className={cn("size-3 shrink-0", !highlighted && failedToolIconClassName)}
1986 />
1951 <XIcon aria-hidden className={cn("size-3 shrink-0", failedToolIconClassName)} />
19871952 ) : null}
19881953 </span>
19891954 );
......@@ -2002,24 +1967,12 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "
20021967 aria-expanded={row.expanded}
20031968 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
20041969 >
2005 {row.active ? (
2006 <LiveActivityRow
2007 label={label}
2008 iconName={workEntryIconName(row.entry)}
2009 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
2010 failed={failed}
2011 />
2012 ) : (
2013 <div className="min-h-6 w-fit max-w-full min-w-0 overflow-hidden rounded-md text-sm leading-relaxed">
2014 <LiveActivityContent
2015 label={label}
2016 iconName={workEntryIconName(row.entry)}
2017 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
2018 failed={failed}
2019 announceFailure={failed}
2020 />
2021 </div>
2022 )}
1970 <LiveActivityRow
1971 label={label}
1972 iconName={workEntryIconName(row.entry)}
1973 toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon}
1974 failed={failed}
1975 />
20231976 </button>
20241977 );
20251978}
apps/web/src/index.css+2-94
......@@ -425,80 +425,6 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
425425 }
426426}
427427
428@keyframes live-activity-focus {
429 0% {
430 transform: translateX(0);
431 }
432 100% {
433 transform: translateX(100%);
434 }
435}
436
437@keyframes live-activity-focus-counter {
438 0% {
439 transform: translateX(0);
440 }
441 100% {
442 transform: translateX(-100%);
443 }
444}
445
446@utility live-activity-focus {
447 --live-activity-focus-width: 4.5rem;
448
449 right: auto;
450 left: calc(-1 * var(--live-activity-focus-width));
451 width: calc(100% + var(--live-activity-focus-width) + var(--live-activity-focus-width));
452 -webkit-mask-image: linear-gradient(
453 to right,
454 transparent 0,
455 rgb(0 0 0 / 12%) 0.675rem,
456 rgb(0 0 0 / 55%) 1.575rem,
457 black 2.25rem,
458 rgb(0 0 0 / 55%) 2.925rem,
459 rgb(0 0 0 / 12%) 3.825rem,
460 transparent var(--live-activity-focus-width),
461 transparent 100%
462 );
463 -webkit-mask-repeat: no-repeat;
464 mask-image: linear-gradient(
465 to right,
466 transparent 0,
467 rgb(0 0 0 / 12%) 0.675rem,
468 rgb(0 0 0 / 55%) 1.575rem,
469 black 2.25rem,
470 rgb(0 0 0 / 55%) 2.925rem,
471 rgb(0 0 0 / 12%) 3.825rem,
472 transparent var(--live-activity-focus-width),
473 transparent 100%
474 );
475 mask-repeat: no-repeat;
476 animation: live-activity-focus 2.2s linear infinite;
477 will-change: transform;
478
479 @media (prefers-reduced-motion: reduce) {
480 animation: none;
481 opacity: 0;
482 will-change: auto;
483 }
484}
485
486@utility live-activity-focus-counter {
487 width: 100%;
488 animation: live-activity-focus-counter 2.2s linear infinite;
489 will-change: transform;
490
491 @media (prefers-reduced-motion: reduce) {
492 animation: none;
493 will-change: auto;
494 }
495}
496
497@utility live-activity-focus-aligned {
498 width: calc(100% - var(--live-activity-focus-width) - var(--live-activity-focus-width));
499 margin-left: var(--live-activity-focus-width);
500}
501
502428@property --assistant-citation-highlight-opacity {
503429 syntax: "<number>";
504430 inherits: true;
......@@ -1906,24 +1832,6 @@ code {
19061832 color: var(--code-foreground) !important;
19071833}
19081834
1909@keyframes ultrathink-rainbow {
1910 0% {
1911 background-position: 0% 50%;
1912 }
1913 100% {
1914 background-position: 200% 50%;
1915 }
1916}
1917
1918@keyframes ultrathink-chroma-shift {
1919 0% {
1920 filter: hue-rotate(0deg) saturate(1.2);
1921 }
1922 100% {
1923 filter: hue-rotate(360deg) saturate(1.2);
1924 }
1925}
1926
19271835.ultrathink-frame {
19281836 --ultrathink-spectrum: linear-gradient(
19291837 120deg,
......@@ -1946,6 +1854,7 @@ code {
19461854 padding: 2px;
19471855 background-image: var(--ultrathink-spectrum);
19481856 background-size: 220% 220%;
1857 background-position: 0% 50%;
19491858 pointer-events: none;
19501859 filter: saturate(0.82) brightness(0.92);
19511860 -webkit-mask:
......@@ -1956,7 +1865,6 @@ code {
19561865 linear-gradient(#000 0 0) content-box,
19571866 linear-gradient(#000 0 0);
19581867 mask-composite: exclude;
1959 animation: ultrathink-rainbow 10s linear infinite;
19601868}
19611869
19621870.ultrathink-frame > * {
......@@ -1964,7 +1872,7 @@ code {
19641872}
19651873
19661874.ultrathink-chroma {
1967 animation: ultrathink-chroma-shift 10s linear infinite;
1875 filter: saturate(1.2);
19681876}
19691877
19701878@keyframes preview-loading-progress {
docs/user/composer.md+2
......@@ -174,6 +174,8 @@ On web and desktop, loading and syncing statuses fill the available banner width
174174stash tab. Task progress appears above the composer, while the timeline's working timer shows
175175only elapsed time.
176176
177Loading, syncing, and server-update icons are static. Live tool labels do not shimmer.
178
177179On web and desktop, additional notices peek out above the attached banner. Hover over the peek
178180to reveal them, or focus **Show other notices** with `Tab` and press `Enter` or `Space`. Press
179181`Escape` to close the stack and return focus to that control. On a touchscreen, tap the peek to