import { ArrowsAltOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, SaveOutlined, ShrinkOutlined } from '@ant-design/icons'; import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message } from 'antd'; import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess'; import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel'; import { buildChatPath } from '../../../app/main/routes'; import { useTokenAccess } from '../../../app/main/tokenAccess'; import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select'; import { listSavedLayouts, saveLayout, type SavedLayoutRecord } from '../../../views/play/layoutStorage'; import { isReusableLayoutConversation, resolveLayoutCodexRequestSocketUrl, resolveLayoutConversationTitle } from './featureMenu.chat'; import type { FeatureMenuTabKey, LayoutInteractionRule } from './featureMenu.types'; import { buildFeatureMenuPrompt, normalizeLayoutInteractions } from './featureMenu.utils'; import './FeatureMenuLayoutPage.css'; const { Paragraph, Text } = Typography; type FeatureMenuLayoutPageProps = { layoutId: string; savedLayouts: SavedLayoutRecord[]; onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void; }; export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsChange }: FeatureMenuLayoutPageProps) { const navigate = useNavigate(); const { chatTypes } = useChatTypeRegistry(); const { hasAccess } = useTokenAccess(); const [messageApi, contextHolder] = message.useMessage(); const [modalApi, modalContextHolder] = Modal.useModal(); const [selectedLayoutId, setSelectedLayoutId] = useState(null); const [selectedFeatureId, setSelectedFeatureId] = useState(null); const [activeTab, setActiveTab] = useState('description'); const [isEditorMaximized, setIsEditorMaximized] = useState(false); const [draftBody, setDraftBody] = useState(''); const [isSaving, setIsSaving] = useState(false); const [isSending, setIsSending] = useState(false); const [selectedChatTypeId, setSelectedChatTypeId] = useState(null); const chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const availableChatTypes = useMemo( () => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)), [chatPermissionRoles, chatTypes], ); const selectedChatType = useMemo( () => availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? availableChatTypes[0] ?? null, [availableChatTypes, selectedChatTypeId], ); const layoutOptions = useMemo( () => [...savedLayouts] .sort((left, right) => { if (left.id === layoutId) { return -1; } if (right.id === layoutId) { return 1; } return 0; }) .map((item) => ({ code: item.id, value: item.name, })), [layoutId, savedLayouts], ); useEffect(() => { if (savedLayouts.length === 0) { setSelectedLayoutId(null); return; } if (selectedLayoutId && savedLayouts.some((item) => item.id === selectedLayoutId)) { return; } const fallback = savedLayouts.find((item) => item.id === layoutId) ?? savedLayouts[0]; setSelectedLayoutId(fallback?.id ?? null); }, [layoutId, savedLayouts, selectedLayoutId]); useEffect(() => { if (availableChatTypes.length === 0) { setSelectedChatTypeId(null); return; } if (selectedChatTypeId && availableChatTypes.some((item) => item.id === selectedChatTypeId)) { return; } setSelectedChatTypeId(availableChatTypes[0]?.id ?? null); }, [availableChatTypes, selectedChatTypeId]); const selectedLayout = selectedLayoutId ? savedLayouts.find((item) => item.id === selectedLayoutId) ?? null : null; const selectedLayoutInteractions = useMemo( () => normalizeLayoutInteractions(selectedLayout?.tree ?? null), [selectedLayout?.tree], ); const featureOptions = useMemo( () => selectedLayoutInteractions.map((item, index) => ({ code: item.id, value: item.title || `기능설명 ${index + 1}`, })), [selectedLayoutInteractions], ); useEffect(() => { if (featureOptions.length === 0) { setSelectedFeatureId(null); return; } if (selectedFeatureId && featureOptions.some((item) => item.code === selectedFeatureId)) { return; } setSelectedFeatureId(featureOptions[0]?.code ?? null); }, [featureOptions, selectedFeatureId]); const selectedFeature = selectedFeatureId ? selectedLayoutInteractions.find((item) => item.id === selectedFeatureId) ?? null : null; useEffect(() => { setDraftBody(selectedFeature?.description ?? ''); setActiveTab('description'); }, [selectedFeature?.description, selectedFeature?.id]); const isDirty = Boolean(selectedFeature && selectedFeature.description !== draftBody); const refreshLayouts = async () => { const nextLayouts = await listSavedLayouts(); onSavedLayoutsChange?.(nextLayouts); return nextLayouts; }; const handleSave = async () => { if (!selectedLayout || !selectedFeature) { return; } const nextDescription = draftBody.trim(); if (!nextDescription) { void messageApi.warning('기능설명 본문을 입력하세요.'); return; } setIsSaving(true); try { const nextInteractions = selectedLayoutInteractions.map((item) => item.id === selectedFeature.id ? { ...item, description: nextDescription, } : item, ); const nextTree = selectedLayout.tree && typeof selectedLayout.tree === 'object' ? { ...(selectedLayout.tree as Record), interactions: nextInteractions, interactionMode: 'scoped-v2', } : { root: null, interactions: nextInteractions, interactionMode: 'scoped-v2', }; await saveLayout({ ...selectedLayout, updatedAt: new Date().toISOString(), tree: nextTree, }); await refreshLayouts(); void messageApi.success('기능설명을 저장했습니다.'); } catch (error) { void messageApi.error(error instanceof Error ? error.message : '기능설명 저장에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleAdd = async () => { if (!selectedLayout) { return; } const nextIndex = selectedLayoutInteractions.length + 1; const nextFeatureId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `feature-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; const nextFeature: LayoutInteractionRule = { id: nextFeatureId, title: `기능설명 ${nextIndex}`, description: '', implementationNotes: '', }; const nextInteractions = [...selectedLayoutInteractions, nextFeature]; const nextTree = selectedLayout.tree && typeof selectedLayout.tree === 'object' ? { ...(selectedLayout.tree as Record), interactions: nextInteractions, interactionMode: 'scoped-v2', } : { root: null, interactions: nextInteractions, interactionMode: 'scoped-v2', }; setIsSaving(true); try { await saveLayout({ ...selectedLayout, updatedAt: new Date().toISOString(), tree: nextTree, }); await refreshLayouts(); setSelectedFeatureId(nextFeatureId); setDraftBody(''); setActiveTab('description'); void messageApi.success('기능설명 항목을 추가했습니다.'); } catch (error) { void messageApi.error(error instanceof Error ? error.message : '기능설명 항목 추가에 실패했습니다.'); } finally { setIsSaving(false); } }; const handleDelete = () => { if (!selectedLayout || !selectedFeature) { return; } void modalApi.confirm({ title: '선택한 기능설명을 삭제할까요?', content: '삭제 후 되돌릴 수 없습니다.', okText: '삭제', cancelText: '취소', okButtonProps: { danger: true }, async onOk() { const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id); const nextTree = selectedLayout.tree && typeof selectedLayout.tree === 'object' ? { ...(selectedLayout.tree as Record), interactions: nextInteractions, interactionMode: 'scoped-v2', } : { root: null, interactions: nextInteractions, interactionMode: 'scoped-v2', }; await saveLayout({ ...selectedLayout, updatedAt: new Date().toISOString(), tree: nextTree, }); await refreshLayouts(); void messageApi.success('기능설명을 삭제했습니다.'); }, }); }; const handleCodexExecute = async () => { if (!selectedLayout || !selectedChatType) { if (!selectedChatType) { void messageApi.error('사용 가능한 채팅유형이 없어 Codex 실행을 보낼 수 없습니다.'); } return; } const rules = selectedFeature ? [selectedFeature] : selectedLayoutInteractions; const prompt = buildFeatureMenuPrompt(selectedLayout, rules); const conversationTitle = resolveLayoutConversationTitle(selectedLayout.name); setIsSending(true); try { const conversations = await fetchChatConversations(); const matchedConversation = conversations.find((item) => isReusableLayoutConversation(item, conversationTitle, selectedChatType.id), ); const shouldCreateConversation = !matchedConversation; const targetSessionId = matchedConversation?.sessionId || (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); if (shouldCreateConversation) { await createChatConversationRoom({ sessionId: targetSessionId, title: conversationTitle, chatTypeId: selectedChatType.id, lastChatTypeId: selectedChatType.id, contextLabel: selectedChatType.name, contextDescription: selectedChatType.description, notifyOffline: true, }); } const requestId = `client-${Date.now().toString(36)}`; const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId); await new Promise((resolve, reject) => { const socket = new WebSocket(socketUrl); const timeoutId = window.setTimeout(() => { socket.close(); reject(new Error('Codex 요청 연결 시간이 초과되었습니다.')); }, 8000); socket.addEventListener('open', () => { socket.send( JSON.stringify({ type: 'message:send', payload: { text: prompt, chatTypeId: selectedChatType.id, chatTypeLabel: selectedChatType.name, chatTypeDescription: selectedChatType.description, requestId, mode: 'queue', }, }), ); window.setTimeout(() => { window.clearTimeout(timeoutId); socket.close(); resolve(); }, 120); }); socket.addEventListener('error', () => { window.clearTimeout(timeoutId); reject(new Error('Codex 요청 연결에 실패했습니다.')); }); socket.addEventListener('close', (event) => { if (!event.wasClean && event.code !== 1000) { window.clearTimeout(timeoutId); reject(new Error('Codex 요청 연결이 비정상 종료되었습니다.')); } }); }); void messageApi.success('Codex 실행 요청을 전송했습니다.'); navigate(`${buildChatPath('live')}?sessionId=${encodeURIComponent(targetSessionId)}`); } catch (error) { void messageApi.error(error instanceof Error ? error.message : 'Codex 실행 요청에 실패했습니다.'); } finally { setIsSending(false); } }; const editorTabItems = [ { key: 'description', label: '기능설명 입력', children: (
{ setDraftBody(event.target.value); }} />
), }, { key: 'notes', label: 'Codex 설명', children: selectedFeature?.implementationNotes.trim() ? (
{selectedFeature.implementationNotes}
) : (
), }, ] satisfies Array<{ key: FeatureMenuTabKey; label: string; children: ReactNode }>; return (
{contextHolder} {modalContextHolder}
레이아웃명 { setSelectedLayoutId(nextCode ?? null); }} />
기능설명 선택 { setSelectedFeatureId(nextCode ?? null); }} />
Codex 채팅유형 ({ code: item.id, value: item.name, }))} value={selectedChatType?.id ?? undefined} allowClear={false} disabled={availableChatTypes.length === 0} className="feature-menu-layout-page__select" onChange={(nextCode) => { setSelectedChatTypeId(nextCode ?? null); }} />
{selectedFeature ? ( <>
{ setActiveTab(nextKey as FeatureMenuTabKey); }} /> ) : (
)}
); }