Skip to content
Open
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
198 changes: 198 additions & 0 deletions apps/webuiapps/src/components/ChatPanel/ChatSubComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Helper sub-components extracted from ChatPanel
*
* StageIndicator, ActionsTaken, CharacterAvatar, renderMessageContent
*/

import React, { useState, useEffect, useCallback, memo, useRef } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { CharacterConfig } from '@/lib/characterManager';
import { resolveEmotionMedia } from '@/lib/characterManager';
import type { ModManager } from '@/lib/modManager';
import styles from './index.module.scss';

// ---------------------------------------------------------------------------
// Render message content — formats (action text) as styled spans
// ---------------------------------------------------------------------------

export function renderMessageContent(content: string): React.ReactNode {
const parts = content.split(/(\([^)]+\))/g);
return parts.map((part, i) => {
if (/^\([^)]+\)$/.test(part)) {
return (
<span key={i} className={styles.emotion}>
{part}
</span>
);
}
return part;
});
}

// ---------------------------------------------------------------------------
// Stage Indicator
// ---------------------------------------------------------------------------

export const StageIndicator: React.FC<{ modManager: ModManager | null }> = ({ modManager }) => {
if (!modManager) return null;

const total = modManager.stageCount;
const current = modManager.currentStageIndex;
const finished = modManager.isFinished;

return (
<div className={styles.stageIndicator}>
<span className={styles.stageText}>
Stage {finished ? total : current + 1}/{total}
</span>
<div className={styles.stageDots}>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={`${styles.stageDot} ${
i < current || finished
? styles.stageDotCompleted
: i === current
? styles.stageDotCurrent
: ''
}`}
/>
))}
</div>
</div>
);
};

// ---------------------------------------------------------------------------
// Actions Taken (collapsible)
// ---------------------------------------------------------------------------

export const ActionsTaken: React.FC<{ calls: string[] }> = ({ calls }) => {
const [open, setOpen] = useState(false);
if (calls.length === 0) return null;

return (
<div className={styles.actionsTaken}>
<button className={styles.actionsTakenToggle} onClick={() => setOpen(!open)}>
Actions taken
{open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
{open && (
<div className={styles.actionsTakenList}>
{calls.map((c, i) => (
<div key={i}>{c}</div>
))}
</div>
)}
</div>
);
};

// ---------------------------------------------------------------------------
// CharacterAvatar – crossfade between emotion media without flashing
// ---------------------------------------------------------------------------

interface AvatarLayer {
url: string;
type: 'video' | 'image';
active: boolean;
}

export const CharacterAvatar: React.FC<{
character: CharacterConfig;
emotion?: string;
onEmotionEnd: () => void;
}> = memo(({ character, emotion, onEmotionEnd }) => {
const isIdle = !emotion;
const media = resolveEmotionMedia(character, emotion || 'default');

const [layers, setLayers] = useState<AvatarLayer[]>(() =>
media ? [{ url: media.url, type: media.type, active: true }] : [],
);
const activeUrl = layers.find((l) => l.active)?.url;
const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
return () => {
if (cleanupRef.current) clearTimeout(cleanupRef.current);
};
}, []);

useEffect(() => {
if (!media) {
setLayers([]);
return;
}
if (media.url === activeUrl) return;
setLayers((prev) => {
// If the URL already exists (possibly inactive), reactivate it
const existing = prev.find((l) => l.url === media.url);
if (existing) {
// Cancel any pending cleanup that might remove this layer
if (cleanupRef.current) {
clearTimeout(cleanupRef.current);
cleanupRef.current = null;
}
return prev.map((l) => ({
...l,
active: l.url === media.url,
}));
}
return [...prev, { url: media.url, type: media.type, active: false }];
});
}, [media?.url, activeUrl]);

const handleMediaReady = useCallback((readyUrl: string) => {
setLayers((prev) => {
const staleUrls = prev.filter((l) => l.url !== readyUrl).map((l) => l.url);
if (cleanupRef.current) clearTimeout(cleanupRef.current);
cleanupRef.current = setTimeout(() => {
setLayers((curr) => curr.filter((l) => !staleUrls.includes(l.url)));
}, 300);
return prev.map((l) => ({ ...l, active: l.url === readyUrl }));
});
Comment on lines +145 to +153
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleMediaReady always activates the layer that finished loading (readyUrl). If the user switches emotions quickly, an old/inactive layer can finish loading later and incorrectly become active, causing the avatar to “jump back” to the wrong emotion. Consider tracking the latest desired media.url in a ref and ignoring readyUrl events that don’t match it (and/or storing a per-layer generation token) before promoting a layer to active.

Copilot uses AI. Check for mistakes.
}, []);

if (layers.length === 0) {
return <div className={styles.avatarPlaceholder}>{character.character_name.charAt(0)}</div>;
}

return (
<>
{layers.map((layer) => {
const layerStyle: React.CSSProperties = {
position: 'absolute',
inset: 0,
opacity: layer.active ? 1 : 0,
transition: 'opacity 0.25s ease-out',
};
if (layer.type === 'video') {
return (
<video
key={layer.url}
className={styles.avatarImage}
style={layerStyle}
src={layer.url}
autoPlay
loop={layer.active ? isIdle : false}
muted
playsInline
onCanPlay={!layer.active ? () => handleMediaReady(layer.url) : undefined}
onEnded={layer.active && !isIdle ? onEmotionEnd : undefined}
/>
);
}
return (
<img
key={layer.url}
className={styles.avatarImage}
style={layerStyle}
src={layer.url}
alt={character.character_name}
onLoad={!layer.active ? () => handleMediaReady(layer.url) : undefined}
/>
);
})}
</>
);
});
Loading
Loading