import { memo, useEffect, useRef, useState } from 'react' import type { StudioMessage, StudioSession } from '../protocol/studio-agent-types' interface StudioCommandPanelProps { session: StudioSession | null messages: StudioMessage[] latestAssistantText: string isBusy: boolean disabled: boolean onRun: (inputText: string) => Promise | void onExit: () => void } export function StudioCommandPanel({ session, messages, latestAssistantText, isBusy, disabled, onRun, onExit, }: StudioCommandPanelProps) { const [input, setInput] = useState('') const [animatedAssistantText, setAnimatedAssistantText] = useState('') const scrollRef = useRef(null) const endRef = useRef(null) const inputRef = useRef(null) const streamRateRef = useRef(0) const latestTextMetaRef = useRef<{ text: string; at: number }>({ text: '', at: 0 }) const lastScrollSignatureRef = useRef('') const handleSubmit = async () => { const next = input.trim() if (!next || disabled) { return } setInput('') try { await onRun(next) } catch { setInput(next) } inputRef.current?.focus() } const lastMessage = messages.at(-1) ?? null const streamIntoLastAssistant = Boolean(lastMessage && lastMessage.role === 'assistant' && (isBusy || latestAssistantText || animatedAssistantText)) useEffect(() => { const signature = [ messages.length, lastMessage?.id ?? '', latestAssistantText.length, isBusy ? 'busy' : 'idle', ].join(':') if (signature === lastScrollSignatureRef.current) { return } lastScrollSignatureRef.current = signature if (typeof endRef.current?.scrollIntoView === 'function') { endRef.current.scrollIntoView({ block: 'end' }) } }, [isBusy, lastMessage?.id, latestAssistantText.length, messages.length]) useEffect(() => { if (!disabled) { inputRef.current?.focus() } }, [disabled, session?.id]) useEffect(() => { if (!latestAssistantText) { streamRateRef.current = 0 latestTextMetaRef.current = { text: '', at: 0 } const frame = window.requestAnimationFrame(() => { setAnimatedAssistantText('') }) return () => window.cancelAnimationFrame(frame) } const now = Date.now() const prev = latestTextMetaRef.current if (prev.text && latestAssistantText.startsWith(prev.text) && latestAssistantText.length > prev.text.length) { const deltaChars = latestAssistantText.length - prev.text.length const deltaMs = Math.max(1, now - prev.at) const charsPerSecond = (deltaChars * 1000) / deltaMs streamRateRef.current = streamRateRef.current === 0 ? charsPerSecond : streamRateRef.current * 0.68 + charsPerSecond * 0.32 } else if (!prev.text) { streamRateRef.current = 0 } latestTextMetaRef.current = { text: latestAssistantText, at: now } const frame = window.requestAnimationFrame(() => { setAnimatedAssistantText((current) => { if (!latestAssistantText.startsWith(current)) { return latestAssistantText.slice(0, 1) } return current || latestAssistantText.slice(0, 1) }) }) return () => window.cancelAnimationFrame(frame) }, [latestAssistantText]) useEffect(() => { if (!latestAssistantText) { return } if (animatedAssistantText === latestAssistantText) { return } const timer = window.setTimeout(() => { setAnimatedAssistantText((current) => { if (!latestAssistantText.startsWith(current)) { return latestAssistantText.slice(0, 1) } const nextLength = current.length + nextTypeStep(latestAssistantText.length - current.length) return latestAssistantText.slice(0, nextLength) }) }, nextTypeDelay(latestAssistantText, animatedAssistantText.length, streamRateRef.current)) return () => window.clearTimeout(timer) }, [animatedAssistantText, latestAssistantText]) return (
{session?.title ?? 'Studio'}
{messages.length === 0 && (
🐾
Ready for commands
)}
{messages.map((message) => { if (message.role === 'user') { return ( ) } const isStreamingTarget = streamIntoLastAssistant && lastMessage?.id === message.id return ( ) })} {(isBusy || latestAssistantText || animatedAssistantText) && !streamIntoLastAssistant && (
Agent
{animatedAssistantText ? (
{animatedAssistantText}
) : (
Thinking
)}
)}
{'>'} setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() void handleSubmit() } }} placeholder={disabled ? 'Initializing...' : 'Send instruction to ManimCat...'} disabled={disabled} className="flex-1 bg-transparent text-[14px] font-medium leading-relaxed text-text-primary outline-none placeholder:text-text-secondary/25 disabled:cursor-not-allowed disabled:opacity-50" />
Enter to send
) } const UserMessageItem = memo(function UserMessageItem({ message, }: { message: Extract }) { return (
Input / User
{message.text}
) }) const AssistantMessageItem = memo(function AssistantMessageItem({ message, isStreamingTarget, streamedText, showCaret, }: { message: Extract isStreamingTarget: boolean streamedText: string showCaret: boolean }) { const textParts = message.parts.filter((part) => part.type === 'text' || part.type === 'reasoning') const toolParts = message.parts.filter((part) => part.type === 'tool') const hasStreamedText = streamedText.length > 0 return (
Output / Agent
{toolParts.length > 0 && (
{toolParts.map((part, i) => { const status = part.state.status === 'error' ? '!' : part.state.status === 'completed' ? '->' : '...' const args = 'input' in part.state ? truncateArgs(part.state.input) : '' return (
{status} {part.tool} ({args})
) })}
)} {isStreamingTarget && hasStreamedText ? (
{streamedText} {showCaret && }
) : textParts.map((part, i) => { const text = part.text.trim() if (!text) return null return (
{text}
) })} {!isStreamingTarget && textParts.every((part) => !part.text.trim()) && (
(No response output)
)}
) }) function neutralToolTone(status: string) { switch (status) { case 'error': return 'text-rose-500/70' case 'completed': return 'text-text-primary/40' default: return 'text-amber-500/70' } } function truncateArgs(input?: Record) { if (!input) return '' const str = JSON.stringify(input) return str.length > 60 ? `${str.slice(0, 57)}...` : str } function nextTypeDelay(target: string, currentLength: number, streamRate: number) { const nextChar = target[currentLength] ?? '' const backlog = target.length - currentLength const targetCharsPerSecond = resolveTypingCharsPerSecond(backlog, streamRate) if (!nextChar) { return 18 } if (nextChar === '\n') { return 1000 / Math.max(targetCharsPerSecond * 1.4, 1) } if (/[,。!?;:,.!?;:]/.test(nextChar)) { return Math.max(24, 1000 / Math.max(targetCharsPerSecond * 0.55, 1)) } if (/\s/.test(nextChar)) { return Math.max(10, 1000 / Math.max(targetCharsPerSecond * 1.25, 1)) } return Math.max(12, 1000 / Math.max(targetCharsPerSecond, 1)) } function nextTypeStep(backlog: number) { if (backlog >= 28) { return 3 } if (backlog >= 16) { return 2 } return 1 } function resolveTypingCharsPerSecond(backlog: number, streamRate: number) { const minCharsPerSecond = 10 const maxCharsPerSecond = 26 const adaptiveBase = streamRate > 0 ? streamRate * 0.55 + 6 : minCharsPerSecond if (backlog >= 10) { return clamp(adaptiveBase, 12, maxCharsPerSecond) } return clamp(adaptiveBase, minCharsPerSecond, 18) } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) }