diff --git a/packages/web/src/components/sdk/RunningBorder.tsx b/packages/web/src/components/sdk/RunningBorder.tsx deleted file mode 100644 index 232f600db..000000000 --- a/packages/web/src/components/sdk/RunningBorder.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * RunningBorder — SVG-based animated border light for running task blocks. - * - * Uses SVG `animateMotion` + `mpath` to glide a masked white stroke exactly - * around the element's border path. Adapts to any rendered size via - * ResizeObserver so no hardcoded dimensions are required. - * - * Usage: - * - *
- *
- */ - -import { useRef, useLayoutEffect, useState } from 'preact/hooks'; -import type { ComponentChildren } from 'preact'; - -// Monotonically-increasing counter for unique SVG def IDs per instance. -let _uid = 0; - -interface RunningBorderProps { - children: ComponentChildren; - /** Border-radius in px; should match the wrapped card. Default: 8 (rounded-lg). */ - borderRadius?: number; - /** One full revolution duration. Default: '6s'. */ - duration?: string; - /** Extra classes applied to the outer wrapper div. */ - className?: string; -} - -/** - * Wraps `children` in a `position: relative` container, measures it, and - * renders a path-following animated border light on top via an inline SVG. - * The light follows the exact rounded-rectangle outline of the card. - */ -export function RunningBorder({ - children, - borderRadius = 8, - duration = '6s', - className, -}: RunningBorderProps) { - const wrapperRef = useRef(null); - const [size, setSize] = useState<{ w: number; h: number } | null>(null); - - useLayoutEffect(() => { - const el = wrapperRef.current; - if (!el) return; - const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight }); - measure(); - const ro = new ResizeObserver(measure); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - return ( -
- {children} - {size && size.w > 0 && size.h > 0 && ( - - )} -
- ); -} - -// ── 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 */}