Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions dashboard/src/v2/components/agents/AgentAvatarScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { h } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
import * as THREE from "../../../lib/three-lite.js";
import type { AgentAvatarConfig } from "../../types.js";
import type { AgentResponseAnimation } from "../../../../../src/contracts/connection-chat-types.js";
import {
DEFAULT_AGENT_AVATAR_CONFIG,
getAccentHex,
Expand Down Expand Up @@ -55,9 +56,11 @@ import {
import { extrudeLogoPath, type LogoShapeFrame } from "../../lib/logo-shapes.js";
import { AgentAvatarSvg } from "./AgentAvatarSvg.js";

interface AgentAvatarSceneProps {
export interface AgentAvatarSceneProps {
config?: AgentAvatarConfig;
expression?: AgentAvatarExpression;
/** A validated, short-lived choreography layered over the semantic expression. */
animation?: AgentResponseAnimation;
className?: string;
fallbackMode?: boolean;
/**
Expand All @@ -79,6 +82,15 @@ export type AgentSceneTool = "screwdriver" | "jackhammer" | "wrench" | "hammer"
/** Resting scale of the tool group once its entrance pop finishes. */
const TOOL_SCALE = 0.5;

const RESPONSE_ANIMATION_EXPRESSION: Record<AgentResponseAnimation, AgentAvatarExpression> = {
hyped: "hyped",
shake_head: "shake_head",
nod: "nod",
laughing: "laughing",
wink: "wink",
dance: "dance",
};

/* ── Hex string → THREE.Color int ── */
function hexInt(hex: string, fallback = 0x000000): number {
const m = hex.match(/^#?([\da-f]{6})$/i);
Expand Down Expand Up @@ -1025,6 +1037,7 @@ function disposeSubtree(
export function AgentAvatarScene({
config = DEFAULT_AGENT_AVATAR_CONFIG,
expression = "happy",
animation,
className = "",
fallbackMode = false,
pointerTracking = "hover",
Expand All @@ -1037,6 +1050,9 @@ export function AgentAvatarScene({
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
const configKey = `${config.chassis}-${config.eyes}-${config.antenna}-${config.wings}-${config.headphones}-${config.accent}-${config.baseColor}-${config.visorColor}`;
const choreographyExpression = animation
? RESPONSE_ANIMATION_EXPRESSION[animation]
: expression;

/** Persistent across config changes. Created once on mount. */
const rendererRef = useRef<{
Expand Down Expand Up @@ -1295,7 +1311,7 @@ export function AgentAvatarScene({
let eyeScaleY = 1.0;
let jewelIntensity = 0.95;

switch (expression) {
switch (choreographyExpression) {
case "happy":
bounceAmp = 0.06; bounceSpeed = 1.8;
jewelIntensity = 1.0;
Expand Down Expand Up @@ -1430,18 +1446,18 @@ export function AgentAvatarScene({

// Head pose — pointer parallax wins over idle drift when hovering
const ptr = pointerRef.current;
if (expression === "shake_head") {
if (choreographyExpression === "shake_head") {
// Slow, deliberate "no" — a fast shake reads as glitching
p.headGroup.rotation.y = Math.sin(t * 1.8) * 0.22;
} else if (expression === "nod") {
} else if (choreographyExpression === "nod") {
// Calm, reassuring "yes"
p.headGroup.rotation.x = Math.sin(t * 1.6) * 0.14;
} else if (expression === "dance") {
} else if (choreographyExpression === "dance") {
// Groove — side-to-side sway, alternating lean, little hip shift
p.headGroup.rotation.z = Math.sin(t * 3.2) * 0.16;
p.headGroup.rotation.y = Math.sin(t * 1.6) * 0.22;
p.headGroup.position.x = Math.sin(t * 3.2) * 0.08;
} else if (expression === "laughing") {
} else if (choreographyExpression === "laughing") {
// Soft chuckle — gentle pitch wobble thrown back
p.headGroup.rotation.x = -0.1 + Math.sin(t * 5) * 0.04;
p.headGroup.rotation.z = Math.sin(t * 2.5) * 0.03;
Expand All @@ -1452,7 +1468,7 @@ export function AgentAvatarScene({
p.headGroup.rotation.x = THREE.MathUtils.lerp(p.headGroup.rotation.x, targetPitch, 0.08);
p.headGroup.rotation.z = THREE.MathUtils.lerp(p.headGroup.rotation.z, headTiltZ, 0.06);
}
if (expression !== "dance") {
if (choreographyExpression !== "dance") {
// Damp out any leftover dance hip-shift when the mood changes
p.headGroup.position.x = THREE.MathUtils.lerp(p.headGroup.position.x, 0, 0.1);
}
Expand All @@ -1471,7 +1487,7 @@ export function AgentAvatarScene({
// Wink — the left eye drops on a lazy cycle while the right stays open
let leftBlink = blinkFactor;
const rightBlink = blinkFactor;
if (expression === "wink") {
if (choreographyExpression === "wink") {
const winkPhase = t % 2.8;
if (winkPhase < 0.45) leftBlink = 0.1;
}
Expand Down Expand Up @@ -1559,7 +1575,7 @@ export function AgentAvatarScene({
const r2 = rendererRef.current;
if (r2) cancelAnimationFrame(r2.animationId);
};
}, [expression, shouldUseFallback, webglError]);
}, [choreographyExpression, shouldUseFallback, webglError]);

if (shouldUseFallback || webglError) {
return (
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/v2/components/agents/LazyAgentAvatarScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { lazy, Suspense } from "preact/compat";
import { useEffect, useRef, useState } from "preact/hooks";
import type { AgentAvatarConfig } from "../../types.js";
import type { AgentAvatarExpression } from "../../lib/agent-avatar.js";
import type { AgentResponseAnimation } from "../../../../../src/contracts/connection-chat-types.js";
import type { AgentSceneTool } from "./AgentAvatarScene.js";
import { useReducedMotion } from "../../hooks/use-reduced-motion.js";
import { AgentAvatarSvg } from "./AgentAvatarSvg.js";
Expand All @@ -11,9 +12,10 @@ const AgentAvatarScene = lazy(() => import("./AgentAvatarScene.js").then((module
default: module.AgentAvatarScene,
})));

interface LazyAgentAvatarSceneProps {
export interface LazyAgentAvatarSceneProps {
config?: AgentAvatarConfig;
expression?: AgentAvatarExpression;
animation?: AgentResponseAnimation;
className?: string;
fallbackMode?: boolean;
eager?: boolean;
Expand Down
43 changes: 39 additions & 4 deletions dashboard/src/v2/components/chat/cinematic/CinematicStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import { buildCinematicQuickActions } from "../../../lib/cinematic-quick-actions
import { useProjectEffectiveSettings } from "../../../hooks/use-project-effective-settings.js";
import { synthesizeSpeech } from "../../../lib/speech-api.js";
import { SpeechInputButton } from "../../speech/SpeechInputButton.js";
import type { AgentResponseEffect } from "../../../../../../src/contracts/connection-chat-types.js";
import {
getAgentResponseEffectCaption,
resolveAgentResponseEffect,
} from "../../../lib/agent-response-effects.js";

/* ════════════════════════════════════════════════════════════════════════
* CinematicStage — the default "3D Chat" view of the chat page.
Expand Down Expand Up @@ -184,7 +189,7 @@ const AgentSpeechBubble: FunctionComponent<{
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
{segments.map((segment, index) =>
segment.kind === "widget" ? (
segment.kind === "agent" ? null : segment.kind === "widget" ? (
<StageWidgetRenderer key={index} widget={segment.widget} onAction={onAction} />
) : (
!widgetData.suppressBodyMarkdown && (
Expand Down Expand Up @@ -358,6 +363,22 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
}
}
const latestAgentMessage = latestAgentIndex >= 0 ? visibleMessages[latestAgentIndex] : null;
const latestResponseEffect = latestAgentMessage
? resolveAgentResponseEffect(latestAgentMessage.metadata, latestAgentMessage.bodyMarkdown || "")
: undefined;
const latestResponseEffectKey = latestAgentMessage && latestResponseEffect
? `${latestAgentMessage.id}:${latestResponseEffect.emotion}:${latestResponseEffect.animation}:${latestResponseEffect.durationMs}:${latestResponseEffect.caption ?? ""}`
: null;
const [activeResponseEffect, setActiveResponseEffect] = useState<AgentResponseEffect | null>(null);
useEffect(() => {
if (!latestResponseEffect || !latestResponseEffectKey) {
setActiveResponseEffect(null);
return;
}
setActiveResponseEffect(latestResponseEffect);
const timer = window.setTimeout(() => setActiveResponseEffect(null), latestResponseEffect.durationMs);
return () => window.clearTimeout(timer);
}, [latestResponseEffectKey]);
const pendingUserMessages = visibleMessages
.slice(latestAgentIndex + 1)
.filter((message) => message.direction === "dashboard_to_connection")
Expand Down Expand Up @@ -465,6 +486,12 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
userEngaged: composerFocused || input.trim().length > 0,
agentName,
});
// Runtime truth always wins. A validated reply effect may only replace the
// otherwise idle/listening micro-expression for its bounded lifetime.
const responseEffect = !error && !sending && !runtimeBusy ? activeResponseEffect : null;
const stageExpression = responseEffect?.emotion ?? mood.expression;
const stageAnimation = reducedMotion ? undefined : responseEffect?.animation;
const stageCaption = responseEffect ? getAgentResponseEffectCaption(responseEffect) : mood.caption;

/* Cinematic drift — the whole bot slowly floats, leans, and wanders a few
pixels on top of the scene's own idle bob, so it never reads as parked. */
Expand Down Expand Up @@ -572,9 +599,17 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
ref={floatRef}
className="pointer-events-auto h-[28vh] w-[28vh] max-w-full will-change-transform md:h-[min(48vh,520px)] md:w-[min(48vh,520px)]"
role="img"
aria-label={`${agentName}, animated project manager. ${mood.caption}`}
aria-label={`${agentName}, project manager. ${stageCaption}`}
>
<LazyAgentAvatarScene eager pointerTracking="window" tool={activeTool} config={avatarConfig} expression={mood.expression} className="h-full w-full" />
<LazyAgentAvatarScene
eager
pointerTracking="window"
tool={activeTool}
config={avatarConfig}
expression={stageExpression}
animation={stageAnimation}
className="h-full w-full"
/>
</div>

{/* Name plate + truthful mood caption — tucked up into the canvas's
Expand All @@ -584,7 +619,7 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
{agentName}
</div>
<div aria-live="polite" className="mt-0.5 text-[12px] font-medium text-slate-500 dark:text-slate-400">
{mood.caption}
{stageCaption}
</div>
<div className="mt-1.5 flex items-center justify-center gap-1.5 font-mono text-[9px] uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
<span className={`h-1 w-1 rounded-full ${activeConnection ? "bg-signal-500" : "bg-slate-300 dark:bg-slate-600"}`} aria-hidden="true" />
Expand Down
22 changes: 19 additions & 3 deletions dashboard/src/v2/components/chat/cinematic/StageWidgets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
Sparkles,
XCircle,
} from "lucide-preact";
import type { AgentResponseEffect } from "../../../../../../src/contracts/connection-chat-types.js";
import { parseAgentResponseEffectJson } from "../../../lib/agent-response-effects.js";

/* ════════════════════════════════════════════════════════════════════════
* Stage widgets — the rich vocabulary agents embed in ordinary markdown.
Expand Down Expand Up @@ -46,7 +48,8 @@ export interface StageWidget {

export type BubbleSegment =
| { kind: "markdown"; markdown: string }
| { kind: "widget"; widget: StageWidget };
| { kind: "widget"; widget: StageWidget }
| { kind: "agent"; effect: AgentResponseEffect };

const WIDGET_FENCE = /```codeux:([a-z]+)[ \t]*\n([\s\S]*?)```/g;
const WIDGET_TYPES: StageWidgetType[] = ["status", "tasks", "sprint", "metrics", "memory", "actions"];
Expand All @@ -59,6 +62,7 @@ export function parseBubbleSegments(markdown: string): BubbleSegment[] {
for (let match = WIDGET_FENCE.exec(markdown); match; match = WIDGET_FENCE.exec(markdown)) {
const [raw, type, body] = match;
let widget: StageWidget | null = null;
const agentEffect = type === "agent" ? parseAgentResponseEffectJson(body) : undefined;
if ((WIDGET_TYPES as string[]).includes(type)) {
try {
const data = JSON.parse(body);
Expand All @@ -69,11 +73,23 @@ export function parseBubbleSegments(markdown: string): BubbleSegment[] {
widget = null;
}
}
if (!widget) continue; // leave the fence inside the surrounding markdown
if (!widget && !agentEffect) {
if (type !== "agent") continue; // leave unknown/malformed widget fences untouched
if (match.index > cursor) {
segments.push({ kind: "markdown", markdown: markdown.slice(cursor, match.index) });
}
segments.push({ kind: "markdown", markdown: raw.replace(/^```codeux:agent[^\n]*/, "```json") });
cursor = match.index + raw.length;
continue;
}
if (match.index > cursor) {
segments.push({ kind: "markdown", markdown: markdown.slice(cursor, match.index) });
}
segments.push({ kind: "widget", widget });
if (agentEffect) {
segments.push({ kind: "agent", effect: agentEffect });
} else if (widget) {
segments.push({ kind: "widget", widget });
}
cursor = match.index + raw.length;
}
if (cursor < markdown.length) {
Expand Down
94 changes: 94 additions & 0 deletions dashboard/src/v2/lib/agent-response-effects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {
AGENT_RESPONSE_ANIMATIONS,
AGENT_RESPONSE_EFFECT_MAX_CAPTION_LENGTH,
AGENT_RESPONSE_EFFECT_MAX_DURATION_MS,
AGENT_RESPONSE_EFFECT_MIN_DURATION_MS,
AGENT_RESPONSE_EMOTIONS,
type AgentResponseEffect,
} from "../../../../src/contracts/connection-chat-types.js";

const supportedEmotions = new Set<string>(AGENT_RESPONSE_EMOTIONS);
const supportedAnimations = new Set<string>(AGENT_RESPONSE_ANIMATIONS);
const AGENT_EFFECT_FENCE = /^```codeux:agent[^\n]*\n([\s\S]*?)^```[ \t]*$/gm;

const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === "object" && !Array.isArray(value)
);

/** Validate untrusted response metadata against the shared backend bounds. */
export function normalizeAgentResponseEffect(value: unknown): AgentResponseEffect | undefined {
if (!isRecord(value)) return undefined;

const { emotion, animation, durationMs } = value;
if (
typeof emotion !== "string"
|| !supportedEmotions.has(emotion)
|| typeof animation !== "string"
|| !supportedAnimations.has(animation)
|| typeof durationMs !== "number"
|| !Number.isSafeInteger(durationMs)
|| durationMs < AGENT_RESPONSE_EFFECT_MIN_DURATION_MS
|| durationMs > AGENT_RESPONSE_EFFECT_MAX_DURATION_MS
) {
return undefined;
}

const effect: AgentResponseEffect = {
emotion: emotion as AgentResponseEffect["emotion"],
animation: animation as AgentResponseEffect["animation"],
durationMs,
};
if (value.caption !== undefined) {
if (typeof value.caption !== "string") return undefined;
const caption = value.caption.trim();
if (!caption || caption.length > AGENT_RESPONSE_EFFECT_MAX_CAPTION_LENGTH) return undefined;
effect.caption = caption;
}
return effect;
}

export function parseAgentResponseEffectJson(rawJson: string): AgentResponseEffect | undefined {
try {
return normalizeAgentResponseEffect(JSON.parse(rawJson) as unknown);
} catch {
return undefined;
}
}

export interface ExtractedAgentResponseEffect {
markdown: string;
effect?: AgentResponseEffect;
}

/**
* Remove valid native avatar cues from visible markdown and return the first.
* Invalid cues are downgraded to ordinary JSON fences so provider output
* remains inspectable without retaining a dashboard-only fence tag.
*/
export function extractAgentResponseEffect(markdown: string): ExtractedAgentResponseEffect {
let effect: AgentResponseEffect | undefined;
AGENT_EFFECT_FENCE.lastIndex = 0;
const normalizedMarkdown = markdown.replace(AGENT_EFFECT_FENCE, (fence, rawJson: string) => {
const candidate = parseAgentResponseEffectJson(rawJson);
if (candidate) {
effect ??= candidate;
return "";
}
return fence.replace(/^```codeux:agent[^\n]*/, "```json");
}).replace(/\n{3,}/g, "\n\n").trim();

return { markdown: normalizedMarkdown, ...(effect ? { effect } : {}) };
}

/** Metadata wins when valid; native fences remain a backward-compatible cue. */
export function resolveAgentResponseEffect(metadata: unknown, markdown: string): AgentResponseEffect | undefined {
const metadataEffect = isRecord(metadata)
? normalizeAgentResponseEffect(metadata.agentEffect)
: undefined;
return metadataEffect ?? extractAgentResponseEffect(markdown).effect;
}

export function getAgentResponseEffectCaption(effect: AgentResponseEffect): string {
if (effect.caption) return effect.caption;
return `Feeling ${effect.emotion}.`;
}
Loading
Loading