diff --git a/.changeset/quiet-parallel-agents.md b/.changeset/quiet-parallel-agents.md new file mode 100644 index 0000000000..09665a265c --- /dev/null +++ b/.changeset/quiet-parallel-agents.md @@ -0,0 +1,7 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +"@opencode-ai/ui": patch +--- + +Stop offscreen loading animations while preserving their original appearance, and release obsolete transcript pages from memory. diff --git a/packages/kilo-ui/src/components/spinner.css b/packages/kilo-ui/src/components/spinner.css new file mode 100644 index 0000000000..d1bd402f55 --- /dev/null +++ b/packages/kilo-ui/src/components/spinner.css @@ -0,0 +1,5 @@ +@media (prefers-reduced-motion: reduce) { + [data-component="spinner"] > rect { + animation: none !important; + } +} diff --git a/packages/kilo-ui/src/stories/spinner.stories.tsx b/packages/kilo-ui/src/stories/spinner.stories.tsx index 11b8ba7f2b..edec1d094c 100644 --- a/packages/kilo-ui/src/stories/spinner.stories.tsx +++ b/packages/kilo-ui/src/stories/spinner.stories.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource solid-js */ import type { Meta, StoryObj } from "storybook-solidjs-vite" -import { Spinner } from "@opencode-ai/ui/spinner" +import { Spinner } from "@kilocode/kilo-ui/spinner" const meta: Meta = { title: "Components/Spinner", @@ -20,6 +20,16 @@ export const Large: Story = { render: () => , } +export const Parallel: Story = { + render: () => ( +
+ {Array.from({ length: 24 }, () => ( + + ))} +
+ ), +} + export const Colored: Story = { render: () => , } diff --git a/packages/kilo-ui/src/styles/index.css b/packages/kilo-ui/src/styles/index.css index adc0af0f53..37b1f47029 100644 --- a/packages/kilo-ui/src/styles/index.css +++ b/packages/kilo-ui/src/styles/index.css @@ -35,6 +35,7 @@ @import "../components/select.css"; @import "../components/session.css"; @import "../components/settings-sidebar.css"; +@import "../components/spinner.css"; @import "../components/status-indicator.css"; @import "../components/switch.css"; @import "../components/tabs.css"; diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 0f290023ca..96d8889e01 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -1451,6 +1451,21 @@ export const SessionProvider: ParentComponent = (props) => { // proxy creation for each message object dominated the trace (~900ms). // "prepend" / "reconcile": reconcile to preserve existing proxies. if (mode === "replace") { + const keep = new Set(merged.map((message) => message.id)) + const removed = current.filter((message) => !keep.has(message.id)).map((message) => message.id) + if (removed.length > 0) { + for (const id of removed) { + stash.remove(id) + optimisticParts.delete(id) + } + clearHiddenErrors(removed) + setStore( + "parts", + produce((parts) => { + for (const id of removed) delete parts[id] + }), + ) + } setStore("messages", sessionID, merged) } else { setStore("messages", sessionID, reconcile(merged, { key: "id" })) diff --git a/packages/ui/src/components/spinner.css b/packages/ui/src/components/spinner.css index 2ca474dc3c..1630282f0b 100644 --- a/packages/ui/src/components/spinner.css +++ b/packages/ui/src/components/spinner.css @@ -4,3 +4,9 @@ width: 18px; aspect-ratio: 1; } + +/* kilocode_change start */ +[data-component="spinner"][data-paused] > rect { + animation: none !important; +} +/* kilocode_change end */ diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index f8aacb6e29..1df533acd0 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -1,4 +1,5 @@ import { ComponentProps, For } from "solid-js" +import { observe } from "../kilocode/spinner" // kilocode_change const outerIndices = new Set([1, 2, 4, 7, 8, 11, 13, 14]) const cornerIndices = new Set([0, 3, 12, 15]) @@ -19,6 +20,7 @@ export function Spinner(props: { }) { return ( () +let observer: IntersectionObserver | undefined + +function update(root: Element, visible: boolean) { + root.toggleAttribute("data-paused", !visible || document.visibilityState === "hidden") +} + +function refresh() { + for (const [root, visible] of roots) update(root, visible) +} + +export function observe(root: SVGSVGElement) { + onMount(() => { + if (typeof IntersectionObserver === "undefined") return + if (!observer) { + observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!roots.has(entry.target)) continue + const visible = entry.isIntersecting && entry.intersectionRatio > 0 + roots.set(entry.target, visible) + update(entry.target, visible) + } + }) + document.addEventListener("visibilitychange", refresh) + } + roots.set(root, false) + update(root, false) + observer.observe(root) + onCleanup(() => { + observer?.unobserve(root) + roots.delete(root) + root.removeAttribute("data-paused") + if (roots.size > 0) return + observer?.disconnect() + observer = undefined + document.removeEventListener("visibilitychange", refresh) + }) + }) +}