- );
-}
-
-// ── Internal SVG renderer ────────────────────────────────────────────────────
-
-interface SVGProps {
- w: number;
- h: number;
- r: number;
- duration: string;
-}
-
-function RunningBorderSVG({ w, h, r, duration }: SVGProps) {
- // Stable unique ID assigned once per component instance.
- const idRef = useRef(null);
- if (idRef.current === null) {
- idRef.current = `_rb${++_uid}`;
- }
- const uid = idRef.current;
-
- // SVG is 2 px larger on each side (inset: -1 px) so the stroke straddles
- // the card's outer edge rather than sitting inside it.
- const svgW = w + 2;
- const svgH = h + 2;
-
- // The rect path traces the card's outer border at (1, 1) in SVG coords.
- const pathD = roundedRectPath(1, 1, w, h, r);
-
- // Trail length and height — fixed values matching the reference design.
- // rx=120: trail arc length in px. ry=12: glow half-height; safe for any card
- // taller than ~24px (glow from opposite edges won't meet at centre).
- const trailRx = 120;
- const trailRy = 12;
-
- const gradId = `${uid}g`;
- const pathId = `${uid}p`;
- const maskId = `${uid}m`;
-
- return (
-
- );
-}
-
-/** Build the SVG path for a rounded rectangle starting at (x, y). */
-function roundedRectPath(x: number, y: number, w: number, h: number, r: number): string {
- return [
- `M ${x + r} ${y}`,
- `H ${x + w - r}`,
- `A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,
- `V ${y + h - r}`,
- `A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}`,
- `H ${x + r}`,
- `A ${r} ${r} 0 0 1 ${x} ${y + h - r}`,
- `V ${y + r}`,
- `A ${r} ${r} 0 0 1 ${x + r} ${y}`,
- 'Z',
- ].join(' ');
-}
diff --git a/packages/web/src/components/sdk/SDKAssistantMessage.tsx b/packages/web/src/components/sdk/SDKAssistantMessage.tsx
index 43ed76c0c..ec9642fd8 100644
--- a/packages/web/src/components/sdk/SDKAssistantMessage.tsx
+++ b/packages/web/src/components/sdk/SDKAssistantMessage.tsx
@@ -59,9 +59,10 @@ interface Props {
responses: QuestionDraftResponse[]
) => void;
/**
- * When true, child tool / thinking / subagent blocks in this message are
- * each wrapped in so the animated arc traces their border.
- * Set by the compact task thread renderer for the last non-terminal message.
+ * When true, child tool / thinking / subagent blocks in this message each
+ * show a faint white shimmer sweep (the `.running-shimmer` overlay) across
+ * their surface. Set by the compact task thread renderer for the last
+ * non-terminal message.
*/
isRunning?: boolean;
/** Tool use IDs within this message whose cards are currently running. */
@@ -257,10 +258,10 @@ export function SDKAssistantMessage({
// If so, we should not show a checkbox for this message (the sub-agent messages will have their own).
// Normal mode - original layout
//
- // When isRunning, ALL blocks in this message receive the animated arc so
+ // When isRunning, ALL blocks in this message show the running shimmer so
// every block type is visible for debugging/verification. Each component
- // applies the arc via a wrapper div (not directly on its overflow:hidden
- // root) so the inset:-2px extension isn't clipped.
+ // renders the `.running-shimmer` overlay inside its own card, contained by
+ // the card's overflow-hidden rounded surface.
const messageContent = (
void;
- /** When true, wrap this block's outermost visible bordered card in
- * . Used by the compact task thread renderer to indicate the
- * last still-executing event message. */
+ /** When true, show a faint white shimmer sweep (the `.running-shimmer`
+ * overlay) across this block's surface. Used by the compact task thread
+ * renderer to indicate the last still-executing event message. */
isRunning?: boolean;
/** Render Task/Agent tool_use blocks as generic tool cards when true. */
flattenSubagentTools?: boolean;
diff --git a/packages/web/src/components/sdk/SDKMessageRenderer.tsx b/packages/web/src/components/sdk/SDKMessageRenderer.tsx
index 474644719..1b66c8a6d 100644
--- a/packages/web/src/components/sdk/SDKMessageRenderer.tsx
+++ b/packages/web/src/components/sdk/SDKMessageRenderer.tsx
@@ -92,9 +92,9 @@ interface Props {
showToolResultUserMessages?: boolean;
/**
* When true, the last non-terminal event message in a compact task thread is
- * still executing. The receiving component wraps its visible boundary element
- * (e.g. the assistant message bubble or tool card) in so the
- * animated arc traces exactly that element's rounded-rect border.
+ * still executing. The receiving component shows a faint white shimmer sweep
+ * (the `.running-shimmer` overlay) across its visible boundary element (e.g.
+ * the assistant message bubble or tool card).
*/
isRunning?: boolean;
/** Tool use IDs within this assistant message whose cards are currently running. */
diff --git a/packages/web/src/components/sdk/SubagentBlock.tsx b/packages/web/src/components/sdk/SubagentBlock.tsx
index f622554c6..d5cdd063d 100644
--- a/packages/web/src/components/sdk/SubagentBlock.tsx
+++ b/packages/web/src/components/sdk/SubagentBlock.tsx
@@ -12,7 +12,6 @@
import type { ComponentChild } from 'preact';
import { useState, useMemo } from 'preact/hooks';
import { cn } from '../../lib/utils.ts';
-import { RunningBorder } from './RunningBorder.tsx';
import MarkdownRenderer from '../chat/MarkdownRenderer.tsx';
import type { AgentInput } from '@hyperneo/shared/sdk/sdk-tools.d.ts';
import type {
@@ -133,8 +132,8 @@ interface SubagentBlockProps {
taskProgressMap?: Map;
/** Additional CSS classes */
className?: string;
- /** When true, wrap this block in so the animated arc traces
- * this card's outer rounded-rectangle border. */
+ /** When true, show a faint white shimmer sweep (the `.running-shimmer`
+ * overlay) across this block's surface. */
isRunning?: boolean;
}
@@ -416,12 +415,19 @@ export function SubagentBlock({
return completed;
}, [nestedMessages]);
- // The running-state arc is rendered by as an absolutely
- // positioned SVG sibling (applied via a wrapper below). It cannot live on
- // this div because overflow:hidden would clip the SVG that extends slightly
- // past the card's outer edge.
+ // The running-state shimmer is a `.running-shimmer` overlay rendered inside
+ // this card while isRunning (added below). overflow-hidden contains it to
+ // the card's rounded surface.
const block = (
-
+
{/* Header */}
)}
+ {isRunning && }
);
- if (isRunning) {
- return {block};
- }
+
return block;
}
diff --git a/packages/web/src/components/sdk/ThinkingBlock.tsx b/packages/web/src/components/sdk/ThinkingBlock.tsx
index 80c405738..b0a8c020b 100644
--- a/packages/web/src/components/sdk/ThinkingBlock.tsx
+++ b/packages/web/src/components/sdk/ThinkingBlock.tsx
@@ -9,15 +9,14 @@
import { useState, useRef, useLayoutEffect } from 'preact/hooks';
import { cn } from '../../lib/utils.ts';
-import { RunningBorder } from './RunningBorder.tsx';
interface ThinkingBlockProps {
content: string;
className?: string;
/** Compact mode: shows only first line with no expand/collapse button */
compact?: boolean;
- /** When true, wrap this card in so the animated arc traces
- * this card's outer rounded-rectangle border. */
+ /** When true, show a faint white shimmer sweep (the `.running-shimmer`
+ * overlay) across this card's surface. */
isRunning?: boolean;
/** Optional estimated token count for this thinking block (persisted from SDK) */
estimatedTokens?: number;
@@ -78,7 +77,13 @@ export function ThinkingBlock({
const inner = (
{/* Header */}
@@ -176,11 +181,10 @@ export function ThinkingBlock({
)}
+
+ {isRunning && }
);
- if (isRunning) {
- return {inner};
- }
return inner;
}
diff --git a/packages/web/src/components/sdk/__tests__/ThinkingBlock.test.tsx b/packages/web/src/components/sdk/__tests__/ThinkingBlock.test.tsx
index 20f80f218..8425c35f1 100644
--- a/packages/web/src/components/sdk/__tests__/ThinkingBlock.test.tsx
+++ b/packages/web/src/components/sdk/__tests__/ThinkingBlock.test.tsx
@@ -333,4 +333,19 @@ describe('ThinkingBlock', () => {
expect(container.textContent).toContain('Thinking');
});
});
+
+ describe('Running shimmer indicator', () => {
+ it('shows the .running-shimmer overlay while running', () => {
+ const { container } = render();
+
+ const card = container.querySelector('[data-testid="thinking-block"]');
+ expect(card?.querySelector('.running-shimmer')).toBeTruthy();
+ });
+
+ it('does not show the .running-shimmer overlay when not running', () => {
+ const { container } = render();
+
+ expect(container.querySelector('.running-shimmer')).toBeNull();
+ });
+ });
});
diff --git a/packages/web/src/components/sdk/tools/ToolResultCard.tsx b/packages/web/src/components/sdk/tools/ToolResultCard.tsx
index 0ca0be64c..270f2cdc5 100644
--- a/packages/web/src/components/sdk/tools/ToolResultCard.tsx
+++ b/packages/web/src/components/sdk/tools/ToolResultCard.tsx
@@ -19,7 +19,6 @@ import {
} from './tool-utils.ts';
import { isFileReadOutput, isFileEditOutput, isFileWriteOutput } from './sdk-tool-types.ts';
import { cn } from '../../../lib/utils.ts';
-import { RunningBorder } from '../RunningBorder.tsx';
import { connectionManager } from '../../../lib/connection-manager.ts';
import { toast } from '../../../lib/toast.ts';
import { ConfirmModal } from '../../ui/ConfirmModal.tsx';
@@ -257,12 +256,19 @@ export function ToolResultCard({
const lineCountDisplay = getLineCountDisplay();
// Default & detailed variants - full display with expand/collapse
- // Note: the running-state arc is rendered by as an absolutely
- // positioned SVG sibling (applied via a wrapper below). It cannot live on this
- // div because overflow:hidden would clip the SVG that extends slightly past
- // the card's outer edge.
+ // Note: the running-state shimmer is a `.running-shimmer` overlay rendered
+ // inside this card while isRunning (added below). overflow-hidden contains
+ // it to the card's rounded surface.
const card = (
-
+
{/* Header - clickable to expand/collapse */}
);
- if (isRunning) {
- return {card};
- }
+
return card;
}
diff --git a/packages/web/src/components/sdk/tools/tool-types.ts b/packages/web/src/components/sdk/tools/tool-types.ts
index 55c2806a4..5d6f82013 100644
--- a/packages/web/src/components/sdk/tools/tool-types.ts
+++ b/packages/web/src/components/sdk/tools/tool-types.ts
@@ -134,8 +134,8 @@ export interface ToolResultCardProps {
isOutputRemoved?: boolean;
/** Disable expand/collapse and hide the chevron icon */
disableExpand?: boolean;
- /** When true, wrap this card in so the animated arc traces
- * this card's outer rounded-rectangle border. */
+ /** When true, show a faint white shimmer sweep (the `.running-shimmer`
+ * overlay) across this card's surface. */
isRunning?: boolean;
/** Terminal task_notification for this tool_use (status/summary/usage).
* Folded onto the card instead of rendered as a standalone system row. */
diff --git a/packages/web/src/styles.css b/packages/web/src/styles.css
index 624f4676e..e62d2b0ac 100644
--- a/packages/web/src/styles.css
+++ b/packages/web/src/styles.css
@@ -747,12 +747,41 @@ html.keyboard-open [data-testid="bottom-tab-bar"] {
padding — the wrapper already accounts for both the tab bar and home indicator. */
/* ── Space task thread — compact running-block indicator ─────────────────────
- The animated border-light for running (non-terminal) event messages is now
- rendered by the Preact component in
- packages/web/src/components/sdk/RunningBorder.tsx. It uses an inline SVG
- with and a radial-gradient mask to trace the element's
- rounded-rectangle border exactly.
-
- No global CSS class is needed for the effect; consumers (ThinkingBlock,
- ToolResultCard, SubagentBlock) wrap themselves in when
- `isRunning` is true. */
+ A faint white left→right gradient sweep across a running block's surface.
+ Applied by ThinkingBlock / ToolResultCard / SubagentBlock as a
+ `.running-shimmer` overlay while a block is still executing (`isRunning`).
+ Pure CSS — no SVG, no ResizeObserver. Honors prefers-reduced-motion. */
+@keyframes runningShimmer {
+ /* background-position percentages are resolution-independent: with
+ background-size 200%, animating 130% → -30% sweeps the band fully off the
+ left edge, across the card, and fully off the right edge (band center
+ fraction = 1 - P), so the loop seam is invisible. */
+ 0% {
+ background-position: 130% 0;
+ }
+ 100% {
+ background-position: -30% 0;
+ }
+}
+
+.running-shimmer {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0) 45%,
+ rgba(255, 255, 255, 0.18) 50%,
+ rgba(255, 255, 255, 0) 55%
+ );
+ background-size: 200% 100%;
+ background-repeat: no-repeat;
+ animation: runningShimmer 1.8s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .running-shimmer {
+ animation: none;
+ display: none;
+ }
+}