import { memo, useEffect, useRef, useState } from 'react' import type { StudioMessage, StudioSession } from '../protocol/studio-agent-types' import { useI18n } from '../../i18n' import { StudioMarkdown } from './StudioMarkdown' 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 { t } = useI18n() 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 focusInput = () => { if (disabled) { return } inputRef.current?.focus() } const lastMessage = messages.at(-1) ?? null const streamIntoLastAssistant = Boolean(lastMessage && lastMessage.role === 'assistant' && (isBusy || latestAssistantText || animatedAssistantText)) const visibleMessages = messages.filter((message, index) => { if (message.role === 'user') { return true } return shouldRenderAssistantMessage(message, { isLast: index === messages.length - 1, isBusy, }) }) 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) { focusInput() } }, [disabled, session?.id]) useEffect(() => { const handleWindowKeyDown = (event: KeyboardEvent) => { if (disabled || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) { return } const target = event.target as HTMLElement | null if ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable ) { return } if (!shouldRedirectKeyToInput(event)) { return } focusInput() if (event.key === 'Backspace') { setInput((current) => current.slice(0, -1)) event.preventDefault() return } if (event.key.length === 1) { setInput((current) => `${current}${event.key}`) event.preventDefault() } } window.addEventListener('keydown', handleWindowKeyDown) return () => window.removeEventListener('keydown', handleWindowKeyDown) }, [disabled]) 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 ?? t('studio.title')}
{messages.length === 0 && (
🐾
{t('studio.readyForCommands')}
)}
{visibleMessages.map((message) => { if (message.role === 'user') { return ( ) } const isStreamingTarget = streamIntoLastAssistant && lastMessage?.id === message.id return ( ) })}
{'>'} setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() void handleSubmit() } }} placeholder={disabled ? t('studio.initializing') : t('studio.commandPlaceholder')} disabled={false} aria-disabled={disabled} className="flex-1 bg-transparent text-[14px] font-medium leading-relaxed text-text-primary outline-none placeholder:text-text-secondary/25" />
{t('studio.enterToSend')}
) } const UserMessageItem = memo(function UserMessageItem({ message, }: { message: Extract }) { const { t } = useI18n() return (
{t('studio.inputUser')}
) }) const AssistantMessageItem = memo(function AssistantMessageItem({ message, isStreamingTarget, streamedText, showCaret, }: { message: Extract isStreamingTarget: boolean streamedText: string showCaret: boolean }) { const { t } = useI18n() 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 const hasRenderableText = textParts.some((part) => part.text.trim()) return (
{t('studio.outputAgent')}
{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 ? ( ) : isStreamingTarget && !hasRenderableText ? (
{t('studio.thinking')}
) : textParts.map((part, i) => { const text = part.text.trim() if (!text) return null return ( ) })} {!isStreamingTarget && !hasRenderableText && (
{t('studio.noResponseOutput')}
)}
) }) 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)) } function shouldRedirectKeyToInput(event: KeyboardEvent): boolean { return event.key.length === 1 || event.key === 'Backspace' } function shouldRenderAssistantMessage( message: Extract, options: { isLast: boolean; isBusy: boolean }, ): boolean { const hasRenderableText = message.parts.some((part) => ( (part.type === 'text' || part.type === 'reasoning') && part.text.trim() )) const hasToolParts = message.parts.some((part) => part.type === 'tool') if (hasRenderableText || hasToolParts) { return true } return options.isLast && options.isBusy }