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

import React, { useState, useEffect, useCallback, memo } 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;

useEffect(() => {
if (!media) {
setLayers([]);
return;
}
if (media.url === activeUrl) return;
setLayers((prev) => {
if (prev.some((l) => l.url === media.url)) return prev;
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);
setTimeout(() => {
setLayers((curr) => curr.filter((l) => !staleUrls.includes(l.url)));
}, 300);
Comment on lines +127 to +131
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

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

The setTimeout callback calls setLayers without any unmount cleanup. If the component unmounts within the 300ms delay, this can trigger a state update on an unmounted component. Track the timeout ID and clear it in an effect cleanup (or guard with an isMounted ref) to avoid leaks/warnings.

Copilot uses AI. Check for mistakes.
return prev.map((l) => ({ ...l, active: l.url === readyUrl }));
});
}, []);

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