sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user