chore: sync local workspace changes
@@ -29,11 +29,22 @@
|
||||
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
|
||||
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
|
||||
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
|
||||
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
|
||||
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
|
||||
|
||||
구현 우선순위:
|
||||
|
||||
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
|
||||
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
|
||||
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
|
||||
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
|
||||
|
||||
금지 해석:
|
||||
|
||||
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
|
||||
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
|
||||
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
|
||||
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
|
||||
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
|
||||
|
||||
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.
|
||||
|
||||
317
src/features/layout/feature-menu/FeatureMenuLayoutPage.css
Normal file
@@ -0,0 +1,317 @@
|
||||
.feature-menu-layout-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, #f4f6f8 0%, #eef1f4 100%);
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__filters,
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d6dde5;
|
||||
border-radius: 0;
|
||||
background: #f8fafc;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #51606f;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__select.ant-select,
|
||||
.feature-menu-layout-page__select .ant-select-selector,
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__select .ant-select-selector,
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
border-color: #c8d1db;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__run-button.ant-btn {
|
||||
height: 44px;
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
padding-inline: 0;
|
||||
border-radius: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-fields {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
overflow-y: auto !important;
|
||||
resize: none;
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #16202a;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__textarea.ant-input::placeholder {
|
||||
color: #8a97a6;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #d6dde5;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-content,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
||||
margin: 0 0 8px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #d6dde5;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-tab {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tab-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tab-actions .ant-btn {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
padding-inline: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d6dde5;
|
||||
background: #fff;
|
||||
padding: 14px 16px;
|
||||
overflow: auto;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes--empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes-body.ant-typography {
|
||||
margin-bottom: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #16202a;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes-empty.ant-empty {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page--editor-maximized {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__filters {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
|
||||
position: fixed;
|
||||
inset: 16px;
|
||||
z-index: 40;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.feature-menu-layout-page__filters {
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__run-button.ant-btn {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.feature-menu-layout-page {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
padding: 4px 4px calc(2px + env(safe-area-inset-bottom, 0px));
|
||||
gap: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__filters {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
column-gap: 6px;
|
||||
row-gap: 6px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__filters,
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-shell {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
align-self: stretch;
|
||||
height: calc(100% - 24px);
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__field:first-of-type {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-toolbar {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tabs,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-content,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
|
||||
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__editor-fields {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__textarea.ant-input {
|
||||
align-self: stretch;
|
||||
height: calc(100% - 4px) !important;
|
||||
min-height: 0 !important;
|
||||
max-height: none;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__notes {
|
||||
height: calc(100% - 4px);
|
||||
max-height: none;
|
||||
padding: 7px 12px 7px;
|
||||
padding-bottom: 7px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page__tab-actions .ant-btn,
|
||||
.feature-menu-layout-page__run-button.ant-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
|
||||
inset: 6px;
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
520
src/features/layout/feature-menu/FeatureMenuLayoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/features/layout/feature-menu/featureMenu.chat.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { resolveChatWebSocketUrl } from '../../../app/main/mainChatPanel/chatUtils';
|
||||
|
||||
export function resolveLayoutConversationTitle(layoutName: string) {
|
||||
const normalized = layoutName.trim() || '이름 없는 레이아웃';
|
||||
return `${normalized} 명세`;
|
||||
}
|
||||
|
||||
export function resolveLayoutCodexRequestSocketUrl(sessionId: string) {
|
||||
const resolvedUrl = new URL(resolveChatWebSocketUrl(sessionId));
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return resolvedUrl.toString();
|
||||
}
|
||||
|
||||
if (['127.0.0.1', 'localhost', '0.0.0.0'].includes(window.location.hostname)) {
|
||||
resolvedUrl.protocol = 'wss:';
|
||||
resolvedUrl.hostname = 'test.sm-home.cloud';
|
||||
resolvedUrl.port = '';
|
||||
resolvedUrl.pathname = '/ws/chat';
|
||||
}
|
||||
|
||||
return resolvedUrl.toString();
|
||||
}
|
||||
|
||||
export function isReusableLayoutConversation(
|
||||
item: { title: string; chatTypeId: string | null; lastChatTypeId: string | null },
|
||||
expectedTitle: string,
|
||||
chatTypeId: string,
|
||||
) {
|
||||
return item.title.trim() === expectedTitle && (item.chatTypeId === chatTypeId || item.lastChatTypeId === chatTypeId);
|
||||
}
|
||||
8
src/features/layout/feature-menu/featureMenu.types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type LayoutInteractionRule = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
implementationNotes: string;
|
||||
};
|
||||
|
||||
export type FeatureMenuTabKey = 'description' | 'notes';
|
||||
72
src/features/layout/feature-menu/featureMenu.utils.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { SavedLayoutRecord } from '../../../views/play/layoutStorage';
|
||||
import type { LayoutInteractionRule } from './featureMenu.types';
|
||||
|
||||
function normalizeText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function normalizeLayoutInteractions(tree: unknown) {
|
||||
if (!tree || typeof tree !== 'object') {
|
||||
return [] as LayoutInteractionRule[];
|
||||
}
|
||||
|
||||
const candidate = tree as { interactions?: unknown };
|
||||
if (!Array.isArray(candidate.interactions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return candidate.interactions
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next = item as Record<string, unknown>;
|
||||
const id = normalizeText(next.id);
|
||||
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title: normalizeText(next.title),
|
||||
description: typeof next.description === 'string' ? next.description : '',
|
||||
implementationNotes: typeof next.implementationNotes === 'string' ? next.implementationNotes : '',
|
||||
};
|
||||
})
|
||||
.filter((item): item is LayoutInteractionRule => item !== null);
|
||||
}
|
||||
|
||||
export function buildFeatureMenuPrompt(layout: SavedLayoutRecord, rules: LayoutInteractionRule[]) {
|
||||
const normalizedRules = rules.filter((rule) => rule.title.trim() && rule.description.trim());
|
||||
|
||||
if (normalizedRules.length === 0) {
|
||||
return `${layout.name} 레이아웃에는 아직 실행할 기능설명 항목이 없습니다.`;
|
||||
}
|
||||
|
||||
const featureBody = normalizedRules
|
||||
.map((rule, index) => {
|
||||
const sections = [`${index + 1}. 기능 제목: ${rule.title.trim()}`, `설명: ${rule.description.trim()}`];
|
||||
|
||||
if (rule.implementationNotes.trim()) {
|
||||
sections.push(`관련 설명: ${rule.implementationNotes.trim()}`);
|
||||
}
|
||||
|
||||
sections.push('이 항목에 필요한 React UI, 상태 연결, API 연동만 구현하고 다른 기능은 건드리지 마세요.');
|
||||
return sections.join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return [
|
||||
`레이아웃 이름: ${layout.name}`,
|
||||
'아래 기능설명 기준으로 실제 메뉴 화면 구현을 진행해 주세요.',
|
||||
'실제 개발이 진행되면 설계문서 전체 최종본은 src/features/layout/feature-menu/resources/feature-menu-final.md에 함께 반영해 주세요.',
|
||||
'설명 문구를 화면에 그대로 노출하지 말고 동작 구현에만 사용해 주세요.',
|
||||
'기능설명 입력과 관련 설명은 같은 본문에 섞지 말고 탭으로 구분해 주세요.',
|
||||
'상시 노출 액션 버튼은 문구 없이 아이콘만 사용하고 tooltip과 aria-label로 의미를 보완해 주세요.',
|
||||
'저장된 레이아웃 구조와 pane 수는 유지하세요.',
|
||||
'요구사항:',
|
||||
featureBody,
|
||||
].join('\n');
|
||||
}
|
||||
1
src/features/layout/feature-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FeatureMenuLayoutPage } from './FeatureMenuLayoutPage';
|
||||
@@ -0,0 +1,23 @@
|
||||
# 기능설명 관리 패키지 분석 문서
|
||||
|
||||
## 요청 목표
|
||||
- 기존 `기능설명 관리` 화면에서 모바일 기준 가시성을 높이고, 기능설명 편집 흐름을 단순화한다.
|
||||
- 최종 산출물을 세션 리소스에만 남기지 않고 `feature-menu` 패키지 내부에서도 바로 추적 가능하게 정리한다.
|
||||
|
||||
## 작업 대상
|
||||
- 패키지 루트: `src/features/layout/feature-menu/`
|
||||
- 관련 저장 레이아웃 ID: `layout-1777643627048`
|
||||
- 검증 기준 도메인: `https://test.sm-home.cloud/`
|
||||
- 최종 확인 기준: `4173 preview`
|
||||
|
||||
## 확인된 기존 문제
|
||||
- 상단 description 요약이 모바일 세로 공간을 과도하게 차지했다.
|
||||
- 하단 액션과 탭 구성이 좁은 화면에서 잘리거나 입력 영역을 압박했다.
|
||||
- 기능설명 제목은 선택만 가능하고 편집 입력이 없어 수정 흐름이 한 번에 이어지지 않았다.
|
||||
- 최종 설계/검증 근거가 세션 리소스에 분산돼 패키지 단위 추적성이 약했다.
|
||||
|
||||
## 최종 판단
|
||||
- 이 화면은 신규 메뉴가 아니라 기존 `feature-menu` 패키지의 수정 작업으로 유지한다.
|
||||
- 기능설명 입력과 Codex 설명은 탭으로 분리하되, 제목 입력은 제거하고 본문 textarea 단일 편집 흐름으로 유지한다.
|
||||
- 최종 설계 문서, 구현 완료 문서, 검증 이미지까지 패키지 내부 `resources/`에서 같이 관리한다.
|
||||
- 헤더 가림 수정 이후 완료 기준 산출물은 패키지 내부 최종 경로로 이관해 세션 리소스 의존도를 줄인다.
|
||||
@@ -0,0 +1,72 @@
|
||||
# 기능설명 관리 패키지 최종 설계문서
|
||||
|
||||
## 문서 목적
|
||||
- 이 문서는 `src/features/layout/feature-menu/` 패키지의 최종 설계 기준 문서다.
|
||||
- 실제 개발이 진행된 뒤에는 세션 리소스 문서보다 이 문서를 우선 기준으로 사용한다.
|
||||
- 세션 리소스 문서는 대화 기록용 보조 산출물로만 유지하고, 최종 분석/검증 산출물은 패키지 내부 `resources/`를 기준으로 관리한다.
|
||||
|
||||
## 대상 범위
|
||||
- 메뉴명: `기능설명 관리`
|
||||
- 관련 저장 레이아웃 ID: `layout-1777643627048`
|
||||
- 패키지 루트: `src/features/layout/feature-menu/`
|
||||
- 진입 시 현재 메뉴 레이아웃 ID를 우선 선택하고, 같은 이름/ID 레코드에 바로 덮어쓴다.
|
||||
- 모바일에서는 전체 편집 레이아웃이 부모 높이를 넘지 않도록 루트와 편집 박스의 높이 계산을 `border-box` 기준으로 고정하고, 편집 셸은 남는 높이를 강제로 늘리지 않고 내용 기준 높이로 먼저 맞춘다.
|
||||
|
||||
## 패키지 구조 기준
|
||||
- 전용 화면과 로직은 `feature-menu` 패키지 내부에만 둔다.
|
||||
- 현재 패키지 구성은 `FeatureMenuLayoutPage.tsx`, `FeatureMenuLayoutPage.css`, `featureMenu.types.ts`, `featureMenu.utils.ts`, `featureMenu.chat.ts`로 분리한다.
|
||||
- 공용 app 계층으로 승격하는 작업은 다른 화면 재사용 근거가 확인될 때만 진행한다.
|
||||
|
||||
## 화면 역할
|
||||
- 이 메뉴는 일반 메모 관리가 아니라 선택한 레이아웃의 `tree.interactions`를 선택, 편집, 실행하는 관리 화면이다.
|
||||
- 선택된 기능설명의 저장 대상은 `selectedLayout.tree.interactions[].description`이다.
|
||||
- `implementationNotes`는 본문 저장 대상이 아니라 관련 설명 탭에서만 읽는 보조 메타데이터다.
|
||||
|
||||
## 화면 구성 최종본
|
||||
1. 상단 필터 영역
|
||||
- `레이아웃명` 선택
|
||||
- `기능설명 선택`
|
||||
- `Codex 실행` 아이콘 버튼
|
||||
|
||||
2. 본문 편집 영역
|
||||
- 상단 요약 description 박스는 두지 않는다.
|
||||
- `추가`, `저장`, `삭제` 액션 아이콘은 두번째 섹션 상단에 고정해 하단 잘림을 피한다.
|
||||
- 편집영역 툴바에는 문구 없는 `입력 최대화` 아이콘을 함께 둔다.
|
||||
- 탭 버튼은 본문 상단에 두고, 선택된 탭 내용이 아래로 바로 이어지게 한다.
|
||||
- 탭 구성: `기능설명 입력`, `Codex 설명`
|
||||
- `기능설명 입력` 탭은 별도 제목 입력 없이 textarea 하나만 둔다.
|
||||
- 기능설명 제목은 상단 `기능설명 선택` 드롭다운 항목명으로만 유지한다.
|
||||
- `기능설명 입력` textarea는 편집 카드 높이를 최대한 채우도록 늘린다.
|
||||
- `Codex 설명`이 비어 있을 때는 설명 문구를 따로 노출하지 않는다.
|
||||
- 완료 기준 문서와 검증 산출물은 세션 리소스가 아니라 이 패키지 내부 경로를 우선 기준으로 본다.
|
||||
|
||||
## UI 규칙
|
||||
- 기능설명 입력과 관련 설명은 같은 본문에 섞지 않고 탭으로 분리한다.
|
||||
- 상시 노출 액션 버튼은 문구 없이 아이콘만 사용한다.
|
||||
- 버튼 의미는 tooltip과 `aria-label`로 보완한다.
|
||||
- 모바일에서는 설명성 문구보다 입력 영역과 하단 액션/탭 가시성을 우선한다.
|
||||
- 모바일에서는 첫 섹션 높이를 줄여 두번째 섹션이 더 위에서 시작되도록 배치한다.
|
||||
- `textarea`는 일반 상태와 최대화 상태 모두에서 마지막 줄이 잘리지 않게 내부 스크롤을 유지한다.
|
||||
- 하단 입력 마지막 줄이 잘리지 않도록 탭 본문은 내부 스크롤과 하단 여백을 유지한다.
|
||||
- 모바일에서는 제목 input, 탭 헤더, textarea가 같은 편집 카드 안에서 보이되, 편집 카드 자체는 다시 `auto + 1fr`로 남는 높이를 채운다.
|
||||
- 모바일 편집 셸과 탭 본문은 `1fr` 채움을 유지하되, 하단 safe-area를 포함한 바깥 padding을 최소한으로 남긴다.
|
||||
- 모바일에서는 아이폰 12 Pro 실기기 기준으로 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 줄이고, 편집 셸 높이 감산은 `24px`, 입력/설명 패널 감산은 `4px` 수준으로 맞춰 wrapper와 입력 영역이 함께 더 아래까지 늘어나게 한다.
|
||||
- 모바일 `Codex 설명` 탭도 같은 높이 체계를 유지하고, 하단 padding만 줄여 wrapper 하단의 큰 빈 영역처럼 보이지 않게 한다.
|
||||
- 전체 페이지 overflow는 숨기고, 넘치는 내용은 페이지 바깥이 아니라 textarea 또는 `Codex 설명` 패널 내부 스크롤에서만 처리한다.
|
||||
|
||||
## Codex Live 실행 규칙
|
||||
- 이 메뉴에서 Codex 실행 시 현재 선택된 레이아웃과 기능설명 본문을 프롬프트 입력으로 사용한다.
|
||||
- 실제 구현 요청을 보낼 때는 이 패키지 최종 설계문서를 함께 갱신 대상으로 간주한다.
|
||||
- 후속 개발에서 설계가 바뀌면 세션 문서만 수정하지 말고 이 문서를 먼저 갱신한다.
|
||||
|
||||
## 검증 기준
|
||||
- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선한다.
|
||||
- 검증 대상 기본 도메인은 `https://test.sm-home.cloud/`다.
|
||||
- 최종 검증 산출물은 `resources/verification/` 아래에 패키지 기준으로 함께 보관한다.
|
||||
|
||||
## 패키지 내부 산출물
|
||||
- 분석 문서: `resources/feature-menu-analysis.md`
|
||||
- 개발 완료 문서: `resources/feature-menu-implementation.md`
|
||||
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-mobile-final.png`
|
||||
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-desktop-final.png`
|
||||
- `test.sm-home.cloud` 비교 검증 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`
|
||||
@@ -0,0 +1,58 @@
|
||||
# 기능설명 관리 패키지 개발 완료 문서
|
||||
|
||||
## 반영 내용
|
||||
- 상단 description 요약 영역을 제거했다.
|
||||
- 상단 필터에는 `Codex 실행` 아이콘 버튼만 유지했다.
|
||||
- 상단 필터의 두번째 선택은 `기능설명 선택`으로 유지하고, 제목은 드롭다운 항목명으로만 유지하게 바꿨다.
|
||||
- 본문은 `기능설명 입력`, `Codex 설명` 탭으로 구성했다.
|
||||
- 후속 단순화 요청에 따라 `기능설명 입력` 탭의 제목 `input`은 제거하고 textarea 하나만 남겼다.
|
||||
- `추가`, `저장`, `삭제` 아이콘 액션은 두번째 섹션 상단으로 옮겼다.
|
||||
- 편집영역 툴바에 문구 없는 `입력 최대화` 아이콘 토글을 추가했다.
|
||||
- `기능설명 입력` textarea는 편집 영역의 남는 세로 공간을 `100%` 채우도록 다시 조정했다.
|
||||
- 모바일에서는 편집 필드를 grid 행으로 재구성하고 툴바/탭/입력 패딩을 더 줄여 textarea가 부모 영역을 넘치지 않게 조정했다.
|
||||
- 이번 수정에서는 편집 셸과 탭 본문 자체를 `minmax(0, 1fr)` 기반 grid로 다시 묶고, `textarea`를 남은 높이만 채우는 방식으로 바꿨다.
|
||||
- 후속 미세조정으로 모바일 `textarea`는 `calc(100% - 52px)`까지만 차도록 다시 줄여, 하단 테두리가 화면 안에서 분명히 보이도록 맞췄다.
|
||||
- 추가 미세조정으로 모바일 wrapper 자체가 덜 눌려 보이도록 페이지/편집 셸 패딩과 탭 간격을 한 번 더 줄이고, `textarea`는 `calc(100% - 60px)`까지만 차도록 낮췄다.
|
||||
- 이번 후속 수정에서는 textarea 자체보다 부모 wrapper가 길게 늘어난 점을 기준으로, 모바일에서 루트/편집 셸/탭/입력 필드의 `1fr` 확장을 풀고 내용 기준 높이로 다시 줄였다.
|
||||
- 이번 최신 수정에서는 너무 줄어든 모바일 높이를 다시 되돌려, 루트/편집 셸/탭/입력 필드를 `auto + 1fr` 채움 구조로 복구하고 바깥 패딩, 탭 간격, `notes` 하단 padding만 더 줄였다.
|
||||
- 이번 최신 미세조정에서는 부모 카드가 덜 잘리고 textarea는 조금 더 다시 커지도록, 모바일 편집 셸 높이는 `30px` 안쪽으로만 줄이고 제목행/툴바/탭 간격을 더 압축한 뒤 `textarea`와 `Codex 설명` 패널 높이는 각각 `30px` 안쪽 기준으로 다시 맞췄다.
|
||||
- 이번 최신 재조정에서는 부모 카드 하단선을 더 확실히 보이게 하려고 모바일 편집 셸 높이 감산을 `42px`로 늘리고, 대신 `textarea`와 `Codex 설명` 패널 높이 감산은 `24px`로만 유지해 입력 높이 손실을 최소화했다.
|
||||
- 이번 최신 재조정에서는 wrapper 하단선이 실제로 보이도록 모바일 편집 셸 감산을 `56px`로 더 키우고, 대신 필터/툴바/탭/제목행 고정 높이를 더 줄인 뒤 `textarea`와 `Codex 설명` 패널 감산은 `20px`로만 유지했다.
|
||||
- 이번 최신 후속 조정에서는 아이폰 12 Pro 실기기 캡처 기준으로 하단 safe-area 여유와 편집 셸 감산이 과하다고 보고, 모바일 페이지 하단 padding을 `calc(4px + env(safe-area-inset-bottom))`로 줄이고 편집 셸 감산도 `44px`로 낮췄다. 동시에 입력/설명 패널 감산은 `12px`로 완화해 두번째 카드가 더 아래까지 늘어나도록 다시 키웠다.
|
||||
- 이번 추가 보정은 원복 요청에 따라 되돌렸고, 모바일 기준은 다시 아이폰 12 Pro 실기기 캡처를 따라 페이지 하단 padding `calc(4px + env(safe-area-inset-bottom))`, 편집 셸 감산 `44px`, 입력/설명 패널 감산 `12px` 조합으로 복구했다.
|
||||
- 이번 최신 조정에서는 모바일 하단 여백을 더 줄여달라는 요청에 맞춰 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 낮추고, 편집 셸 감산을 `24px`, 입력/설명 패널 감산을 `4px`로 완화해 wrapper와 textarea를 함께 다시 키웠다.
|
||||
- `play-saved` 모바일 레이아웃도 헤더 높이를 `52px` 기준으로 맞춰 상단 가림을 제거했다.
|
||||
- 진입 직후에는 현재 메뉴 레이아웃 ID를 먼저 선택하도록 바꿔, 이전 다른 레이아웃이 기본값으로 먼저 보이지 않게 맞췄다.
|
||||
- 모바일 하단 여백처럼 보이던 현상은 페이지 루트가 `height: 100%` 상태에서 `padding`까지 바깥으로 더해지던 문제여서, 루트/편집 셸/탭 영역에 `box-sizing: border-box`를 맞춰 전체 레이아웃 overflow를 막았다.
|
||||
- 빈 `Codex 설명` 탭에서는 설명성 문구를 제거했다.
|
||||
- Codex Live 실패 응답 복구 로직은 `src/app/main/mainChatPanel/chatUtils.ts`에서 별도로 보완됐다.
|
||||
- 최종 완료 기준 문서와 검증 이미지는 `feature-menu` 패키지 내부 `resources/` 최종 경로로 이관했다.
|
||||
|
||||
## 산출물 위치
|
||||
- 최종 설계 문서: `resources/feature-menu-final.md`
|
||||
- 최종 분석 문서: `resources/feature-menu-analysis.md`
|
||||
- 최종 preview 모바일: `resources/verification/feature-menu-preview-mobile-final.png`
|
||||
- 최종 preview 데스크톱: `resources/verification/feature-menu-preview-desktop-final.png`
|
||||
- `test.sm-home.cloud` 재현 확인 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`
|
||||
|
||||
## 검증 결과
|
||||
- `test.sm-home.cloud` 모바일 재현에서는 textarea 하단이 편집 쉘 아래로 약 `91px` 넘치는 기존 상태를 다시 확인했다.
|
||||
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `tabs.bottom = 651`, `textarea.bottom = 651`로 맞춰졌고, 마지막 줄까지 내부 스크롤로 확인됐다.
|
||||
- 최종 반영 결과는 `4173 preview` 기준 모바일/데스크톱 캡처로 보관했다.
|
||||
- 같은 날짜 후속 개선으로 루트/편집 셸/탭 본문을 다시 `auto + minmax(0, 1fr)` 구조로 복구하고, textarea를 `autoSize` 대신 탭 본문 남는 높이 전체를 채우는 방식으로 조정했다.
|
||||
- `2026-05-03` `4173 preview` 최종 재검증에서는 모바일 기준 `bodyScrollHeight = 844`, `root.bottom = 844`, `textarea.bottom = 831`, `textarea.height = 487.17`로 남는 공간을 채우면서도 페이지 바깥 overflow는 발생하지 않았다.
|
||||
- 같은 날짜 추가 미세조정 뒤 `4173 preview` 모바일 재검증에서는 `bodyScrollHeight = 664`, `root.bottom = 664`, `textarea.bottom = 599`, `textarea.height = 255.17`로 textarea 하단 여유를 더 확보했고, 페이지 바깥 overflow는 없었다.
|
||||
- 같은 날짜 최신 재검증에서는 `test.sm-home.cloud`가 여전히 기존 번들로 `textarea.bottom = 747.25`인 반면, `4173 preview` 수정본은 `shell.bottom = 660`, `tabs.bottom = 655`, `textarea.bottom = 595`, `textarea.height = 272.17`로 wrapper 외곽과 하단 입력 영역이 더 안쪽에 들어오도록 정리됐다.
|
||||
- 이번 후속 수정 검증은 동일한 모바일 문제 이미지 기준으로 부모 wrapper 하단의 과한 빈 영역이 사라졌는지 확인하는 것이 목적이다.
|
||||
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `shell.bottom = 547.83`, `tabs.bottom = 542.83`, `textarea.bottom = 542.83`, `textarea.height = 220`으로 wrapper 자체가 이전보다 약 `292px` 짧아졌다.
|
||||
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 840`, `tabs.bottom = 836`, `textarea.bottom = 836`, `textarea.height = 521.17`로 다시 하단까지 거의 채우면서도 카드 외곽 하단 선이 캡처 안에서 유지됐다.
|
||||
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 806`, `tabs.bottom = 801`, `textarea.bottom = 771`, `textarea.height = 455.17`로 부모 카드 하단이 다시 화면 안에 들어오면서 textarea 높이도 이전 `v30`보다 `187px` 커졌다.
|
||||
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 794`, `tabs.bottom = 789`, `textarea.bottom = 765`, `textarea.height = 449.17`로 부모 카드 하단선을 `v31`보다 `12px` 더 위로 올리면서도 textarea 높이는 `6px`만 줄였다.
|
||||
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 778`, `tabs.bottom = 774`, `textarea.bottom = 754`, `textarea.height = 447.17`로 wrapper 하단선이 캡처 안에서 분명히 보이면서도 textarea 높이는 직전 대비 `2px`만 줄었다.
|
||||
- 같은 날짜 원복 기준은 아이폰 12 Pro viewport에서 `test.sm-home.cloud`가 `shell.bottom = 598`, `notes.bottom = 574`인 반면, `4173 preview` 수정본은 `shell.bottom = 616`, `notes.bottom = 600`으로 두번째 카드가 실제로 더 아래까지 내려오던 시점의 값으로 맞춘다.
|
||||
- 이번 최신 조정 검증은 모바일 wrapper와 textarea를 동시에 더 늘리는 것이 목적이며, `4173 preview` 기준으로 다시 확인한다.
|
||||
- 같은 날짜 최신 `v36` 검증에서는 아이폰 12 Pro viewport 기준 `4173 preview`가 `shell.bottom = 586`, `tabs.bottom = 582`, `textarea.bottom = 578`, `textarea.height = 323.17`로 확인됐고, 같은 조건 `test.sm-home.cloud`는 `shell.bottom = 564`, `tabs.bottom = 560`, `textarea.bottom = 548`, `textarea.height = 293.17`이었다.
|
||||
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1088`, `tabs.bottom = 1077`, `textarea.bottom = 1077`로 기존 데스크톱 채움 구조는 유지됐다.
|
||||
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1188`, `tabs.bottom = 1177`, `textarea.bottom = 1177`로 데스크톱 채움 구조가 그대로 유지됐다.
|
||||
- 같은 날짜 후속 수정으로 `기능설명 입력` 탭은 `title input` 없이 textarea 하나만 남았고, `4173 preview` 기준 `titleInputCount = 0`, `textareaCount = 1`로 확인했다.
|
||||
- 이번 이관 작업은 패키지 내부 문서/리소스 구조 정리이며 동작 로직 추가 변경은 포함하지 않는다.
|
||||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -170,6 +170,7 @@ export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
|
||||
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
|
||||
currentPrice: null,
|
||||
changeRate: null,
|
||||
volumeRate5d: null,
|
||||
quotedAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
@@ -546,39 +547,39 @@ export function StockAlertGridPane() {
|
||||
field: 'stockName',
|
||||
headerName: '종목명',
|
||||
editable: false,
|
||||
minWidth: 170,
|
||||
flex: 1.3,
|
||||
minWidth: 150,
|
||||
flex: 1.05,
|
||||
},
|
||||
{
|
||||
field: 'changeRate',
|
||||
headerName: '등락률',
|
||||
editable: false,
|
||||
minWidth: 130,
|
||||
flex: 0.9,
|
||||
minWidth: 104,
|
||||
flex: 0.72,
|
||||
cellRenderer: ChangeRateCellRenderer,
|
||||
},
|
||||
{
|
||||
field: 'currentPrice',
|
||||
headerName: '현재가',
|
||||
editable: false,
|
||||
minWidth: 120,
|
||||
flex: 0.9,
|
||||
minWidth: 110,
|
||||
flex: 0.8,
|
||||
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
|
||||
},
|
||||
{
|
||||
field: 'quotedAt',
|
||||
headerName: '기준일시',
|
||||
editable: false,
|
||||
minWidth: 190,
|
||||
flex: 1.2,
|
||||
minWidth: 168,
|
||||
flex: 1,
|
||||
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
|
||||
},
|
||||
{
|
||||
field: 'alertTypes',
|
||||
headerName: '알림유형',
|
||||
editable: false,
|
||||
minWidth: 220,
|
||||
flex: 1.1,
|
||||
minWidth: 180,
|
||||
flex: 0.95,
|
||||
cellRenderer: AlertTypeCellEditorRenderer,
|
||||
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
|
||||
isOpen: params.data?.id === activeAlertTypeEditorRowId,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type StockAlertItem = {
|
||||
alertTypeLabels: string[];
|
||||
currentPrice: number | null;
|
||||
changeRate: number | null;
|
||||
volumeRate5d: number | null;
|
||||
quotedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -26,6 +27,7 @@ export type StockAlertDraftRow = {
|
||||
alertTypeLabels: string[];
|
||||
currentPrice: number | null;
|
||||
changeRate: number | null;
|
||||
volumeRate5d: number | null;
|
||||
quotedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
@@ -61,6 +63,7 @@ function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
|
||||
alertTypeLabels: item.alertTypeLabels,
|
||||
currentPrice: item.currentPrice,
|
||||
changeRate: item.changeRate,
|
||||
volumeRate5d: item.volumeRate5d,
|
||||
quotedAt: item.quotedAt,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt,
|
||||
@@ -81,6 +84,7 @@ export function createEmptyStockAlertRow(): StockAlertDraftRow {
|
||||
alertTypeLabels: ['현재가'],
|
||||
currentPrice: null,
|
||||
changeRate: null,
|
||||
volumeRate5d: null,
|
||||
quotedAt: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
|
||||