+
- {isStreaming && !content ? (
- statusText ? (
-
- ) : (
-
- )
+ {isStreaming && !content && toolStatuses?.length ? (
+
+ {toolStatuses.map((status) => (
+
+ ))}
+
+ ) : isStreaming && !content ? (
+
+ {statusText ? (
+
+ ) : (
+
+ )}
+
) : (
-
-
-
-
+
+ {content.trim().length > 0 && (
+
+ )}
+ {toolStatuses?.map((status) => (
+
+ ))}
)}
@@ -250,16 +268,24 @@ export const ChatMessages = ({ messages }: ChatMessagesProps) => {
return (
{/* 가운데 정렬 컨테이너 - ChatInput과 동일한 max-width */}
-
+
{messages.map((message, index) => {
const prevMessage = messages[index - 1];
const isNewGroup =
prevMessage?.role === "assistant" && message.role === "user";
+ const isAnswerAfterQuestion =
+ prevMessage?.role === "user" && message.role === "assistant";
const marginTop =
- index === 0 ? "" : isNewGroup ? "mt-[40px]" : "mt-[12px]";
+ index === 0
+ ? ""
+ : isNewGroup
+ ? "mt-[40px]"
+ : isAnswerAfterQuestion
+ ? "mt-[24px]"
+ : "mt-[12px]";
return (
{
content={message.content}
isStreaming={message.isStreaming}
statusText={message.statusText}
+ toolStatuses={message.toolStatuses}
/>
)}
diff --git a/src/features/chat/components/rightpanel/RightPanel.tsx b/src/features/chat/components/rightpanel/RightPanel.tsx
index c47cc71f..f00e82f1 100644
--- a/src/features/chat/components/rightpanel/RightPanel.tsx
+++ b/src/features/chat/components/rightpanel/RightPanel.tsx
@@ -37,7 +37,7 @@ export const RightPanel = ({
{/* 입력 영역 */}
-
+
{
const iconColor = isBlue ? "#2A6AFF" : "#6B7280";
return (
-
+
) => (
+
+);
+
+const TransformIcon = (props: SVGProps) => {
+ // 같은 아이콘이 여러 개 렌더링돼도 gradient id가 충돌하지 않도록 인스턴스마다 고유 id 생성
+ const gradientId = useId();
+
+ return (
+
+ );
+};
+
+const ICON_BY_TYPE: Record = {
+ python: PythonIcon,
+ transform: TransformIcon,
+};
+
+export const ToolStatusBar = ({
+ icon,
+ label,
+ className = "",
+}: ToolStatusBarProps) => {
+ const Icon = ICON_BY_TYPE[icon];
+
+ return (
+
+ );
+};
diff --git a/src/features/chat/components/rightpanel/index.ts b/src/features/chat/components/rightpanel/index.ts
index 73a93b39..b61559ed 100644
--- a/src/features/chat/components/rightpanel/index.ts
+++ b/src/features/chat/components/rightpanel/index.ts
@@ -1,2 +1,3 @@
export { RightPanel } from "./RightPanel";
export { ChatMessages } from "./ChatMessages";
+export { ToolStatusBar } from "./ToolStatusBar";
diff --git a/src/features/chat/components/rightpanel/status_bar_styles.ts b/src/features/chat/components/rightpanel/status_bar_styles.ts
new file mode 100644
index 00000000..a42a7246
--- /dev/null
+++ b/src/features/chat/components/rightpanel/status_bar_styles.ts
@@ -0,0 +1,8 @@
+/**
+ * AI 상태바 공통 컨테이너 스타일
+ *
+ * ThinkingBar / FinalResponseLoadingBar / ToolStatusBar가 공유하는
+ * 둥근 회색 바 컨테이너의 기본 클래스. 패딩은 각 컴포넌트에서 덧붙인다.
+ */
+export const STATUS_BAR_BASE_CLASS =
+ "flex min-h-[30px] w-full items-center gap-[8px] overflow-hidden rounded-[12px] border-[0.5px] border-[#D1D6DE] bg-[#E3E7ED]";
diff --git a/src/features/chat/hooks/tool_status_mock.ts b/src/features/chat/hooks/tool_status_mock.ts
new file mode 100644
index 00000000..b0d42edb
--- /dev/null
+++ b/src/features/chat/hooks/tool_status_mock.ts
@@ -0,0 +1,42 @@
+import type { ChatMessage } from "../types/chat_types";
+
+/**
+ * 도구 상태바(ToolStatusBar) UI 확인용 목업 데이터
+ *
+ * 개발 환경(`import.meta.env.DEV`)에서 `?toolStatusMock=true` 쿼리 파라미터가
+ * 있을 때만 사용된다. 실제 SSE 이벤트 연동 시 제거 예정.
+ */
+export const MOCK_TOOL_STATUSES: NonNullable = [
+ {
+ id: "mock-python-create",
+ icon: "python",
+ label: "파이썬 코드 생성 중...",
+ },
+ {
+ id: "mock-python-calculate",
+ icon: "python",
+ label: "파이썬 코드로 문제의 정답 계산 중...",
+ },
+ {
+ id: "mock-transform-create",
+ icon: "transform",
+ label: "변형 문제가 생성되는 중...",
+ },
+];
+
+/** 첫 번째 assistant 메시지에 목업 도구 상태바를 주입한다. */
+export const withMockToolStatuses = (
+ messages: ChatMessage[],
+): ChatMessage[] => {
+ const firstAssistantIndex = messages.findIndex(
+ (message) => message.role === "assistant",
+ );
+
+ if (firstAssistantIndex === -1) return messages;
+
+ return messages.map((message, index) =>
+ index === firstAssistantIndex
+ ? { ...message, toolStatuses: MOCK_TOOL_STATUSES }
+ : message,
+ );
+};
diff --git a/src/features/chat/hooks/useChatMessages.ts b/src/features/chat/hooks/useChatMessages.ts
index 30a06b97..f6b33c59 100644
--- a/src/features/chat/hooks/useChatMessages.ts
+++ b/src/features/chat/hooks/useChatMessages.ts
@@ -25,6 +25,7 @@ import { creditKeys } from "@/features/settings/hooks/useCredit";
import { userKeys } from "@/features/settings/hooks/useUser";
import { assetKeys } from "@/features/storage/hooks/useAssets";
import { showErrorToast } from "@/shared/lib/toast";
+import { MOCK_TOOL_STATUSES, withMockToolStatuses } from "./tool_status_mock";
type PendingAttachment = ChatSendData["attachments"][number];
@@ -116,6 +117,8 @@ export const useChatMessages = () => {
const chatEntrySource =
(initialStateRef.current as { chatEntrySource?: string } | null)
?.chatEntrySource ?? "unknown";
+ const shouldUseToolStatusMock =
+ import.meta.env.DEV && searchParams.get("toolStatusMock") === "true";
const viewerFile = firstMessageData?.viewerFile;
@@ -206,10 +209,13 @@ export const useChatMessages = () => {
setMessages((prev) => {
const serverIds = new Set(serverMessages.map((m) => m.id));
const localOnly = prev.filter((m) => !serverIds.has(m.id));
- return [...serverMessages, ...localOnly];
+ const mergedMessages = [...serverMessages, ...localOnly];
+ return shouldUseToolStatusMock
+ ? withMockToolStatuses(mergedMessages)
+ : mergedMessages;
});
}
- }, [noteDetail, hasFirstMessage]);
+ }, [noteDetail, hasFirstMessage, shouldUseToolStatusMock]);
// ─── 스트리밍 상태 관리 ───
const [isUploading, setIsUploading] = useState(false);
@@ -249,12 +255,14 @@ export const useChatMessages = () => {
// 컴포넌트 언마운트 시 진행 중인 스트리밍 + 미리보기 URL 정리
useEffect(() => {
+ const managedPreviewUrls = managedPreviewUrlsRef.current;
+
return () => {
abortControllerRef.current?.abort();
- managedPreviewUrlsRef.current.forEach((url) => {
+ managedPreviewUrls.forEach((url) => {
URL.revokeObjectURL(url);
});
- managedPreviewUrlsRef.current.clear();
+ managedPreviewUrls.clear();
};
}, []);
@@ -501,6 +509,7 @@ export const useChatMessages = () => {
role: "assistant",
content: "",
isStreaming: true,
+ toolStatuses: shouldUseToolStatusMock ? MOCK_TOOL_STATUSES : undefined,
},
]);
@@ -659,6 +668,7 @@ export const useChatMessages = () => {
registerPreviewUrl,
searchParams,
setSearchParams,
+ shouldUseToolStatusMock,
]);
// ─── 후속 대화 전송 핸들러 ───
@@ -728,6 +738,9 @@ export const useChatMessages = () => {
role: "assistant",
content: "",
isStreaming: true,
+ toolStatuses: shouldUseToolStatusMock
+ ? MOCK_TOOL_STATUSES
+ : undefined,
},
]);
@@ -787,7 +800,13 @@ export const useChatMessages = () => {
setIsStreamingResponse(false);
}
},
- [noteId, processStream, queryClient, toMessageAttachment],
+ [
+ noteId,
+ processStream,
+ queryClient,
+ shouldUseToolStatusMock,
+ toMessageAttachment,
+ ],
);
// ─── 파생 데이터 ───
diff --git a/src/features/chat/hooks/useChatPanel.ts b/src/features/chat/hooks/useChatPanel.ts
index fd93f30d..4252aa7c 100644
--- a/src/features/chat/hooks/useChatPanel.ts
+++ b/src/features/chat/hooks/useChatPanel.ts
@@ -42,7 +42,7 @@ export const useChatPanel = ({
} = useResizable({
initialWidth: 50,
leftMinPx: 382,
- rightMinPx: 302,
+ rightMinPx: 330,
});
const handleTabChange = useCallback(
diff --git a/src/features/chat/types/chat_types.ts b/src/features/chat/types/chat_types.ts
index 137a2545..bb5e4b8b 100644
--- a/src/features/chat/types/chat_types.ts
+++ b/src/features/chat/types/chat_types.ts
@@ -10,6 +10,16 @@ export interface MessageAttachment {
previewUrl?: string;
}
+/** AI 도구 실행 상태바 아이콘 종류 */
+export type ToolStatusIconType = "python" | "transform";
+
+/** AI 도구 실행 상태바 */
+export interface ToolStatus {
+ id: string;
+ icon: ToolStatusIconType;
+ label: string;
+}
+
/** 채팅 메시지 */
export interface ChatMessage {
id: string;
@@ -21,4 +31,6 @@ export interface ChatMessage {
isStreaming?: boolean;
/** AI 진행 상황 텍스트 — message(custom) 이벤트의 status (assistant 메시지에만 사용) */
statusText?: string;
+ /** AI 도구 실행 상태바 목록 (assistant 메시지에만 사용) */
+ toolStatuses?: ToolStatus[];
}
diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx
index 5748fc70..1209ec5d 100644
--- a/src/pages/ChatPage.tsx
+++ b/src/pages/ChatPage.tsx
@@ -67,7 +67,7 @@ const ChatPageContent = () => {
return (