chore: sync local workspace changes

This commit is contained in:
2026-05-07 11:03:47 +09:00
parent 2df0ba30cb
commit 82c0d8a197
217 changed files with 44873 additions and 1678 deletions

View File

@@ -0,0 +1,520 @@
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 {
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
} from '../../../app/main/chatTypeDefaults';
import { buildChatPath } from '../../../app/main/routes';
import { useTokenAccess } from '../../../app/main/tokenAccess';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import { resolvePreferredLayoutCodexChatType } from '../../../views/play/layoutCodexChatType';
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<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<FeatureMenuTabKey>('description');
const [isEditorMaximized, setIsEditorMaximized] = useState(false);
const [draftBody, setDraftBody] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isSending, setIsSending] = useState(false);
const chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const availableChatTypes = useMemo(
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
[chatPermissionRoles, chatTypes],
);
const preferredCodexChatType = useMemo(
() => resolvePreferredLayoutCodexChatType(availableChatTypes),
[availableChatTypes],
);
const targetChatType = preferredCodexChatType ?? {
id: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
};
const layoutOptions = useMemo<SelectOptionItem[]>(
() =>
[...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]);
const selectedLayout = selectedLayoutId ? savedLayouts.find((item) => item.id === selectedLayoutId) ?? null : null;
const selectedLayoutInteractions = useMemo<LayoutInteractionRule[]>(
() => normalizeLayoutInteractions(selectedLayout?.tree ?? null),
[selectedLayout?.tree],
);
const featureOptions = useMemo<SelectOptionItem[]>(
() =>
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<string, unknown>),
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<string, unknown>),
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<string, unknown>),
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) {
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, targetChatType.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: targetChatType.id,
lastChatTypeId: targetChatType.id,
contextLabel: targetChatType.name,
contextDescription: targetChatType.description,
notifyOffline: true,
});
}
const requestId = `client-${Date.now().toString(36)}`;
const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId);
await new Promise<void>((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: targetChatType.id,
chatTypeLabel: targetChatType.name,
chatTypeDescription: targetChatType.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: (
<div className="feature-menu-layout-page__editor-fields">
<Input.TextArea
value={draftBody}
placeholder="기능설명 본문을 입력하세요."
autoSize={false}
className="feature-menu-layout-page__textarea"
onChange={(event) => {
setDraftBody(event.target.value);
}}
/>
</div>
),
},
{
key: 'notes',
label: 'Codex 설명',
children: selectedFeature?.implementationNotes.trim() ? (
<div className="feature-menu-layout-page__notes">
<Paragraph className="feature-menu-layout-page__notes-body">{selectedFeature.implementationNotes}</Paragraph>
</div>
) : (
<div className="feature-menu-layout-page__notes feature-menu-layout-page__notes--empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={null}
className="feature-menu-layout-page__notes-empty"
/>
</div>
),
},
] satisfies Array<{ key: FeatureMenuTabKey; label: string; children: ReactNode }>;
return (
<div
className={`feature-menu-layout-page${isEditorMaximized ? ' feature-menu-layout-page--editor-maximized' : ''}`}
data-layout-id={layoutId}
>
{contextHolder}
{modalContextHolder}
<section className="feature-menu-layout-page__filters">
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"></Text>
<SelectUI
data={layoutOptions}
value={selectedLayoutId ?? undefined}
allowClear={false}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedLayoutId(nextCode ?? null);
}}
/>
</div>
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"> </Text>
<SelectUI
data={featureOptions}
value={selectedFeatureId ?? undefined}
allowClear={false}
disabled={featureOptions.length === 0}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedFeatureId(nextCode ?? null);
}}
/>
</div>
<Tooltip title="Codex 실행">
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
className="feature-menu-layout-page__run-button"
disabled={!selectedLayout}
loading={isSending}
aria-label="Codex 실행"
onClick={() => {
void handleCodexExecute();
}}
/>
</Tooltip>
</section>
<section className="feature-menu-layout-page__editor-shell">
{selectedFeature ? (
<>
<div className="feature-menu-layout-page__editor-toolbar">
<Space size={8} wrap className="feature-menu-layout-page__tab-actions">
<Tooltip title="추가">
<Button
icon={<PlusOutlined />}
disabled={!selectedLayout}
loading={isSaving && !selectedFeature}
aria-label="추가"
onClick={() => {
void handleAdd();
}}
/>
</Tooltip>
<Tooltip title={isEditorMaximized ? '입력 축소' : '입력 최대화'}>
<Button
type={isEditorMaximized ? 'primary' : 'default'}
icon={isEditorMaximized ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={isEditorMaximized ? '입력 축소' : '입력 최대화'}
onClick={() => {
setIsEditorMaximized((current) => !current);
}}
/>
</Tooltip>
<Tooltip title="저장">
<Button
type="primary"
icon={<SaveOutlined />}
disabled={!selectedFeature || !isDirty}
loading={isSaving}
aria-label="저장"
onClick={() => {
void handleSave();
}}
/>
</Tooltip>
<Tooltip title="삭제">
<Button
danger
icon={<DeleteOutlined />}
disabled={!selectedFeature}
aria-label="삭제"
onClick={handleDelete}
/>
</Tooltip>
</Space>
</div>
<Tabs
activeKey={activeTab}
className="feature-menu-layout-page__tabs"
tabPosition="top"
items={editorTabItems}
onChange={(nextKey) => {
setActiveTab(nextKey as FeatureMenuTabKey);
}}
/>
</>
) : (
<div className="feature-menu-layout-page__empty">
<Empty description="선택 가능한 기능설명 항목이 없습니다." />
</div>
)}
</section>
</div>
);
}