sync: update from private repo (9a86f49b)
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync
2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions
+62 -29
View File
@@ -1,12 +1,17 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useActivePet } from '../../hooks/useActivePet';
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
import { useStableNodePromotion } from '../../hooks/useStableNodePromotion';
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
import { extractLatestToolName, petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
import type { LastToolEvent } from '../../hooks/useJobStream';
import { petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
import { PetSprite } from './PetSprite';
import { ToolSpark } from './ToolSpark';
const JUMP_DURATION_MS = 1500;
// A whole number of petJump cycles (3 × 0.55s) so the hop ends near
// translateY(0) instead of snapping down from mid-arc when it stops
// (Fable review #2).
const JUMP_DURATION_MS = 1650;
const DONE_FLOURISH_MS = 1000;
function usePrefersReducedMotion(): boolean {
@@ -26,14 +31,17 @@ function usePrefersReducedMotion(): boolean {
export function ChatPetOverlay({
taskId,
taskStatus,
currentActivity,
lastToolEvent,
workerId,
lastBackendId,
className,
}: {
taskId: number | null;
taskStatus: string | null;
currentActivity: string | null;
/** Most recent SSE tool_use/tool_result, keyed by callId (Pets Phase 1,
* U0). Drives the jump + spark triggers — see
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
lastToolEvent: LastToolEvent | null;
workerId: string | null;
/**
* Physical backend id when the worker is a proxy (LiteLLM deployment
@@ -60,56 +68,81 @@ export function ChatPetOverlay({
// anyway. Prefer the proxy-backend mapping over the worker mapping
// — same precedence as useActivePet uses for sprite selection.
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
// U3: hold the promotion for ~2 idle polls before letting it lapse, so a
// single missed busy sample on the shared node doesn't flap the pet back
// to idle and immediately back up.
const stableNodeAnimState = useStableNodePromotion(nodeAnimState);
const taskBaseState = petStateFromJobStatus(taskStatus, taskId);
// Promote an 'idle' base state to 'running' when the backing node is
// actively processing. Don't override informative states like
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
// that node.busy doesn't.
const baseState: PetRuntimeState = taskBaseState === 'idle' && nodeAnimState === 'running'
// that node.busy doesn't. Real task-status states stay immediate; only
// this node-derived promotion goes through the hysteresis above.
const baseState: PetRuntimeState = taskBaseState === 'idle' && stableNodeAnimState === 'running'
? 'running'
: taskBaseState;
const baseStateRef = useRef(baseState);
baseStateRef.current = baseState;
const reducedMotion = (data?.settings.reducedMotion ?? false) || prefersReducedMotion;
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
// U1/U0: jump is a one-shot overlay on the outer sprite wrapper, kept
// entirely separate from `displayState` (the pose fed to PetSprite's
// frame-cycle) so re-triggering it never restarts the inner animation.
// Triggered by SSE `tool_use` (callId change) instead of the 5s-polled
// `currentActivity`, which also carried `LLM: …` status text and caused
// false jumps/sparks.
const [jumping, setJumping] = useState(false);
const jumpTimerRef = useRef<number | null>(null);
const lastJumpCallIdRef = useRef<string | null>(null);
useEffect(() => () => {
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
}, []);
// Reset display to base state whenever it changes; brief 'done' flourish then
// settle to idle so the wave doesn't loop forever.
useEffect(() => {
setDisplayState(baseState);
// Base state changed out from under an in-flight jump (e.g. the task
// just finished) — stop the hop rather than let it bounce over a
// done/error/waiting pose it no longer applies to.
if (baseState !== 'running' && baseState !== 'runningAlt' && baseState !== 'dispatching') {
if (jumpTimerRef.current != null) {
window.clearTimeout(jumpTimerRef.current);
jumpTimerRef.current = null;
}
setJumping(false);
}
if (baseState !== 'done') return;
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
return () => window.clearTimeout(timer);
}, [baseState]);
// When the current activity changes (= a new tool fired) during active
// execution, jump for ~1.5s and revert to whatever the base state is by then.
useEffect(() => {
if (!currentActivity) return;
if (reducedMotion) return;
if (!lastToolEvent || !lastToolEvent.callId) return;
if (lastToolEvent.callId === lastJumpCallIdRef.current) return;
lastJumpCallIdRef.current = lastToolEvent.callId;
const active = baseStateRef.current;
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
setDisplayState('jumping');
const timer = window.setTimeout(() => {
const current = baseStateRef.current;
if (current === 'running' || current === 'runningAlt' || current === 'dispatching') {
setDisplayState(current);
}
// For other base states (done / error / idle / waiting) the dedicated
// effects above will have taken over; don't fight them here.
setJumping(true);
// U5: a later tool_use before this fires just pushes the end time out
// (new callId -> this effect re-runs, clears + reschedules) — it never
// toggles `jumping` off and back on, so the CSS animation on the outer
// wrapper never restarts.
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
jumpTimerRef.current = window.setTimeout(() => {
setJumping(false);
jumpTimerRef.current = null;
}, JUMP_DURATION_MS);
return () => window.clearTimeout(timer);
}, [currentActivity]);
const toolName = useMemo(
() => extractLatestToolName(currentActivity),
[currentActivity],
);
}, [lastToolEvent, reducedMotion]);
if (isLoading || !data?.settings.enabled || !data.pet) return null;
const reducedMotion = data.settings.reducedMotion || prefersReducedMotion;
return (
<div
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
@@ -117,8 +150,7 @@ export function ChatPetOverlay({
aria-hidden="true"
>
<ToolSpark
toolName={toolName}
activityKey={currentActivity}
event={lastToolEvent}
enabled={data.settings.toolSparkEnabled}
reducedMotion={reducedMotion}
/>
@@ -131,6 +163,7 @@ export function ChatPetOverlay({
gridRows={data.gridRows}
framesPerRow={framesPerRow}
state={displayState}
jumping={jumping}
size={data.settings.size}
reducedMotion={reducedMotion}
/>
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { PetSprite } from './PetSprite';
describe('PetSprite', () => {
it('falls back when the configured pet image cannot be loaded', async () => {
const OriginalImage = globalThis.Image;
class FailedImage {
onerror: (() => void) | null = null;
set src(_: string) { queueMicrotask(() => this.onerror?.()); }
}
vi.stubGlobal('Image', FailedImage);
try {
const { container } = render(<PetSprite name="Toast pet" imageUrl="/broken.png" frameWidth={null} frameHeight={null} gridCols={null} gridRows={null} framesPerRow={null} state="done" size={48} reducedMotion />);
await waitFor(() => expect(container.querySelector('.pet-sprite-fallback')).toBeInTheDocument());
// U1: the outer (titled) wrapper only carries the jump overlay now —
// the base-state class lives on the nested `.pet-sprite-pose` layer.
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite');
expect(container.querySelector('.pet-sprite-pose')).toHaveClass('pet-sprite-done');
} finally {
vi.stubGlobal('Image', OriginalImage);
}
});
it('U1: jumping never changes the inner frame-cycle animation name', () => {
const { container, rerender } = render(
<PetSprite
name="Toast pet"
imageUrl="/sprite.png"
frameWidth={null}
frameHeight={null}
gridCols={8}
gridRows={9}
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
state="running"
jumping={false}
size={48}
reducedMotion={false}
/>,
);
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
expect(grid).toBeInTheDocument();
const before = grid.style.animation;
expect(before).toContain('petFrameCycle8');
rerender(
<PetSprite
name="Toast pet"
imageUrl="/sprite.png"
frameWidth={null}
frameHeight={null}
gridCols={8}
gridRows={9}
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
state="running"
jumping={true}
size={48}
reducedMotion={false}
/>,
);
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
expect(gridAfter.style.animation).toBe(before);
// The jump overlay lands on the outer (titled) wrapper, not the pose/grid layers.
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite-jumping');
expect(container.querySelector('.pet-sprite-pose')).not.toHaveClass('pet-sprite-jumping');
});
it('U4: does not start the frame-cycle animation while framesPerRow is unresolved', () => {
const { container, rerender } = render(
<PetSprite
name="Toast pet"
imageUrl="/sprite.png"
frameWidth={null}
frameHeight={null}
gridCols={8}
gridRows={9}
framesPerRow={null}
state="running"
size={48}
reducedMotion={false}
/>,
);
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
expect(grid).toBeInTheDocument();
expect(grid.style.animation).toBe('');
rerender(
<PetSprite
name="Toast pet"
imageUrl="/sprite.png"
frameWidth={null}
frameHeight={null}
gridCols={8}
gridRows={9}
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
state="running"
size={48}
reducedMotion={false}
/>,
);
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
expect(gridAfter.style.animation).toContain('petFrameCycle8');
});
it('positions sprite frames from the full grid without wrapping', () => {
const { container } = render(
<PetSprite
name="Toast pet"
imageUrl="/sprite.png"
frameWidth={null}
frameHeight={null}
gridCols={8}
gridRows={9}
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
state="running"
size={48}
reducedMotion={false}
/>,
);
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
expect(grid.style.backgroundRepeat).toBe('no-repeat');
expect(Number.parseFloat(grid.style.getPropertyValue('--pet-frame-1-position'))).toBeCloseTo(100 / 7);
expect(grid.style.getPropertyValue('--pet-frame-7-position')).toBe('100%');
});
});
+100 -48
View File
@@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
@@ -20,6 +21,7 @@ export function PetSprite({
gridRows,
framesPerRow,
state,
jumping = false,
size,
reducedMotion,
}: {
@@ -31,79 +33,129 @@ export function PetSprite({
gridRows: number | null;
framesPerRow: number[] | null;
state: PetRuntimeState;
/** One-shot hop, layered on the outer wrapper only (U1). Never changes
* `state`, so the inner frame-cycle animation below never restarts
* when a tool fires mid-run. See
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
jumping?: boolean;
size: number;
reducedMotion: boolean;
}) {
const className = [
const [imageFailed, setImageFailed] = useState(false);
useEffect(() => {
setImageFailed(false);
if (!imageUrl) return;
const image = new Image();
image.onerror = () => setImageFailed(true);
image.src = imageUrl;
}, [imageUrl]);
const usableImageUrl = imageFailed ? null : imageUrl;
// Outer layer: the jump hop only. Deliberately carries no pose/state
// class so toggling it never disturbs the pose layer's continuous
// wobble animation or the inner frame-cycle (U1). Nested transforms
// compose, so a hop + the base-state wobble render together.
const outerClassName = [
'pet-sprite',
jumping && !reducedMotion ? 'pet-sprite-jumping' : '',
reducedMotion ? 'pet-sprite-reduced' : '',
].filter(Boolean).join(' ');
// Pose layer: the continuous base-state wobble (idle/run/wait/done/
// error) and the sprite-sheet row/clip selection. Always reflects
// `state` as-is — jumping never reaches this layer.
const poseClassName = [
'pet-sprite-pose',
`pet-sprite-${state}`,
reducedMotion ? 'pet-sprite-reduced' : '',
].filter(Boolean).join(' ');
const useGridCrop = !!(imageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
const useFrameCrop = !useGridCrop && !!(imageUrl && frameWidth && frameHeight);
const useGridCrop = !!(usableImageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
const useFrameCrop = !useGridCrop && !!(usableImageUrl && frameWidth && frameHeight);
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
const bgPosY = useGridCrop && gridRows! > 1
? `${(stateRow / (gridRows! - 1)) * 100}%`
: '0%';
// U4: framesPerRow starts null and resolves asynchronously (canvas
// analysis of the spritesheet). Before it resolves, don't start the
// frame-cycle at all — cycling through the gridCols fallback walks
// past columns that may be transparent on rows with fewer filled
// frames, producing a flash right after mount. Stay on frame 0 (the
// default background-position) until analysis resolves.
const analysisResolved = framesPerRow !== null;
const detectedFrames = framesPerRow?.[stateRow];
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
const cycleAnimation = useGridCrop && !reducedMotion && rowFrameCount > 1
const cycleAnimation = useGridCrop && !reducedMotion && analysisResolved && rowFrameCount > 1
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
: undefined;
const framePositions = useGridCrop
? Array.from({ length: 8 }, (_, index) => {
const clampedIndex = Math.min(index, gridCols! - 1);
return gridCols! > 1 ? `${(clampedIndex / (gridCols! - 1)) * 100}%` : '0%';
})
: [];
return (
<div
className={className}
style={{
width: size,
height: size,
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
}}
className={outerClassName}
style={{ width: size, height: size }}
aria-hidden="true"
title={name}
>
{imageUrl ? (
useGridCrop ? (
<div
className="pet-sprite-grid"
style={{
width: size,
height: size,
backgroundImage: `url(${imageUrl})`,
backgroundRepeat: 'repeat-x',
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
backgroundPositionY: bgPosY,
animation: cycleAnimation,
imageRendering: 'auto',
}}
/>
) : useFrameCrop ? (
<img
src={imageUrl}
alt=""
draggable={false}
style={{
width: 'auto',
height: 'auto',
maxWidth: 'none',
maxHeight: 'none',
objectFit: 'none',
transformOrigin: '0 0',
transform: `scale(${size / frameWidth!})`,
}}
/>
<div
className={poseClassName}
style={{
width: size,
height: size,
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
}}
>
{usableImageUrl ? (
useGridCrop ? (
<div
className="pet-sprite-grid"
style={{
width: size,
height: size,
backgroundImage: `url(${usableImageUrl})`,
backgroundRepeat: 'no-repeat',
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
backgroundPositionY: bgPosY,
animation: cycleAnimation,
imageRendering: 'auto',
...Object.fromEntries(framePositions.map((position, index) => [
`--pet-frame-${index}-position`,
position,
])),
}}
/>
) : useFrameCrop ? (
<img
src={usableImageUrl}
alt=""
draggable={false}
style={{
width: 'auto',
height: 'auto',
maxWidth: 'none',
maxHeight: 'none',
objectFit: 'none',
transformOrigin: '0 0',
transform: `scale(${size / frameWidth!})`,
}}
/>
) : (
<img src={usableImageUrl} alt="" draggable={false} />
)
) : (
<img src={imageUrl} alt="" draggable={false} />
)
) : (
<div className="pet-sprite-fallback">
<span />
<span />
</div>
)}
<div className="pet-sprite-fallback">
<span />
<span />
</div>
)}
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, expect, it, vi } from 'vitest';
import { act, render } from '@testing-library/react';
import { ToolSpark } from './ToolSpark';
import type { LastToolEvent } from '../../hooks/useJobStream';
function toolEvent(name: string, isError: boolean | null, callId: string): LastToolEvent {
return { name, isError, callId, ts: Date.now() };
}
describe('ToolSpark', () => {
it('U7: does not render a spark for the tool_use moment (isError === null)', () => {
const { container } = render(
<ToolSpark event={toolEvent('WebSearch', null, 'c0')} enabled reducedMotion={false} />,
);
expect(container.querySelector('.tool-spark-burst')).toBeNull();
});
it('U6: renders a search-ripple spark once the result resolves', () => {
const { container, rerender } = render(
<ToolSpark event={toolEvent('WebSearch', null, 'c1')} enabled reducedMotion={false} />,
);
expect(container.querySelector('.tool-spark-burst')).toBeNull();
rerender(<ToolSpark event={toolEvent('WebSearch', false, 'c1')} enabled reducedMotion={false} />);
const burst = container.querySelector('.tool-spark-burst');
expect(burst).toBeInTheDocument();
expect(burst).toHaveAttribute('data-tool-kind', 'search');
expect(burst).toHaveClass('tool-spark-mode-ripple');
expect(burst).toHaveClass('tool-spark-success');
});
it('U6: renders a terminal-blink spark for Bash', () => {
const { container } = render(
<ToolSpark event={toolEvent('Bash', false, 'c2')} enabled reducedMotion={false} />,
);
const burst = container.querySelector('.tool-spark-burst');
expect(burst).toHaveAttribute('data-tool-kind', 'terminal');
expect(burst).toHaveClass('tool-spark-mode-blink');
});
it('U6: falls back to the default star spark for uncategorized tools', () => {
const { container } = render(
<ToolSpark event={toolEvent('SomeCustomTool', false, 'c3')} enabled reducedMotion={false} />,
);
const burst = container.querySelector('.tool-spark-burst');
expect(burst).toHaveAttribute('data-tool-kind', 'spark');
expect(burst).toHaveClass('tool-spark-mode-star');
expect(burst).not.toHaveClass('tool-spark-mode-ripple');
});
it('U7: distinguishes success vs failure by color class and the "!" mark', () => {
const { container: successContainer } = render(
<ToolSpark event={toolEvent('Read', false, 'ok-1')} enabled reducedMotion={false} />,
);
expect(successContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-success');
expect(successContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-error');
expect(successContainer.querySelector('.tool-spark-error-mark')).toBeNull();
const { container: errorContainer } = render(
<ToolSpark event={toolEvent('Read', true, 'err-1')} enabled reducedMotion={false} />,
);
expect(errorContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-error');
expect(errorContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-success');
expect(errorContainer.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
});
it('U7: reducedMotion keeps only the color/mark — no particles, no per-kind motion class', () => {
const { container } = render(
<ToolSpark event={toolEvent('Bash', true, 'rm-1')} enabled reducedMotion={true} />,
);
const burst = container.querySelector('.tool-spark-burst');
expect(burst).toHaveClass('tool-spark-error');
expect(burst?.className ?? '').not.toMatch(/tool-spark-mode-/);
expect(container.querySelector('.tool-spark-particle')).toBeNull();
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
});
it('U8: coalesces rapid results within the window into a ×N combo badge', () => {
vi.useFakeTimers();
try {
const { container, rerender } = render(
<ToolSpark event={toolEvent('Read', false, 'a1')} enabled reducedMotion={false} />,
);
const initialBurst = container.querySelector('.tool-spark-burst');
const initialParticle = container.querySelector('.tool-spark-particle');
// A single result never shows a combo count.
expect(container.querySelector('.tool-spark-combo')).toBeNull();
act(() => { vi.advanceTimersByTime(200); });
rerender(<ToolSpark event={toolEvent('Read', false, 'a2')} enabled reducedMotion={false} />);
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
act(() => { vi.advanceTimersByTime(200); });
rerender(<ToolSpark event={toolEvent('Grep', false, 'a3')} enabled reducedMotion={false} />);
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×3');
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
} finally {
vi.useRealTimers();
}
});
it('U8: resets the combo once the coalesce window elapses without a new result', () => {
vi.useFakeTimers();
try {
const { container, rerender } = render(
<ToolSpark event={toolEvent('Read', false, 'b1')} enabled reducedMotion={false} />,
);
act(() => { vi.advanceTimersByTime(200); });
rerender(<ToolSpark event={toolEvent('Read', false, 'b2')} enabled reducedMotion={false} />);
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
// Let the 600ms coalesce window lapse before the next result arrives.
act(() => { vi.advanceTimersByTime(700); });
rerender(<ToolSpark event={toolEvent('Read', false, 'b3')} enabled reducedMotion={false} />);
expect(container.querySelector('.tool-spark-combo')).toBeNull();
} finally {
vi.useRealTimers();
}
});
it('U8: promotes a coalesced combo to error without restarting its particles', () => {
vi.useFakeTimers();
try {
const { container, rerender } = render(
<ToolSpark event={toolEvent('Read', false, 'error-combo-1')} enabled reducedMotion={false} />,
);
const initialBurst = container.querySelector('.tool-spark-burst');
const initialParticle = container.querySelector('.tool-spark-particle');
act(() => { vi.advanceTimersByTime(200); });
rerender(<ToolSpark event={toolEvent('Grep', true, 'error-combo-2')} enabled reducedMotion={false} />);
const burst = container.querySelector('.tool-spark-burst');
expect(burst).toBe(initialBurst);
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
expect(burst).toHaveClass('tool-spark-error');
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
} finally {
vi.useRealTimers();
}
});
it('hides the spark after the hold duration with no further results', () => {
vi.useFakeTimers();
try {
const { container } = render(
<ToolSpark event={toolEvent('Read', false, 'hold-1')} enabled reducedMotion={false} />,
);
expect(container.querySelector('.tool-spark-burst')).toBeInTheDocument();
act(() => { vi.advanceTimersByTime(3300); });
expect(container.querySelector('.tool-spark-burst')).toBeNull();
} finally {
vi.useRealTimers();
}
});
});
+106 -20
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { iconKindForTool, type ToolIconKind } from '../../lib/pets/toolIconMap';
import type { LastToolEvent } from '../../hooks/useJobStream';
function ToolIcon({ kind }: { kind: ToolIconKind }) {
if (kind === 'search') {
@@ -45,30 +46,97 @@ function randomLaunchVx(): number {
return Math.cos((angleDeg * Math.PI) / 180);
}
// U8: a tool_result landing within this many ms of the previous spark
// coalesces into the held spark (bumps the combo badge) instead of firing a
// second overlapping burst. See docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md
const COALESCE_WINDOW_MS = 600;
// Total time a spark (and its combo badge) stays visible after the most
// recent qualifying result — matches the bubble/particle CSS duration
// (3000ms) plus the particles' staggered delay.
const HOLD_MS = 3300;
// U6: CSS-only visual treatment per tool kind. `ToolIcon` (the bubble
// glyph) is reused unchanged — only the particle/bubble motion differs,
// via the `.tool-spark-mode-*` rules in index.css.
const SPARK_MODE: Record<ToolIconKind, string> = {
search: 'ripple',
terminal: 'blink',
file: 'paper',
edit: 'paper',
browser: 'window',
issue: 'marker',
plug: 'bolt',
spark: 'star',
};
interface SparkVisual {
kind: ToolIconKind;
isError: boolean;
}
export function ToolSpark({
toolName,
activityKey,
event,
enabled,
reducedMotion,
}: {
toolName: string | null;
activityKey: string | null;
/** Most recent SSE tool_use/tool_result (Pets Phase 1, U0). The spark
* fires only once `isError` is confirmed (U7, Phase 2) — the initial
* `tool_use` (isError === null) is the jump's cue, handled separately by
* ChatPetOverlay, and is deliberately ignored here. */
event: LastToolEvent | null;
enabled: boolean;
reducedMotion: boolean;
}) {
const [visibleTool, setVisibleTool] = useState<string | null>(null);
const [visible, setVisible] = useState<SparkVisual | null>(null);
const [comboCount, setComboCount] = useState(0);
const [animationToken, setAnimationToken] = useState(0);
const lastHandledCallIdRef = useRef<string | null>(null);
const lastFireAtRef = useRef<number>(-Infinity);
const comboCountRef = useRef(0);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => () => {
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
}, []);
useEffect(() => {
if (!enabled || !toolName) return;
setVisibleTool(toolName);
setAnimationToken(t => t + 1);
// Bubble: 3000ms animation. Particles: 3000ms animation + up to 270ms
// staggered delay. Hold ~30ms past the latest particle's fade-out so we
// don't snap-cut the last one.
const timer = window.setTimeout(() => setVisibleTool(null), 3300);
return () => window.clearTimeout(timer);
}, [enabled, toolName, activityKey]);
if (!enabled) return;
// U7: only fire once the result is confirmed. `isError === null` means
// this is still the `tool_use` moment (the jump's trigger, not ours).
if (!event || event.isError === null || !event.callId) return;
// Same callId already handled (e.g. a re-render didn't produce a new
// tool_result) — don't re-fire.
if (event.callId === lastHandledCallIdRef.current) return;
lastHandledCallIdRef.current = event.callId;
const now = Date.now();
// U8: a result landing within the coalesce window bumps the combo count
// on the held spark; otherwise it starts a fresh one.
const coalesced = now - lastFireAtRef.current <= COALESCE_WINDOW_MS;
lastFireAtRef.current = now;
comboCountRef.current = coalesced ? comboCountRef.current + 1 : 1;
setComboCount(comboCountRef.current);
// Keep an in-flight burst intact when rapid results coalesce. Replacing
// its visual kind or React key would restart every particle at frame 0,
// which reads as a teleport rather than a continuous fall.
if (!coalesced) {
setVisible({ kind: iconKindForTool(event.name), isError: event.isError });
setAnimationToken(t => t + 1);
} else if (event.isError) {
// Preserve the original kind/particle DOM, but never hide a failure
// that arrives later in the same combo group.
setVisible(current => current ? { ...current, isError: true } : current);
}
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = window.setTimeout(() => {
setVisible(null);
comboCountRef.current = 0;
setComboCount(0);
hideTimerRef.current = null;
}, HOLD_MS);
}, [event, enabled]);
const launchVelocities = useMemo(
() => PARTICLE_BASE.map(() => randomLaunchVx()),
@@ -77,15 +145,28 @@ export function ToolSpark({
[animationToken],
);
if (!enabled || !visibleTool) return null;
if (!enabled || !visible) return null;
const mode = SPARK_MODE[visible.kind];
const statusClass = visible.isError ? 'tool-spark-error' : 'tool-spark-success';
// reduced-motion: no per-kind motion, no particles — just the color/mark.
const modeClass = !reducedMotion ? `tool-spark-mode-${mode}` : '';
const kind = iconKindForTool(visibleTool);
return (
<div className="tool-spark-burst" key={animationToken} aria-hidden="true">
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`}>
<div
className={`tool-spark-burst ${statusClass} ${modeClass}`.trim()}
key={animationToken}
aria-hidden="true"
data-tool-kind={visible.kind}
data-tool-error={visible.isError}
>
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`.trim()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<ToolIcon kind={kind} />
<ToolIcon kind={visible.kind} />
</svg>
{visible.isError && (
<span className="tool-spark-error-mark" aria-hidden="true">!</span>
)}
</div>
{!reducedMotion && PARTICLE_BASE.map((p, i) => (
<span
@@ -104,6 +185,11 @@ export function ToolSpark({
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
</span>
))}
{comboCount > 1 && (
<span className={`tool-spark-combo ${reducedMotion ? 'tool-spark-combo-reduced' : ''}`.trim()}>
×{comboCount}
</span>
)}
</div>
);
}