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
26 changes: 25 additions & 1 deletion dashboard/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,35 @@ html.is-electron.is-fullscreen .app-shell {
animation: stage-quick-float 5.5s ease-in-out infinite;
}

@keyframes stage-cue-rise {
from { transform: translateY(6px) scale(0.94); opacity: 0; }
20%, 75% { opacity: 1; }
to { transform: translateY(-18px) scale(1.04); opacity: 0; }
}

@keyframes stage-cue-label {
from { transform: translate(-50%, 6px) scale(0.96); opacity: 0; }
18%, 82% { transform: translate(-50%, 0) scale(1); opacity: 1; }
to { transform: translate(-50%, -4px) scale(0.98); opacity: 0; }
}

.stage-cue-label {
animation: stage-cue-label 2.8s ease-in-out both;
}

.stage-cue-spark,
.stage-music-note {
animation: stage-cue-rise 2.4s ease-out both;
}

@media (prefers-reduced-motion: reduce) {
.stage-aurora,
.stage-aurora-slow,
.stage-thinking-dot,
.stage-quick-float {
.stage-quick-float,
.stage-cue-label,
.stage-cue-spark,
.stage-music-note {
animation: none;
}
}
Expand Down
43 changes: 43 additions & 0 deletions dashboard/src/v2/components/chat/cinematic/AgentAmbientEffects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { FunctionComponent } from "preact";
import type { AgentAmbientCue } from "./use-agent-mood.js";

export interface AgentAmbientEffectsProps {
cue: AgentAmbientCue | null;
motionEnabled: boolean;
}

/**
* Decorative stage dressing for one bounded idle cue. The visible label is
* deliberately outside a live region so recurring idle beats do not produce
* repeated screen-reader announcements; the avatar's stable mood caption
* remains the semantic status source.
*/
export const AgentAmbientEffects: FunctionComponent<AgentAmbientEffectsProps> = ({ cue, motionEnabled }) => {
if (!cue || !motionEnabled) return null;

return (
<div
className="pointer-events-none absolute inset-0 z-20"
data-testid="agent-ambient-effects"
data-cue={cue.kind}
>
<div
className="stage-cue-label absolute left-1/2 top-[10%] -translate-x-1/2 rounded-full border border-signal-500/20 bg-white/85 px-3 py-1.5 text-[11px] font-semibold text-slate-600 shadow-lg backdrop-blur-md dark:bg-void-800/85 dark:text-slate-300"
data-testid="agent-ambient-cue-label"
>
{cue.label}
</div>
<div aria-hidden="true" className="absolute inset-0">
<span className="stage-cue-spark absolute left-[27%] top-[32%] text-sm text-signal-500/70">✦</span>
<span className="stage-cue-spark absolute right-[27%] top-[25%] text-[10px] text-purple-400/70 [animation-delay:180ms]">✦</span>
{cue.showNotes && (
<div data-testid="agent-ambient-notes">
<span className="stage-music-note absolute left-[31%] top-[42%] text-lg text-signal-500">♪</span>
<span className="stage-music-note absolute right-[31%] top-[35%] text-xl text-purple-400 [animation-delay:240ms]">♫</span>
<span className="stage-music-note absolute right-[27%] top-[47%] text-sm text-signal-400 [animation-delay:480ms]">♪</span>
</div>
)}
</div>
</div>
);
};
18 changes: 12 additions & 6 deletions dashboard/src/v2/components/chat/cinematic/CinematicStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DEFAULT_AGENT_AVATAR_CONFIG } from "../../../lib/agent-avatar.js";
import { useReducedMotion } from "../../../hooks/use-reduced-motion.js";
import { resolveDisplayDeliveryStatus } from "../../../hooks/use-chat-thread-data.js";
import { useAgentMood, type AgentMoodState } from "./use-agent-mood.js";
import { AgentAmbientEffects } from "./AgentAmbientEffects.js";
import { parseBubbleSegments, StageWidgetRenderer } from "./StageWidgets.js";
import { isAgentScheduledWakeup, ScheduledWakeupWidget } from "../widgets/ScheduledWakeupWidget.js";
import { buildCinematicQuickActions } from "../../../lib/cinematic-quick-actions.js";
Expand Down Expand Up @@ -485,19 +486,23 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
messages: visibleMessages,
userEngaged: composerFocused || input.trim().length > 0,
agentName,
reducedMotion,
ambientPaused: Boolean(activeResponseEffect),
});
// 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 stageAnimation = reducedMotion
? undefined
: responseEffect?.animation ?? (mood.ambientMotionEnabled ? mood.ambientCue?.animation : undefined);
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. */
useLayoutEffect(() => {
const el = floatRef.current;
if (!el || reducedMotion) return;
if (!el || !mood.ambientMotionEnabled) return;
const tl = gsap.timeline({ repeat: -1, yoyo: true, defaults: { ease: "sine.inOut" } });
tl.to(el, { y: -16, x: 6, rotation: 1.4, duration: 3.4 })
.to(el, { y: 4, x: -8, rotation: -1.1, duration: 3.0 })
Expand All @@ -506,7 +511,7 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
tl.kill();
gsap.set(el, { x: 0, y: 0, rotation: 0 });
};
}, [reducedMotion]);
}, [mood.ambientMotionEnabled]);

