mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 02:53:39 +00:00
perf(ui): run the spinner and the text shimmer on the compositor (#61562)
This commit is contained in:
parent
d85283496f
commit
ba084e62d8
10 changed files with 140 additions and 67 deletions
|
|
@ -1155,7 +1155,7 @@ test.describe("background shortcut", () => {
|
|||
const capsule = page.getByRole("button", { name: "1 shell", exact: true, includeHidden: true })
|
||||
const list = page.getByRole("menu", { name: "1 shell", exact: true })
|
||||
const closed = (await capsule.boundingBox())!
|
||||
const inset = (await capsule.locator("svg").boundingBox())!.x - closed.x
|
||||
const inset = (await capsule.locator('[data-component="session-progress-indicator-v2"]').boundingBox())!.x - closed.x
|
||||
|
||||
await capsule.click()
|
||||
// Right placement shares the capsule's end edge and mirrors its inset there; centre placement shares its centre.
|
||||
|
|
@ -1336,7 +1336,7 @@ test.describe("background shortcut", () => {
|
|||
// The open menu hides the rest of the page from the accessibility tree, the capsule included.
|
||||
const capsule = page.getByRole("button", { name: "2 subagents · 1 shell", exact: true, includeHidden: true })
|
||||
const label = capsule.getByText("2 subagents · 1 shell", { exact: true })
|
||||
const glyph = await capsule.locator("svg").boundingBox()
|
||||
const glyph = await capsule.locator('[data-component="session-progress-indicator-v2"]').boundingBox()
|
||||
const text = await label.boundingBox()
|
||||
const inset = glyph!.x - (await capsule.boundingBox())!.x
|
||||
|
||||
|
|
@ -1357,7 +1357,7 @@ test.describe("background shortcut", () => {
|
|||
const content = item.querySelector('[data-slot="menu-v2-item-content"]')
|
||||
|
||||
return [
|
||||
content?.querySelector(":scope > svg")?.getBoundingClientRect().left ?? NaN,
|
||||
content?.querySelector(":scope > :first-child")?.getBoundingClientRect().left ?? NaN,
|
||||
content?.querySelector(":scope > span:not([data-slot])")?.getBoundingClientRect().left ?? NaN,
|
||||
item.querySelector('[data-slot="session-activity-elapsed"]')?.getBoundingClientRect().right ?? NaN,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ build on the e2e mock server, so they need no backend.
|
|||
| File | Cases | Workload |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `timeline.bench.ts` | scroll history · type in composer · find in session · expand an edit | A 120-turn session of markdown, code, tools and diffs. |
|
||||
| `stream.bench.ts` | stream a reply · into a tool-heavy history · with review open | 160 markdown deltas at 40 a second from a server that never waits. |
|
||||
| `stream.bench.ts` | stream a reply · into a tool-heavy history · with review open · wait on a running session | 160 markdown deltas at 40 a second from a server that never waits. |
|
||||
| `tabs.bench.ts` | switch tab (warm, review open, after a resize, 1,200 models) · open an unvisited tab (± review) | Two tabs of 200 long markdown exchanges each, loaded in full. |
|
||||
| `navigation.bench.ts` | Home → session · close the last tab · new session (Home, titlebar) · new session page in and out · child session · restore 8 tabs · activate a restored tab · command palette (session, Home) | The stress fixture, paginated, from a fresh document every run. |
|
||||
| `home.bench.ts` | load home · update a session in the background | 500, 5,000 and 10,000 root sessions across 12 folders. |
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ export default bench({
|
|||
await page.waitFor(timeline, { timeout: 60_000 })
|
||||
|
||||
if (name === "stream with review open") await openReview(page)
|
||||
|
||||
// Its animations never stop, and with the attribution probes on a slow machine their frames can run long enough
|
||||
// that the page never counts as idle.
|
||||
if (name === "wait on a running session") return page.wait(2000)
|
||||
await page.idle()
|
||||
},
|
||||
|
||||
|
|
@ -60,5 +64,12 @@ export default bench({
|
|||
"stream into a tool-heavy history": stream,
|
||||
// The review pane open beside the timeline on 72 changed files.
|
||||
"stream with review open": stream,
|
||||
// Five seconds of a running session between deltas: the tab's spinner and the Working shimmer animate, and nothing
|
||||
// else changes. Animation the compositor runs costs the page's main thread nothing.
|
||||
"wait on a running session": {
|
||||
primary: "main.work",
|
||||
metrics: ["main.style", "cpu.renderer", "cpu.gpu", "frames.presented.count"],
|
||||
run: ({ page }) => page.wait(5000),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
[data-slot="menu-v2-item-content"] > svg:first-child {
|
||||
[data-slot="menu-v2-item-content"] > :is(svg, [data-slot="spinner-dots"]):first-child {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Spinner } from "@opencode/ui/spinner"
|
|||
import "./session-progress-indicator-v2.css"
|
||||
|
||||
// The app's one activity spinner, shared by the activity chip, subagent rows, task cards and loading tabs.
|
||||
export function SessionProgressIndicatorV2(props: ComponentProps<"svg">) {
|
||||
export function SessionProgressIndicatorV2(props: ComponentProps<typeof Spinner>) {
|
||||
return (
|
||||
<Spinner
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,34 @@
|
|||
[data-component="spinner"] {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
/* `width` and `height` props arrive as variables, so classes and other CSS still size the spinner over them, as they
|
||||
did over an SVG's attributes. */
|
||||
[data-slot="spinner-dots"] {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: var(--spinner-width, 18px);
|
||||
height: var(--spinner-height, auto);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
[data-component="spinner"] {
|
||||
color: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Each dot rests at its frozen pose, so reduced motion only has to stop the animation. */
|
||||
[data-slot="spinner-dots"] rect {
|
||||
[data-slot="spinner-dots"] > svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
opacity: var(--still);
|
||||
}
|
||||
|
||||
/* syncSpinPhase marks spinners out of view; `!important` beats the inline animation each dot carries. */
|
||||
[data-slot="spinner-dots"][data-offscreen] rect {
|
||||
[data-slot="spinner-dots"][data-offscreen] > svg {
|
||||
animation-play-state: paused !important;
|
||||
}
|
||||
|
||||
:root[data-motion="reduce"] [data-slot="spinner-dots"] rect {
|
||||
:root[data-motion="reduce"] [data-slot="spinner-dots"] > svg {
|
||||
/* Beats the inline animation each dot carries. */
|
||||
animation: none !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { For, splitProps, type ComponentProps } from "solid-js"
|
||||
import { For, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { syncSpinPhase } from "../hooks/spin-phase"
|
||||
import { radarDots, radarKeyframes, radarLoop } from "./spinner-radar"
|
||||
|
||||
|
|
@ -12,8 +12,20 @@ const dots = radarDots()
|
|||
// The keyframes are added to the document once, the first time a spinner renders.
|
||||
let added = false
|
||||
|
||||
export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: string }) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "data-component"])
|
||||
/**
|
||||
* Each dot is its own SVG over the whole box, drawn exactly where it sat in one SVG. The compositor runs the opacity
|
||||
* animation of an SVG element but not of a shape inside one, so a spinner no longer restyles and repaints on every
|
||||
* frame. `width` and `height` size it like an SVG's attributes, below any CSS size.
|
||||
*/
|
||||
export function Spinner(
|
||||
props: Omit<ComponentProps<"span">, "style"> & {
|
||||
"data-component"?: string
|
||||
width?: number
|
||||
height?: number
|
||||
style?: JSX.CSSProperties
|
||||
},
|
||||
) {
|
||||
const [local, rest] = splitProps(props, ["class", "classList", "data-component", "width", "height", "style"])
|
||||
|
||||
if (!added) {
|
||||
added = true
|
||||
|
|
@ -23,32 +35,34 @@ export function Spinner(props: ComponentProps<"svg"> & { "data-component"?: stri
|
|||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
<span
|
||||
{...rest}
|
||||
ref={syncSpinPhase}
|
||||
viewBox="0 0 16 16"
|
||||
data-component={local["data-component"] ?? "spinner"}
|
||||
data-slot="spinner-dots"
|
||||
classList={{ ...local.classList, [local.class ?? ""]: !!local.class }}
|
||||
fill="currentColor"
|
||||
style={{
|
||||
...local.style,
|
||||
"--spinner-width": local.width === undefined ? undefined : `${local.width}px`,
|
||||
"--spinner-height": local.height === undefined ? undefined : `${local.height}px`,
|
||||
}}
|
||||
>
|
||||
<For each={dots}>
|
||||
{(dot) => (
|
||||
<rect
|
||||
x={dot.x}
|
||||
y={dot.y}
|
||||
width={dot.size}
|
||||
height={dot.size}
|
||||
rx={dot.size * 0.24}
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{
|
||||
"--still": dot.still,
|
||||
animation: dot.steady
|
||||
? undefined
|
||||
: `${keyframes}-${dot.shape} calc(${radarLoop}s * var(--spinner-time, 1)) linear calc(${-radarLoop * (1 - dot.at)}s * var(--spinner-time, 1)) infinite`,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<rect x={dot.x} y={dot.y} width={dot.size} height={dot.size} rx={dot.size * 0.24} />
|
||||
</svg>
|
||||
)}
|
||||
</For>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { type ComponentProps } from "solid-js"
|
|||
import { Spinner } from "../../components/spinner"
|
||||
import "./activity-ring.css"
|
||||
|
||||
export function ActivityRing(props: ComponentProps<"svg">) {
|
||||
export function ActivityRing(props: ComponentProps<typeof Spinner>) {
|
||||
return (
|
||||
<Spinner
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,10 @@
|
|||
--text-shimmer-duration: 1200ms;
|
||||
--text-shimmer-swap: 220ms;
|
||||
--text-shimmer-index: 0;
|
||||
--text-shimmer-angle: 90deg;
|
||||
--text-shimmer-spread: 5.2ch;
|
||||
--text-shimmer-gutter: calc(var(--text-shimmer-spread) + 1ch);
|
||||
--text-shimmer-size: calc(200% + var(--text-shimmer-gutter) + var(--text-shimmer-gutter));
|
||||
--text-shimmer-base-color: var(--text-weak);
|
||||
--text-shimmer-peak-color: var(--text-strong);
|
||||
--text-shimmer-sweep: linear-gradient(
|
||||
var(--text-shimmer-angle),
|
||||
transparent calc(50% - var(--text-shimmer-spread)),
|
||||
var(--text-shimmer-peak-color) 50%,
|
||||
transparent calc(50% + var(--text-shimmer-spread))
|
||||
);
|
||||
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
|
||||
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
|
|
@ -47,8 +38,11 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Clipped to its box, where the old background sweep painted, so the sweep's travel never adds overflow. */
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: var(--text-weaker);
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
color: var(--text-shimmer-base-color);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
@ -56,48 +50,85 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"][data-run="true"] {
|
||||
animation-name: text-shimmer-sweep;
|
||||
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The sweep is the text again in the peak colour, seen through a soft window that crosses it. The window and the text
|
||||
inside it move in opposite directions by the same amount, so the text stays put while the window travels: both are
|
||||
transforms, which the compositor runs, where an animated background position restyled and repainted the text on
|
||||
every frame. The window's centre runs from one gutter before the text to one gutter past it. */
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-sweep"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(-1 * var(--text-shimmer-spread));
|
||||
right: calc(-1 * var(--text-shimmer-spread));
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
mask-image: linear-gradient(
|
||||
90deg,
|
||||
transparent calc(50% - var(--text-shimmer-spread)),
|
||||
#000 50%,
|
||||
transparent calc(50% + var(--text-shimmer-spread))
|
||||
);
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-sweep-text"] {
|
||||
display: block;
|
||||
margin-left: var(--text-shimmer-spread);
|
||||
margin-right: var(--text-shimmer-spread);
|
||||
white-space: pre;
|
||||
color: var(--text-shimmer-peak-color);
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"]
|
||||
[data-slot="text-shimmer-char-shimmer"][data-run="true"]
|
||||
> [data-slot="text-shimmer-sweep"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"]
|
||||
[data-slot="text-shimmer-char-shimmer"][data-run="true"]
|
||||
:is([data-slot="text-shimmer-sweep"], [data-slot="text-shimmer-sweep-text"]) {
|
||||
animation-duration: var(--text-shimmer-duration);
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index) * -1);
|
||||
will-change: background-position;
|
||||
}
|
||||
|
||||
@keyframes text-shimmer-sweep {
|
||||
[data-component="text-shimmer"]
|
||||
[data-slot="text-shimmer-char-shimmer"][data-run="true"]
|
||||
[data-slot="text-shimmer-sweep"] {
|
||||
animation-name: text-shimmer-window;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"]
|
||||
[data-slot="text-shimmer-char-shimmer"][data-run="true"]
|
||||
[data-slot="text-shimmer-sweep-text"] {
|
||||
animation-name: text-shimmer-text;
|
||||
}
|
||||
|
||||
/* The window is one spread wider than the text on each side, so its percentages carry the spread back out. */
|
||||
@keyframes text-shimmer-window {
|
||||
0% {
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
translate: calc(-50% + var(--text-shimmer-spread) - var(--text-shimmer-gutter)) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position:
|
||||
0% 0,
|
||||
0 0;
|
||||
translate: calc(50% - var(--text-shimmer-spread) + var(--text-shimmer-gutter)) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-image: var(--text-shimmer-sweep), var(--text-shimmer-base);
|
||||
background-size:
|
||||
var(--text-shimmer-size) 100%,
|
||||
100% 100%;
|
||||
background-position:
|
||||
100% 0,
|
||||
0 0;
|
||||
background-repeat: no-repeat;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
@keyframes text-shimmer-text {
|
||||
0% {
|
||||
translate: calc(50% + var(--text-shimmer-gutter)) 0;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char-base"] {
|
||||
opacity: 0;
|
||||
100% {
|
||||
translate: calc(-50% - var(--text-shimmer-gutter)) 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,10 +139,11 @@
|
|||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
|
||||
animation: none !important;
|
||||
color: inherit;
|
||||
-webkit-text-fill-color: currentColor;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-sweep"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
|
|||
</span>
|
||||
<span data-slot="text-shimmer-char-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
|
||||
{text()}
|
||||
<span data-slot="text-shimmer-sweep">
|
||||
<span data-slot="text-shimmer-sweep-text">{text()}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Dynamic>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue