feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { extractLatestToolName, petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
import { PetSprite } from './PetSprite';
|
||||
import { ToolSpark } from './ToolSpark';
|
||||
|
||||
const JUMP_DURATION_MS = 1500;
|
||||
const DONE_FLOURISH_MS = 1000;
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setReduced(query.matches);
|
||||
update();
|
||||
query.addEventListener('change', update);
|
||||
return () => query.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
|
||||
export function ChatPetOverlay({
|
||||
taskId,
|
||||
taskStatus,
|
||||
currentActivity,
|
||||
workerId,
|
||||
lastBackendId,
|
||||
className,
|
||||
}: {
|
||||
taskId: number | null;
|
||||
taskStatus: string | null;
|
||||
currentActivity: string | null;
|
||||
workerId: string | null;
|
||||
/**
|
||||
* Physical backend id when the worker is a proxy (LiteLLM deployment
|
||||
* name from `x-litellm-model-id`). Falls through to workerId mapping
|
||||
* if unset or unmapped. Phase A: passed in from latestJob.lastBackendId.
|
||||
*/
|
||||
lastBackendId?: string | null;
|
||||
/** Extra classes appended to the overlay wrapper. Used to gate
|
||||
* visibility per breakpoint when multiple instances render (e.g.,
|
||||
* one inside ChatPane for tablet+, another at app level for mobile). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { data, isLoading } = useActivePet(workerId, lastBackendId);
|
||||
const framesPerRow = usePetFrameAnalysis(
|
||||
data?.spriteUrl ?? null,
|
||||
data?.gridCols ?? null,
|
||||
data?.gridRows ?? null,
|
||||
);
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
// Phase C: when the job hasn't reported `running` yet (e.g. fresh
|
||||
// queued state, or status SSE hasn't caught up) but the backend
|
||||
// node is actually busy on our work, surface the running animation
|
||||
// anyway. Prefer the proxy-backend mapping over the worker mapping
|
||||
// — same precedence as useActivePet uses for sprite selection.
|
||||
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
|
||||
|
||||
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'
|
||||
? 'running'
|
||||
: taskBaseState;
|
||||
const baseStateRef = useRef(baseState);
|
||||
baseStateRef.current = baseState;
|
||||
|
||||
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
|
||||
|
||||
// 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);
|
||||
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;
|
||||
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.
|
||||
}, JUMP_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentActivity]);
|
||||
|
||||
const toolName = useMemo(
|
||||
() => extractLatestToolName(currentActivity),
|
||||
[currentActivity],
|
||||
);
|
||||
|
||||
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'}
|
||||
style={{ ['--pet-size' as string]: `${data.settings.size}px` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ToolSpark
|
||||
toolName={toolName}
|
||||
activityKey={currentActivity}
|
||||
enabled={data.settings.toolSparkEnabled}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
<PetSprite
|
||||
name={data.pet.name}
|
||||
imageUrl={data.imageUrl}
|
||||
frameWidth={data.frameWidth}
|
||||
frameHeight={data.frameHeight}
|
||||
gridCols={data.gridCols}
|
||||
gridRows={data.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={displayState}
|
||||
size={data.settings.size}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
|
||||
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
|
||||
idle: '1.2s',
|
||||
running: '0.55s',
|
||||
runningAlt: '0.55s',
|
||||
dispatching: '0.6s',
|
||||
jumping: '0.5s',
|
||||
waiting: '1.4s',
|
||||
done: '0.8s',
|
||||
error: '0.55s',
|
||||
};
|
||||
|
||||
export function PetSprite({
|
||||
name,
|
||||
imageUrl,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
gridCols,
|
||||
gridRows,
|
||||
framesPerRow,
|
||||
state,
|
||||
size,
|
||||
reducedMotion,
|
||||
}: {
|
||||
name: string;
|
||||
imageUrl: string | null;
|
||||
frameWidth: number | null;
|
||||
frameHeight: number | null;
|
||||
gridCols: number | null;
|
||||
gridRows: number | null;
|
||||
framesPerRow: number[] | null;
|
||||
state: PetRuntimeState;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const className = [
|
||||
'pet-sprite',
|
||||
`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 stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
|
||||
const bgPosY = useGridCrop && gridRows! > 1
|
||||
? `${(stateRow / (gridRows! - 1)) * 100}%`
|
||||
: '0%';
|
||||
|
||||
const detectedFrames = framesPerRow?.[stateRow];
|
||||
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
|
||||
const cycleAnimation = useGridCrop && !reducedMotion && rowFrameCount > 1
|
||||
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
||||
}}
|
||||
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!})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img src={imageUrl} alt="" draggable={false} />
|
||||
)
|
||||
) : (
|
||||
<div className="pet-sprite-fallback">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { iconKindForTool, type ToolIconKind } from '../../lib/pets/toolIconMap';
|
||||
|
||||
function ToolIcon({ kind }: { kind: ToolIconKind }) {
|
||||
if (kind === 'search') {
|
||||
return <path d="M10.5 17a6.5 6.5 0 1 1 4.6-1.9L20 20" />;
|
||||
}
|
||||
if (kind === 'terminal') {
|
||||
return <path d="m5 7 4 4-4 4M11 17h8" />;
|
||||
}
|
||||
if (kind === 'file') {
|
||||
return <path d="M7 3h7l4 4v14H7zM14 3v5h5" />;
|
||||
}
|
||||
if (kind === 'edit') {
|
||||
return <path d="M4 20h4l11-11a2.8 2.8 0 0 0-4-4L4 16zM13 6l4 4" />;
|
||||
}
|
||||
if (kind === 'browser') {
|
||||
return <path d="M4 6h16v12H4zM4 9h16M7 7.5h.1M10 7.5h.1" />;
|
||||
}
|
||||
if (kind === 'issue') {
|
||||
return <path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM12 8v5M12 16h.1" />;
|
||||
}
|
||||
if (kind === 'plug') {
|
||||
return <path d="M9 7V3M15 7V3M7 7h10v4a5 5 0 0 1-10 0zM12 16v5" />;
|
||||
}
|
||||
return <path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z" />;
|
||||
}
|
||||
|
||||
// 4-point star particle path centered at (12, 12), radius ~8
|
||||
const STAR_PATH = 'M12 3 L13.6 10.4 L21 12 L13.6 13.6 L12 21 L10.4 13.6 L3 12 L10.4 10.4 Z';
|
||||
|
||||
const PARTICLE_BASE: Array<{ size: number; delay: string; rotate: number; x: number; y: number }> = [
|
||||
{ size: 10, delay: '0ms', rotate: 0, x: -6, y: 0 },
|
||||
{ size: 8, delay: '40ms', rotate: 25, x: 8, y: -2 },
|
||||
{ size: 9, delay: '90ms', rotate: -20, x: -10, y: 2 },
|
||||
{ size: 7, delay: '150ms', rotate: 15, x: 5, y: 4 },
|
||||
{ size: 11, delay: '210ms', rotate: 10, x: 0, y: -4 },
|
||||
{ size: 9, delay: '270ms', rotate: -10, x: 3, y: 6 },
|
||||
];
|
||||
|
||||
// Projection angle range: [85°, 95°] from horizontal — near-vertical with slight lean.
|
||||
// cos(85°) ≈ 0.087 (rightward), cos(95°) ≈ -0.087 (leftward).
|
||||
function randomLaunchVx(): number {
|
||||
const angleDeg = 85 + Math.random() * 10;
|
||||
return Math.cos((angleDeg * Math.PI) / 180);
|
||||
}
|
||||
|
||||
export function ToolSpark({
|
||||
toolName,
|
||||
activityKey,
|
||||
enabled,
|
||||
reducedMotion,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
activityKey: string | null;
|
||||
enabled: boolean;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const [visibleTool, setVisibleTool] = useState<string | null>(null);
|
||||
const [animationToken, setAnimationToken] = useState(0);
|
||||
|
||||
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]);
|
||||
|
||||
const launchVelocities = useMemo(
|
||||
() => PARTICLE_BASE.map(() => randomLaunchVx()),
|
||||
// Re-randomize on each emission so successive sparkles don't look identical
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[animationToken],
|
||||
);
|
||||
|
||||
if (!enabled || !visibleTool) return null;
|
||||
|
||||
const kind = iconKindForTool(visibleTool);
|
||||
return (
|
||||
<div className="tool-spark-burst" key={animationToken} aria-hidden="true">
|
||||
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ToolIcon kind={kind} />
|
||||
</svg>
|
||||
</div>
|
||||
{!reducedMotion && PARTICLE_BASE.map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="tool-spark-particle"
|
||||
style={{
|
||||
left: `calc(50% + ${p.x}px)`,
|
||||
top: `calc(50% + ${p.y}px)`,
|
||||
width: p.size,
|
||||
height: p.size,
|
||||
animationDelay: p.delay,
|
||||
['--p-rot' as string]: `${p.rotate}deg`,
|
||||
['--p-vx' as string]: (launchVelocities[i] ?? 0).toFixed(3),
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user