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
139 changes: 73 additions & 66 deletions dashboard/src/v2/AgentsPage.tsx

Large diffs are not rendered by default.

172 changes: 43 additions & 129 deletions dashboard/src/v2/ProjectsPage.tsx

Large diffs are not rendered by default.

50 changes: 28 additions & 22 deletions dashboard/src/v2/components/agents/AgentAvatarCustomizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
ROBOT_BASE_COLOR_OPTIONS,
ROBOT_VISOR_COLOR_OPTIONS,
generateRandomAgentAvatar,
getAgentAvatarOptionLabel,
} from "../../lib/agent-avatar.js";
import { INTERACTION_CSS_VARIABLES } from "../../lib/motion/tokens.js";
import { useDashboardI18n } from "../../i18n/index.js";
import { agentsMessages } from "../../i18n/messages/agents.js";

interface AgentAvatarCustomizerProps {
config: AgentAvatarConfig;
Expand All @@ -35,6 +38,7 @@ function PartPicker<T extends { id: string; label: string }>({
onChange: (id: string) => void;
disabled?: boolean;
}) {
const { locale, translate } = useDashboardI18n();
return (
<div className="flex flex-col gap-2">
<span className="text-[9px] font-bold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
Expand All @@ -60,9 +64,9 @@ function PartPicker<T extends { id: string; label: string }>({
transitionTimingFunction: INTERACTION_CSS_VARIABLES.selectionMovement.ease,
}}
>
{opt.label}
{getAgentAvatarOptionLabel(opt.label, locale)}
<span className="ml-1 rounded-full border border-current/20 px-1 py-0.5 text-[8px] uppercase tracking-[0.12em]">
{selected ? "Selected" : "Option"}
{translate(agentsMessages, selected ? "selected" : "option")}
</span>
</button>
);
Expand All @@ -86,6 +90,7 @@ function ColorSwatchPicker({
onChange: (id: string) => void;
disabled?: boolean;
}) {
const { locale, translate } = useDashboardI18n();
const selectedOpt = options.find((o) => o.id === value);
return (
<div className="flex flex-col gap-2">
Expand All @@ -95,7 +100,7 @@ function ColorSwatchPicker({
</span>
{selectedOpt && (
<span className="text-[9px] font-semibold text-slate-400 dark:text-slate-500">
{selectedOpt.label}
{getAgentAvatarOptionLabel(selectedOpt.label, locale)}
</span>
)}
</div>
Expand All @@ -108,8 +113,8 @@ function ColorSwatchPicker({
type="button"
disabled={disabled}
onClick={() => onChange(opt.id)}
title={opt.label}
aria-label={opt.label}
title={getAgentAvatarOptionLabel(opt.label, locale)}
aria-label={getAgentAvatarOptionLabel(opt.label, locale)}
aria-describedby={selected ? `${label}-${opt.id}-selected` : undefined}
aria-pressed={selected}
className={`group relative h-8 w-8 rounded-full shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 disabled:cursor-not-allowed disabled:opacity-50 ${
Expand All @@ -126,7 +131,7 @@ function ColorSwatchPicker({
)}
{selected && (
<span id={`${label}-${opt.id}-selected`} className="sr-only">
Selected {opt.label}
{translate(agentsMessages, "selected")} {getAgentAvatarOptionLabel(opt.label, locale)}
</span>
)}
</button>
Expand Down Expand Up @@ -173,6 +178,7 @@ export function AgentAvatarCustomizer({
className = "",
disabled = false,
}: AgentAvatarCustomizerProps) {
const { translate } = useDashboardI18n();
const [isRandomizing, setIsRandomizing] = useState(false);
const handleRandomize = () => {
setIsRandomizing(true);
Expand All @@ -189,17 +195,17 @@ export function AgentAvatarCustomizer({
<div className={`flex flex-col gap-4 ${className}`}>
<div className="flex items-center justify-between">
<p className="text-[11px] leading-relaxed text-slate-500 dark:text-slate-400">
Tweak parts and colors — the portrait updates live.
{translate(agentsMessages, "avatarCustomizerHint")}
</p>
<button
type="button"
onClick={handleRandomize}
title={disabled ? "Avatar controls are disabled while saving" : "Randomize avatar appearance"}
title={translate(agentsMessages, disabled ? "avatarDisabledTitle" : "avatarRandomizeTitle")}
disabled={disabled}
className="group/rnd inline-flex shrink-0 items-center gap-1.5 rounded-full border border-black/[0.06] bg-white/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 transition-colors hover:bg-signal-500/[0.1] hover:text-signal-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.06] dark:bg-white/[0.04] dark:text-slate-400 dark:hover:text-signal-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30"
>
<RefreshCw className="h-3 w-3 transition-transform duration-500 group-hover/rnd:rotate-180" strokeWidth={2.4} />
Randomize
{translate(agentsMessages, "randomize")}
</button>
</div>
<div
Expand All @@ -214,24 +220,24 @@ export function AgentAvatarCustomizer({
}`}
>
{disabled
? "Avatar controls are disabled while this agent is saving."
? translate(agentsMessages, "avatarControlsDisabled")
: isRandomizing
? "Avatar randomized. Save Agent to keep it."
: "Selected parts are labeled and update the live portrait immediately."}
? translate(agentsMessages, "avatarRandomized")
: translate(agentsMessages, "avatarSelectionHint")}
</div>

<CustomizerGroup icon={Shapes} title="Form">
<PartPicker label="Chassis" options={ROBOT_CHASSIS_OPTIONS} value={config.chassis} onChange={(id) => handleField("chassis", id)} disabled={disabled} />
<PartPicker label="Eyes" options={ROBOT_EYE_OPTIONS} value={config.eyes} onChange={(id) => handleField("eyes", id)} disabled={disabled} />
<PartPicker label="Antenna" options={ROBOT_ANTENNA_OPTIONS} value={config.antenna} onChange={(id) => handleField("antenna", id)} disabled={disabled} />
<PartPicker label="Headphones" options={ROBOT_HEADPHONES_OPTIONS} value={config.headphones} onChange={(id) => handleField("headphones", id)} disabled={disabled} />
<PartPicker label="Aura" options={ROBOT_WING_OPTIONS} value={config.wings} onChange={(id) => handleField("wings", id)} disabled={disabled} />
<CustomizerGroup icon={Shapes} title={translate(agentsMessages, "form")}>
<PartPicker label={translate(agentsMessages, "chassis")} options={ROBOT_CHASSIS_OPTIONS} value={config.chassis} onChange={(id) => handleField("chassis", id)} disabled={disabled} />
<PartPicker label={translate(agentsMessages, "eyes")} options={ROBOT_EYE_OPTIONS} value={config.eyes} onChange={(id) => handleField("eyes", id)} disabled={disabled} />
<PartPicker label={translate(agentsMessages, "antenna")} options={ROBOT_ANTENNA_OPTIONS} value={config.antenna} onChange={(id) => handleField("antenna", id)} disabled={disabled} />
<PartPicker label={translate(agentsMessages, "headphones")} options={ROBOT_HEADPHONES_OPTIONS} value={config.headphones} onChange={(id) => handleField("headphones", id)} disabled={disabled} />
<PartPicker label={translate(agentsMessages, "aura")} options={ROBOT_WING_OPTIONS} value={config.wings} onChange={(id) => handleField("wings", id)} disabled={disabled} />
</CustomizerGroup>

<CustomizerGroup icon={Palette} title="Palette">
<ColorSwatchPicker label="Base Color" options={ROBOT_BASE_COLOR_OPTIONS} value={config.baseColor} onChange={(id) => handleField("baseColor", id)} disabled={disabled} />
<ColorSwatchPicker label="Accent Color" options={ROBOT_ACCENT_OPTIONS} value={config.accent} onChange={(id) => handleField("accent", id)} disabled={disabled} />
<ColorSwatchPicker label="Visor Color" options={ROBOT_VISOR_COLOR_OPTIONS} value={config.visorColor} onChange={(id) => handleField("visorColor", id)} disabled={disabled} />
<CustomizerGroup icon={Palette} title={translate(agentsMessages, "palette")}>
<ColorSwatchPicker label={translate(agentsMessages, "baseColor")} options={ROBOT_BASE_COLOR_OPTIONS} value={config.baseColor} onChange={(id) => handleField("baseColor", id)} disabled={disabled} />
<ColorSwatchPicker label={translate(agentsMessages, "accentColor")} options={ROBOT_ACCENT_OPTIONS} value={config.accent} onChange={(id) => handleField("accent", id)} disabled={disabled} />
<ColorSwatchPicker label={translate(agentsMessages, "visorColor")} options={ROBOT_VISOR_COLOR_OPTIONS} value={config.visorColor} onChange={(id) => handleField("visorColor", id)} disabled={disabled} />
</CustomizerGroup>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Smile } from "lucide-preact";
import type { AgentAvatarExpression } from "../../lib/agent-avatar.js";
import { AGENT_AVATAR_EXPRESSIONS } from "../../lib/agent-avatar.js";
import { EXPRESSION_META } from "./AgentAvatarStage.js";
import { useDashboardI18n } from "../../i18n/index.js";
import { agentsMessages } from "../../i18n/messages/agents.js";

interface AgentAvatarExpressionPickerProps {
value: AgentAvatarExpression;
Expand All @@ -17,10 +19,14 @@ export function AgentAvatarExpressionPicker({
className = "",
disabled = false,
}: AgentAvatarExpressionPickerProps) {
const { translate } = useDashboardI18n();
const expressionLabel = (expression: AgentAvatarExpression): string => translate(agentsMessages, ({
happy: "expressionHappy", sad: "expressionSad", angry: "expressionAngry", sleepy: "expressionSleepy", bored: "expressionBored", hyped: "expressionHyped", shake_head: "expressionShakeHead", nod: "expressionNod", curious: "expressionCurious", thinking: "expressionThinking", excited: "expressionExcited", laughing: "expressionLaughing", surprised: "expressionSurprised", wink: "expressionWink", dance: "expressionDance", proud: "expressionProud",
} as const)[expression]);
return (
<div className={`flex flex-col gap-2 ${className}`}>
<span className="text-[9px] font-bold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
Expression
{translate(agentsMessages, "expression")}
</span>
<div className="flex flex-wrap gap-1.5">
{AGENT_AVATAR_EXPRESSIONS.map((expression) => {
Expand All @@ -40,7 +46,7 @@ export function AgentAvatarExpressionPicker({
}`}
>
<meta.Icon className="h-3.5 w-3.5" strokeWidth={2.4} />
{meta.label}
{expressionLabel(expression)}
</button>
);
})}
Expand Down
10 changes: 8 additions & 2 deletions dashboard/src/v2/components/agents/AgentAvatarScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ import { extrudeLogoPath, type LogoShapeFrame } from "../../lib/logo-shapes.js";
import { AgentAvatarSvg } from "./AgentAvatarSvg.js";
import {
AGENT_SCENE_TOOL_CATALOG,
getAgentSceneToolLabel,
getToolMotionPose,
type AgentSceneTool,
type ToolAnimationRef,
type ToolGeometryBlueprint,
type ToolMaterialRole,
} from "../../lib/agent-scene-tools.js";
import { useDashboardI18n } from "../../i18n/index.js";
import { agentsMessages } from "../../i18n/messages/agents.js";

export type { AgentSceneTool } from "../../lib/agent-scene-tools.js";

Expand Down Expand Up @@ -990,6 +993,7 @@ export function AgentAvatarScene({
pointerTracking = "hover",
tool = null,
}: AgentAvatarSceneProps) {
const { locale, translate } = useDashboardI18n();
const mountRef = useRef<HTMLDivElement>(null);
const [webglError, setWebglError] = useState(false);
const [isReducedMotion, setIsReducedMotion] = useState(() => {
Expand Down Expand Up @@ -1545,15 +1549,17 @@ export function AgentAvatarScene({
data-testid="agent-avatar-fallback"
data-tool={tool ?? undefined}
role="img"
aria-label={tool ? `Agent avatar preview working with ${AGENT_SCENE_TOOL_CATALOG[tool].label}` : "Agent avatar preview"}
aria-label={tool
? translate(agentsMessages, "avatarPreviewWithTool", { tool: getAgentSceneToolLabel(tool, locale) })
: translate(agentsMessages, "avatarPreview")}
>
<AgentAvatarSvg config={config} expression={expression} className="w-full h-full max-w-[220px]" static />
{tool && (
<span
className="absolute bottom-3 right-3 rounded-full border border-signal-500/30 bg-white/90 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-signal-700 shadow-sm dark:bg-void-900/90 dark:text-signal-300"
data-testid="agent-avatar-static-tool"
>
{AGENT_SCENE_TOOL_CATALOG[tool].label}
{getAgentSceneToolLabel(tool, locale)}
</span>
)}
</div>
Expand Down
26 changes: 19 additions & 7 deletions dashboard/src/v2/components/agents/AgentAvatarStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { AgentAvatarExpression } from "../../lib/agent-avatar.js";
import { LazyAgentAvatarScene } from "./LazyAgentAvatarScene.js";
import { SHOWCASE_EXPRESSIONS } from "../../lib/agent-avatar.js";
import { useReducedMotion } from "../../hooks/use-reduced-motion.js";
import { useDashboardI18n } from "../../i18n/index.js";
import { agentsMessages } from "../../i18n/messages/agents.js";

/* ── Expression icon + label map (single source of truth) ── */
export const EXPRESSION_META: Record<string, { Icon: typeof Smile; label: string }> = {
Expand Down Expand Up @@ -41,9 +43,18 @@ export const AgentAvatarStage: FunctionComponent<{
fallbackMode = false,
disabled = false,
}) => {
const { translate } = useDashboardI18n();
const reducedMotion = useReducedMotion();
const shouldFallback = fallbackMode || reducedMotion;
const expressionLabels: Record<string, string> = {
happy: translate(agentsMessages, "expressionHappy"),
sad: translate(agentsMessages, "expressionSad"),
angry: translate(agentsMessages, "expressionAngry"),
bored: translate(agentsMessages, "expressionBored"),
hyped: translate(agentsMessages, "expressionHyped"),
};
const activeMeta = EXPRESSION_META[expression];
const activeLabel = expressionLabels[expression] ?? expression;
return (
<div
className={`relative overflow-hidden rounded-[1.5rem] border border-black/[0.06] bg-white/40 shadow-sm backdrop-blur-2xl dark:border-white/[0.07] dark:bg-void-800/40 ${className}`}
Expand All @@ -60,7 +71,7 @@ export const AgentAvatarStage: FunctionComponent<{

<div className={`relative w-full ${heightClass}`}>
<span className="sr-only" aria-live="polite">
Bot expression changed to {activeMeta.label}
{translate(agentsMessages, "expressionChanged", { expression: activeLabel })}
</span>
<LazyAgentAvatarScene
config={config}
Expand All @@ -76,12 +87,12 @@ export const AgentAvatarStage: FunctionComponent<{
type="button"
onClick={onRandomize}
disabled={disabled}
title="Randomize appearance"
aria-label="Randomize appearance"
title={translate(agentsMessages, "randomizeAppearance")}
aria-label={translate(agentsMessages, "randomizeAppearance")}
className="group/rnd absolute right-3 top-3 inline-flex h-9 items-center gap-1.5 rounded-full border border-black/[0.06] bg-white/75 px-3 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-500 shadow-sm backdrop-blur-md transition-all hover:bg-signal-500 hover:text-white dark:hover:text-void-900 hover:shadow-[0_0_18px_rgba(0,224,160,0.35)] focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:bg-white/[0.08] dark:text-slate-300 dark:hover:bg-signal-500"
>
<RefreshCw className="h-3.5 w-3.5 transition-transform duration-500 group-hover/rnd:rotate-180" strokeWidth={2.5} />
Shuffle
{translate(agentsMessages, "shuffle")}
</button>
)}

Expand All @@ -93,14 +104,15 @@ export const AgentAvatarStage: FunctionComponent<{
<div className="flex items-center gap-1 rounded-full border border-black/[0.06] bg-white/75 p-1 shadow-[0_4px_16px_rgba(0,0,0,0.06)] backdrop-blur-xl dark:border-white/[0.08] dark:bg-void-800/75">
{SHOWCASE_EXPRESSIONS.map((expr) => {
const meta = EXPRESSION_META[expr];
const label = expressionLabels[expr] ?? meta.label;
const isActive = expression === expr;
return (
<button
key={expr}
type="button"
onClick={() => onExpressionChange(expr)}
title={meta.label}
aria-label={meta.label}
title={label}
aria-label={label}
aria-pressed={isActive}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/40 ${
isActive
Expand All @@ -115,7 +127,7 @@ export const AgentAvatarStage: FunctionComponent<{
</div>
{activeMeta && (
<span className="text-[9px] font-bold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
{activeMeta.label}
{activeLabel}
</span>
)}
</div>
Expand Down
Loading
Loading