const applySuggestion = (prompt: string): void => {
void handleSend(prompt).finally(() => {
Expand All @@ -526,8 +531,8 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
>
{/* ── Ambient backdrop — aurora glow, pure CSS, zero extra GPU cost ── */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="stage-aurora absolute left-1/2 top-[16%] h-[52vh] w-[52vh] -translate-x-1/2 rounded-full bg-signal-500/[0.06] blur-3xl dark:bg-signal-500/[0.05]" />
<div className="stage-aurora-slow absolute -right-[12%] bottom-[4%] h-[50%] w-[40%] rounded-full bg-purple-500/[0.04] blur-3xl dark:bg-purple-500/[0.04]" />
<div className={`${mood.ambientMotionEnabled ? "stage-aurora" : ""} absolute left-1/2 top-[16%] h-[52vh] w-[52vh] -translate-x-1/2 rounded-full bg-signal-500/[0.06] blur-3xl dark:bg-signal-500/[0.05]`} />
<div className={`${mood.ambientMotionEnabled ? "stage-aurora-slow" : ""} absolute -right-[12%] bottom-[4%] h-[50%] w-[40%] rounded-full bg-purple-500/[0.04] blur-3xl dark:bg-purple-500/[0.04]`} />
</div>

{/* ── Context strip — thread identity + escape hatch to Threads ── */}
Expand Down Expand Up @@ -580,7 +585,7 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
void handleSend(action.prompt);
}}
style={{ animationDelay: action.animationDelay }}
className="stage-quick-float inline-flex min-h-11 min-w-0 items-center justify-center gap-2 rounded-2xl border border-black/[0.07] bg-white/85 px-3 py-2 text-center text-[11px] font-semibold leading-4 text-slate-600 shadow-[0_4px_20px_rgba(0,0,0,0.08)] backdrop-blur-md transition-colors hover:border-signal-500/40 hover:text-signal-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/50 focus-visible:ring-offset-2 dark:border-white/[0.09] dark:bg-void-800/85 dark:text-slate-300 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] dark:hover:text-signal-400 dark:focus-visible:ring-offset-void-900"
className={`${mood.ambientMotionEnabled ? "stage-quick-float" : ""} inline-flex min-h-11 min-w-0 items-center justify-center gap-2 rounded-2xl border border-black/[0.07] bg-white/85 px-3 py-2 text-center text-[11px] font-semibold leading-4 text-slate-600 shadow-[0_4px_20px_rgba(0,0,0,0.08)] backdrop-blur-md transition-colors hover:border-signal-500/40 hover:text-signal-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/50 focus-visible:ring-offset-2 dark:border-white/[0.09] dark:bg-void-800/85 dark:text-slate-300 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] dark:hover:text-signal-400 dark:focus-visible:ring-offset-void-900`}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-signal-500" aria-hidden="true" />
<span className="min-w-0 whitespace-normal break-words">{action.label}</span>
Expand All @@ -593,6 +598,7 @@ export const CinematicStage: FunctionComponent<CinematicStageProps> = ({
{runtimeBusy && (
<ThoughtBubble text={workingPhase === "starting" ? "Spinning up a workspace" : "Working on it"} />
)}
<AgentAmbientEffects cue={mood.ambientCue} motionEnabled={mood.ambientMotionEnabled} />
{/* Generous square canvas so antenna, ears, and aura never clip,
even at the extremes of the float/lean drift. */}
<div
Expand Down
Loading
Loading