diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index 7bbb49f..476c4a1 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -241,6 +241,21 @@ } } +@keyframes stepIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-step-in { + animation: stepIn 0.4s cubic-bezier(0.23, 1, 0.32, 1) forwards; +} + .animate-text-glow { animation: textGlow 2.2s ease-in-out infinite; } diff --git a/frontend/pluto_duck_frontend/components/ai-elements/code-block.tsx b/frontend/pluto_duck_frontend/components/ai-elements/code-block.tsx index 90cf7c3..a44ef9f 100644 --- a/frontend/pluto_duck_frontend/components/ai-elements/code-block.tsx +++ b/frontend/pluto_duck_frontend/components/ai-elements/code-block.tsx @@ -10,7 +10,6 @@ import { type HTMLAttributes, useContext, useEffect, - useRef, useState, } from "react"; import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki"; @@ -50,6 +49,19 @@ const lineNumberTransformer: ShikiTransformer = { }, }; +function escapeHtml(raw: string): string { + return raw + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function createPlainCodeHtml(code: string): string { + return `
${escapeHtml(code)}
`; +} + export async function highlightCode( code: string, language: BundledLanguage, @@ -59,18 +71,25 @@ export async function highlightCode( ? [lineNumberTransformer] : []; - return await Promise.all([ - codeToHtml(code, { - lang: language, - theme: "one-light", - transformers, - }), - codeToHtml(code, { - lang: language, - theme: "one-dark-pro", - transformers, - }), - ]); + try { + return await Promise.all([ + codeToHtml(code, { + lang: language, + theme: "one-light", + transformers, + }), + codeToHtml(code, { + lang: language, + theme: "one-dark-pro", + transformers, + }), + ]); + } catch (error) { + // Keep chat rendering resilient when Shiki theme chunks fail to load in runtime. + console.error("Failed to highlight code block with Shiki:", error); + const fallbackHtml = createPlainCodeHtml(code); + return [fallbackHtml, fallbackHtml]; + } } export const CodeBlock = ({ @@ -83,19 +102,19 @@ export const CodeBlock = ({ }: CodeBlockProps) => { const [html, setHtml] = useState(""); const [darkHtml, setDarkHtml] = useState(""); - const mounted = useRef(false); useEffect(() => { + let cancelled = false; + highlightCode(code, language, showLineNumbers).then(([light, dark]) => { - if (!mounted.current) { + if (!cancelled) { setHtml(light); setDarkHtml(dark); - mounted.current = true; } }); return () => { - mounted.current = false; + cancelled = true; }; }, [code, language, showLineNumbers]); diff --git a/frontend/pluto_duck_frontend/components/chat/ChatPanel.tsx b/frontend/pluto_duck_frontend/components/chat/ChatPanel.tsx index a625945..1da533f 100644 --- a/frontend/pluto_duck_frontend/components/chat/ChatPanel.tsx +++ b/frontend/pluto_duck_frontend/components/chat/ChatPanel.tsx @@ -19,7 +19,7 @@ import { PromptInputModelSelectItem, type PromptInputMessage, } from '../ai-elements/prompt-input'; -import { ActivityLoader } from '../ai-elements/activity-loader'; +import { LoadingDots } from '../ai-elements/loading-dots'; import { MentionMenu } from './MentionMenu'; import { ChatOnboarding } from './ChatOnboarding'; import { RenderItem, type FeedbackType } from './renderers'; @@ -282,8 +282,9 @@ const ConversationMessages = memo(function ConversationMessages({ })} {chatLoadingMode === 'agent-streaming-fallback' && ( -
- +
+ 에이전트가 응답을 생성하고 있습니다 +
)}