feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+139
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}