feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -0,0 +1,284 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationContext,
type AutomationContextRecord,
upsertAutomationContext,
useAutomationContextRegistry,
} from './automationContextAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type AutomationContextFormValue = {
id?: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
};
const EMPTY_FORM_VALUE: AutomationContextFormValue = {
title: '',
content: '',
enabled: true,
defaultSelected: true,
};
function toFormValue(context: AutomationContextRecord | null): AutomationContextFormValue {
if (!context) {
return EMPTY_FORM_VALUE;
}
return {
id: context.id,
title: context.title,
content: context.content,
enabled: context.enabled,
defaultSelected: context.defaultSelected,
};
}
export function AutomationContextManagementPage() {
const { hasAccess } = useTokenAccess();
const { automationContexts, setAutomationContexts, isLoading, errorMessage } = useAutomationContextRegistry();
const [selectedAutomationContextId, setSelectedAutomationContextId] = useState<string | null>(automationContexts[0]?.id ?? null);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationContextFormValue>();
const selectedAutomationContext = useMemo(
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
[automationContexts, selectedAutomationContextId],
);
useEffect(() => {
if (selectedAutomationContextId && automationContexts.some((item) => item.id === selectedAutomationContextId)) {
return;
}
setSelectedAutomationContextId(automationContexts[0]?.id ?? null);
}, [automationContexts, selectedAutomationContextId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
}, [detailMode, form, isCreating, selectedAutomationContext]);
const openCreateForm = () => {
setIsCreating(true);
setSelectedAutomationContextId(null);
setDetailMode('detail');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (automationContextId: string) => {
setIsCreating(false);
setSelectedAutomationContextId(automationContextId);
setDetailMode('detail');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
};
const handleDelete = async () => {
if (!selectedAutomationContext) {
return;
}
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) {
return;
}
const nextAutomationContexts = deleteAutomationContext(automationContexts, selectedAutomationContext.id);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
setSelectedAutomationContextId(savedAutomationContexts[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 삭제에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
if (!hasAccess) {
return (
<Card title="Context 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
/>
</Card>
);
}
return (
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
{detailMode === 'list' ? (
<Card
title="Context 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
Context
</Button>
}
>
<div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> Context</Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationContexts.length}`}</Text>
</div>
{automationContexts.length > 0 ? (
<List
dataSource={automationContexts}
renderItem={(item) => (
<List.Item
className={
item.id === selectedAutomationContextId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item'
}
onClick={() => openDetail(item.id)}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.title}</Text>
<Text type="secondary">{item.id}</Text>
</Space>
<Space size={[8, 8]} wrap style={{ marginTop: 6 }}>
<Text type={item.enabled ? undefined : 'secondary'}>{item.enabled ? '사용' : '중지'}</Text>
<Text type={item.defaultSelected ? undefined : 'secondary'}>
{item.defaultSelected ? '기본 선택' : '기본 해제'}
</Text>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</div>
</div>
</List.Item>
)}
/>
) : (
<Empty description="등록된 Context가 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? 'Context 등록' : 'Context 상세'}
className="chat-type-management-page__card"
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
{!isCreating && selectedAutomationContext ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
}
>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<Form
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
const nextAutomationContexts = upsertAutomationContext(automationContexts, values);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationContexts = await setAutomationContexts(nextAutomationContexts);
const savedAutomationContext = savedAutomationContexts.find(
(item) => item.id === values.id || item.title === values.title,
);
setIsCreating(false);
setSelectedAutomationContextId(savedAutomationContext?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 Context 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<Form.Item label="제목" name="title" rules={[{ required: true, message: '제목을 입력하세요.' }]}>
<Input placeholder="예: 기본 처리" />
</Form.Item>
<Space wrap>
<Form.Item label="사용" name="enabled" valuePropName="checked">
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
<Form.Item label="기본 선택" name="defaultSelected" valuePropName="checked">
<Switch checkedChildren="기본" unCheckedChildren="해제" />
</Form.Item>
</Space>
<Form.Item label="Context 본문" name="content">
<Input.TextArea
autoSize={{ minRows: 10, maxRows: 18 }}
placeholder={'## 처리 기준\n- 이 Context에서 적용할 규칙을 Markdown으로 정리하세요.'}
/>
</Form.Item>
</Form>
</Card>
)}
</div>
);
}

View File

@@ -124,6 +124,24 @@ export function ChatTypeManagementPage() {
};
}, []);
useEffect(() => {
if (typeof document === 'undefined' || !isMobileViewport) {
return;
}
const { body, documentElement } = document;
const previousBodyOverflow = body.style.overflow;
const previousHtmlOverflow = documentElement.style.overflow;
body.style.overflow = 'hidden';
documentElement.style.overflow = 'hidden';
return () => {
body.style.overflow = previousBodyOverflow;
documentElement.style.overflow = previousHtmlOverflow;
};
}, [isMobileViewport]);
const openCreateForm = () => {
setIsCreating(true);
setSelectedChatTypeId(null);
@@ -151,7 +169,7 @@ export function ChatTypeManagementPage() {
return;
}
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) {
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) {
return;
}
@@ -197,13 +215,13 @@ export function ChatTypeManagementPage() {
/>
</Tooltip>
{!isCreating && selectedChatType ? (
<Tooltip title="비활성화">
<Tooltip title="삭제">
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="비활성화"
aria-label="삭제"
onClick={() => void handleDelete()}
/>
</Tooltip>

View File

@@ -28,6 +28,30 @@
overflow: hidden;
}
.app-chat-panel--tablet-app.ant-card,
.app-chat-panel--tablet-app .ant-card-body,
.app-chat-panel--tablet-app .app-chat-panel__stack,
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list {
width: 100%;
min-width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.app-chat-panel--tablet-app .app-chat-panel__stack--chat,
.app-chat-panel--tablet-app .app-chat-panel__conversation-shell,
.app-chat-panel--tablet-app .app-chat-panel__conversation-main,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view,
.app-chat-panel--tablet-app .app-chat-panel__conversation-view-inner {
flex-basis: 100%;
}
.app-chat-panel__preview-modal.ant-modal {
z-index: 1400;
max-width: 100vw;
@@ -663,6 +687,12 @@
margin: auto;
}
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty .ant-empty,
.app-chat-panel--tablet-app .app-chat-panel__conversation-empty-list .ant-empty {
width: 100%;
max-width: 100%;
}
.app-chat-preview-card--activity {
min-width: 0;
width: 100%;
@@ -1141,12 +1171,16 @@
}
}
@media (min-width: 768px) and (max-width: 1180px) {
@media (min-width: 768px) and (max-width: 1366px) {
.app-chat-panel .app-chat-panel__title-copy .ant-typography,
.app-chat-panel .app-chat-panel__conversation-header .ant-typography {
font-size: 15px;
}
.app-chat-panel .app-chat-message__header .ant-typography {
font-size: 16px;
}
.app-chat-panel .app-chat-panel__conversation-section-title,
.app-chat-panel .app-chat-panel__conversation-section-count,
.app-chat-panel .app-chat-panel__conversation-item-time,
@@ -1167,7 +1201,7 @@
.app-chat-panel .app-chat-panel__resource-strip-filter,
.app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography,
.app-chat-panel .app-chat-panel__busy-overlay span {
font-size: 12px;
font-size: 14px;
}
.app-chat-panel .app-chat-panel__conversation-item-title,
@@ -1180,6 +1214,10 @@
font-size: 14px;
}
.app-chat-panel .app-chat-preview-card__ranked-link-anchor {
font-size: 13px;
}
.app-chat-panel .app-chat-panel__conversation-item-preview,
.app-chat-panel .app-chat-message__header-meta,
.app-chat-panel .app-chat-message__header-meta strong,
@@ -1187,11 +1225,12 @@
.app-chat-panel .app-chat-panel__composer-queue-text,
.app-chat-panel .app-chat-panel__composer-queue-more,
.app-chat-panel .app-chat-panel__preview-modal-close-label {
font-size: 13px;
font-size: 15px;
}
.app-chat-panel .app-chat-message__body {
font-size: 18px;
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 20px !important;
line-height: 1.6;
}
@@ -1722,6 +1761,37 @@
color: #475569;
}
.app-chat-preview-card--ranked-link {
gap: 0;
}
.app-chat-preview-card__glyph--ranked-link {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.72);
}
.app-chat-preview-card__body--ranked-link {
padding: 10px;
}
.app-chat-preview-card__ranked-link-anchor {
display: block;
color: #1d4ed8;
font-size: 12px;
line-height: 1.5;
word-break: break-all;
text-decoration: none;
}
.app-chat-preview-card__ranked-link-anchor:hover {
text-decoration: underline;
}
.app-chat-preview-card__open-link.ant-btn {
height: 26px;
padding-inline: 8px;
}
@media (max-width: 1180px) {
.app-chat-panel {
height: 100%;
@@ -2041,7 +2111,7 @@
flex: none;
width: 100%;
min-width: 0;
min-height: 0;
min-height: clamp(112px, 18dvh, 160px);
}
.app-chat-panel__composer-queue {
@@ -2149,15 +2219,15 @@
width: 100%;
font-size: 13px;
line-height: 1.4;
height: clamp(64px, 10dvh, 92px);
min-height: clamp(64px, 10dvh, 92px);
height: clamp(112px, 18dvh, 160px);
min-height: clamp(112px, 18dvh, 160px);
padding: 10px 52px 8px 14px;
box-sizing: border-box;
resize: none;
}
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 76px;
padding-top: 96px;
}
.app-chat-panel__composer-topline,
@@ -2188,6 +2258,24 @@
gap: 6px;
}
.app-chat-panel__composer-contextless-toggle.ant-btn {
color: #475569;
}
.app-chat-panel__composer-contextless-toggle--active.ant-btn {
border-color: #0f766e;
background: linear-gradient(135deg, #0f766e, #0f766e);
color: #f8fafc;
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.24);
}
.app-chat-panel__composer-contextless-toggle--active.ant-btn:hover,
.app-chat-panel__composer-contextless-toggle--active.ant-btn:focus-visible {
border-color: #0f766e;
background: linear-gradient(135deg, #0f766e, #115e59);
color: #f8fafc;
}
.app-chat-panel__composer-utility-buttons {
display: inline-flex;
gap: 6px;
@@ -2901,14 +2989,18 @@
}
.app-chat-panel__composer textarea.ant-input {
height: clamp(56px, 8.5dvh, 72px);
min-height: clamp(56px, 8.5dvh, 72px);
height: clamp(104px, 16dvh, 136px);
min-height: clamp(104px, 16dvh, 136px);
padding-top: 8px;
padding-bottom: 8px;
}
.app-chat-panel__composer-input-shell {
min-height: clamp(104px, 16dvh, 136px);
}
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 64px;
padding-top: 88px;
}
.app-chat-panel__resource-strip-list {

View File

@@ -20,7 +20,15 @@ import {
import { Alert, Button, Card, Empty, Input, Modal, Radio, Space, Tag, Typography, message } from 'antd';
import type { InputRef } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAppStore } from '../../store';
import { useAppConfig } from './appConfig';
@@ -34,6 +42,7 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { shouldSkipForegroundResyncAfterExternalLink } from './mainChatPanel/linkNavigation';
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
@@ -66,11 +75,13 @@ import type {
ChatViewContext,
MainChatPanelProps,
} from './mainChatPanel/types';
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
import { buildChatPath } from './routes';
import './MainChatPanel.css';
import './MainChatPanel.hotfix.css';
const { Text } = Typography;
const ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS = 5000;
type ChatTypeOption = {
value: string;
@@ -107,6 +118,21 @@ type PendingContextConfirm = {
omittedContextCount: number;
};
type ImportedCodexDraftRequest = {
text: string;
autoSend: boolean;
sendMode: 'queue' | 'direct';
};
type PendingFreshConversationSendRequest = {
targetSessionId: string;
text: string;
sendMode: 'queue' | 'direct';
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
};
const CHAT_MAX_RETRY_ATTEMPTS = 5;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_RESTART_REQUIRED_PATTERNS = [
@@ -950,6 +976,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [previewFindQuery, setPreviewFindQuery] = useState('');
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
const [queuedImportedDraft, setQueuedImportedDraft] = useState('');
const [pendingImportedDraftRequest, setPendingImportedDraftRequest] = useState<ImportedCodexDraftRequest | null>(null);
const [pendingFreshConversationSendRequest, setPendingFreshConversationSendRequest] =
useState<PendingFreshConversationSendRequest | null>(null);
const [isSendWithoutContextEnabled, setIsSendWithoutContextEnabled] = useState(false);
const [messageApi, messageContextHolder] = message.useMessage();
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
@@ -959,6 +990,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
const previewSearchMatchIndexRef = useRef(-1);
const previewSearchKeyRef = useRef('');
const activeConversationResyncPromiseRef = useRef<Promise<void> | null>(null);
const previousPreviewModalOpenRef = useRef(false);
const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false);
const titleClusterRef = useRef<HTMLDivElement | null>(null);
const copyFeedbackTimerRef = useRef<number | null>(null);
@@ -977,6 +1010,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const notifiedRestartRequirementKeysRef = useRef<string[]>([]);
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
const requestItems = Array.isArray(requestItemsState) ? requestItemsState : [];
const isCreatingImportedDraftConversationRef = useRef(false);
const setRequestItems = useCallback((next: SetStateAction<ChatConversationRequest[]>) => {
setRequestItemsState((previous) => {
const safePrevious = Array.isArray(previous) ? previous : [];
@@ -989,6 +1023,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
});
}, []);
useEffect(() => {
if (location.pathname !== buildChatPath('live')) {
return;
}
const pendingDraft = consumeCodexLiveDraft();
if (!pendingDraft) {
return;
}
setActiveView('chat');
setQueuedImportedDraft(pendingDraft.text);
setPendingImportedDraftRequest(
pendingDraft.autoSend
? {
text: pendingDraft.text,
autoSend: true,
sendMode: pendingDraft.sendMode === 'direct' ? 'direct' : 'queue',
}
: null,
);
messageApi.success(pendingDraft.autoSend ? '레이아웃 명세를 Codex Live로 전송합니다.' : '레이아웃 명세를 Codex Live 입력창에 채웠습니다.');
}, [location.pathname, messageApi]);
useEffect(() => {
if (!queuedImportedDraft.trim()) {
return;
}
setDraft((previous) => (previous.trim() ? previous : queuedImportedDraft));
composerRef.current?.focus({ cursor: 'end' });
setQueuedImportedDraft('');
}, [activeSessionId, queuedImportedDraft]);
const {
conversationItems,
setConversationItems,
@@ -1100,7 +1168,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
return null;
}
return sessionId;
};
const openCreateConversationModal = () => {
if (availableChatTypes.length === 0) {
@@ -1239,6 +1310,24 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
},
[activeSessionId, syncConversationDetailIntoState],
);
const resyncActiveConversationDetail = useCallback(async () => {
const normalizedSessionId = activeSessionId.trim();
if (!normalizedSessionId) {
return;
}
if (activeConversationResyncPromiseRef.current) {
return activeConversationResyncPromiseRef.current;
}
const requestPromise = syncConversationFromServer(normalizedSessionId).finally(() => {
activeConversationResyncPromiseRef.current = null;
});
activeConversationResyncPromiseRef.current = requestPromise;
return requestPromise;
}, [activeSessionId, syncConversationFromServer]);
const resyncConversationEntryState = useCallback(() => {
const now = Date.now();
@@ -1250,9 +1339,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
void reloadConversationItems();
if (activeSessionId.trim()) {
void syncConversationFromServer(activeSessionId);
void resyncActiveConversationDetail();
}
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]);
}, [activeSessionId, reloadConversationItems, resyncActiveConversationDetail]);
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
const sessionId = eventSessionId.trim() || activeSessionId;
@@ -1662,6 +1751,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
mapSystemStatusMessage,
isActivityLogMessage,
});
useEffect(() => {
if (activeView !== 'chat' || !activeSessionId.trim()) {
return;
}
if (typeof window === 'undefined') {
return;
}
const runSilentResync = () => {
if (document.visibilityState !== 'visible') {
return;
}
void resyncActiveConversationDetail();
};
runSilentResync();
const intervalId = window.setInterval(runSilentResync, ACTIVE_CONVERSATION_DETAIL_POLL_INTERVAL_MS);
return () => {
window.clearInterval(intervalId);
};
}, [activeSessionId, activeView, resyncActiveConversationDetail]);
const { loadOlderMessages } = useConversationRoomController({
activeSessionId,
oldestLoadedMessageId,
@@ -1782,6 +1896,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewError,
previewText,
setActivePreviewId,
setActivePreviewOverride,
setIsPreviewModalOpen,
} = useConversationViewController({
activeSessionId,
@@ -1799,11 +1914,31 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setMessages,
});
const openPreviewModal = useCallback(
(previewId: string) => {
setActivePreviewId(previewId);
(
preview:
| string
| {
id: string;
label: string;
url: string;
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
source?: 'message' | 'context';
},
) => {
if (typeof preview === 'string') {
setActivePreviewOverride(null);
setActivePreviewId(preview);
} else {
setActivePreviewOverride({
...preview,
source: preview.source ?? 'message',
});
setActivePreviewId(null);
}
setIsPreviewModalOpen(true);
},
[setActivePreviewId, setIsPreviewModalOpen],
[setActivePreviewId, setActivePreviewOverride, setIsPreviewModalOpen],
);
const handleCopyActivePreview = useCallback(async () => {
if (!activePreview) {
@@ -1884,6 +2019,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
useEffect(() => {
if (!previousPreviewModalOpenRef.current && isPreviewModalOpen) {
previousPreviewModalOpenRef.current = true;
return;
}
if (previousPreviewModalOpenRef.current && !isPreviewModalOpen) {
previousPreviewModalOpenRef.current = false;
void reloadConversationItems();
void resyncActiveConversationDetail();
}
}, [isPreviewModalOpen, reloadConversationItems, resyncActiveConversationDetail]);
useEffect(() => {
resetActivePreviewSearchState();
clearActivePreviewSearchSelection();
@@ -2755,9 +2903,15 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
useEffect(() => {
const handleFocus = () => {
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState();
};
const handlePageShow = () => {
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState();
};
const handleVisibilityChange = () => {
@@ -2765,6 +2919,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
return;
}
if (connectionState === 'connected' && shouldSkipForegroundResyncAfterExternalLink()) {
return;
}
resyncConversationEntryState();
};
@@ -2777,7 +2935,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [resyncConversationEntryState]);
}, [connectionState, resyncConversationEntryState]);
useEffect(() => {
if (connectionState !== 'disconnected') {
@@ -2946,6 +3104,172 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
scrollViewportToBottom,
});
useEffect(() => {
if (!pendingFreshConversationSendRequest) {
return;
}
if (pendingContextConfirm) {
return;
}
if (activeSessionId !== pendingFreshConversationSendRequest.targetSessionId) {
return;
}
executeSendMessage({
mode: pendingFreshConversationSendRequest.sendMode,
text: pendingFreshConversationSendRequest.text,
chatTypeId: pendingFreshConversationSendRequest.chatTypeId,
chatTypeLabel: pendingFreshConversationSendRequest.chatTypeLabel,
chatTypeDescription: pendingFreshConversationSendRequest.chatTypeDescription,
includedContextCount: 0,
omittedContextCount: 0,
});
setPendingFreshConversationSendRequest(null);
}, [activeSessionId, executeSendMessage, pendingContextConfirm, pendingFreshConversationSendRequest]);
useEffect(() => {
if (!pendingImportedDraftRequest?.autoSend) {
return;
}
if (pendingContextConfirm) {
return;
}
const requestedSessionIdValue = requestedSessionId?.trim() ?? '';
if (requestedSessionIdValue && activeSessionId !== requestedSessionIdValue) {
return;
}
if (!activeSessionId.trim()) {
if (requestedSessionIdValue || isCreatingImportedDraftConversationRef.current) {
return;
}
const importChatType = selectedChatType ?? availableChatTypes[0] ?? null;
if (!importChatType) {
return;
}
isCreatingImportedDraftConversationRef.current = true;
setSelectedChatTypeId(importChatType.id);
void handleCreateConversation(importChatType).finally(() => {
isCreatingImportedDraftConversationRef.current = false;
});
return;
}
if (!effectiveChatType) {
return;
}
setPendingImportedDraftRequest(null);
setDraft('');
executeSendMessage({
mode: pendingImportedDraftRequest.sendMode,
text: pendingImportedDraftRequest.text,
chatTypeId: effectiveChatType.id,
chatTypeLabel: effectiveChatType.name,
chatTypeDescription: effectiveChatType.description,
includedContextCount: 0,
omittedContextCount: 0,
});
}, [
activeSessionId,
availableChatTypes,
effectiveChatType,
executeSendMessage,
handleCreateConversation,
pendingContextConfirm,
pendingImportedDraftRequest,
requestedSessionId,
selectedChatType,
setDraft,
setSelectedChatTypeId,
]);
const handleSendWithoutPreviousContext = useCallback(
async (mode: 'queue' | 'direct') => {
if (isComposerAttachmentUploading) {
return;
}
const nextConversationChatType =
effectiveChatType ??
(selectedChatType && isSelectedChatTypeAllowed
? {
id: selectedChatType.id,
name: selectedChatType.name,
description: selectedChatType.description,
}
: (availableChatTypes[0] ?? null));
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
if (!trimmed) {
return;
}
if (!nextConversationChatType) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
]);
return;
}
const createdSessionId = await handleCreateConversation(nextConversationChatType);
if (!createdSessionId) {
return;
}
setPendingFreshConversationSendRequest({
targetSessionId: createdSessionId,
text: trimmed,
sendMode: mode,
chatTypeId: nextConversationChatType.id,
chatTypeLabel: nextConversationChatType.name,
chatTypeDescription: nextConversationChatType.description,
});
setDraft('');
setComposerAttachments([]);
},
[
availableChatTypes,
buildOutgoingMessageText,
composerAttachments,
createLocalMessage,
draft,
effectiveChatType,
handleCreateConversation,
isComposerAttachmentUploading,
isSelectedChatTypeAllowed,
selectedChatType,
setMessages,
],
);
const handleComposerSend = useCallback(() => {
if (isSendWithoutContextEnabled) {
void handleSendWithoutPreviousContext('queue');
return;
}
handleSend();
}, [handleSend, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
const handleComposerSendImmediate = useCallback(() => {
if (isSendWithoutContextEnabled) {
void handleSendWithoutPreviousContext('direct');
return;
}
handleSendImmediate();
}, [handleSendImmediate, handleSendWithoutPreviousContext, isSendWithoutContextEnabled]);
const handleCopyMessage = async (message: ChatMessage) => {
await copyText(message.text);
setCopiedMessageId(message.id);
@@ -3285,8 +3609,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setSelectedChatTypeId(nextChatTypeId);
}}
onSend={handleSend}
onSendImmediate={handleSendImmediate}
onSend={handleComposerSend}
onSendImmediate={handleComposerSendImmediate}
isSendWithoutContextEnabled={isSendWithoutContextEnabled}
onToggleSendWithoutContext={() => {
setIsSendWithoutContextEnabled((current) => !current);
}}
onClearDraft={() => {
setDraft('');
}}

View File

@@ -11,6 +11,7 @@ import { useSearchLayer } from '../../layer';
import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
@@ -174,6 +175,10 @@ export function MainContent({
return <AutomationTypeManagementPage />;
}
if (selectionId === 'page:plans:automation-context') {
return <AutomationContextManagementPage />;
}
const planStatus = getPlanStatusFromWindowSelection(selectionId);
if (planStatus) {

View File

@@ -578,6 +578,12 @@
overflow-y: auto;
}
html:has(.chat-type-management-page),
body:has(.chat-type-management-page),
#root:has(.chat-type-management-page) {
overflow: hidden;
}
.app-shell,
.app-main-content.ant-layout-content,
.app-main-panel,
@@ -602,6 +608,16 @@
overflow: hidden;
}
.app-shell:has(.chat-type-management-page),
.app-shell:has(.chat-type-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-type-management-page),
.app-main-panel:has(.chat-type-management-page),
.app-main-layout:has(.chat-type-management-page) {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved),
.app-shell:has(.app-main-panel--play-saved) > .ant-layout,
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),

View File

@@ -0,0 +1,361 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
export type AutomationContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationContextInput = {
id?: string;
title: string;
content?: string;
enabled?: boolean;
defaultSelected?: boolean;
};
const AUTOMATION_CONTEXTS_API_PATH = '/automation-contexts';
const AUTOMATION_CONTEXT_SYNC_EVENT = 'work-app:automation-contexts-changed';
const AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS = 8000;
export const DEFAULT_AUTOMATION_CONTEXTS: AutomationContextRecord[] = [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: false,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 Context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function compareContextUpdatedAt(left: AutomationContextRecord, right: AutomationContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationContextRecord>): AutomationContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
title: title || 'Context',
content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationContextRecord>();
const bySemanticKey = new Map<string, AutomationContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_CONTEXTS;
}
function emitAutomationContextsChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_CONTEXT_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const API_BASE_URL = resolveApiBaseUrl();
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${AUTOMATION_CONTEXTS_API_PATH}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
throw new Error((await response.text()) || '자동화 Context 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestAutomationContexts<T>(init?: RequestInit) {
try {
return await requestOnce<T>(API_BASE_URL, init);
} catch (error) {
const shouldRetryWithFallback =
FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(FALLBACK_BASE_URL, init);
}
}
async function loadAutomationContextsFromServer() {
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] | null }>({
method: 'GET',
});
if (response.automationContexts == null) {
return DEFAULT_AUTOMATION_CONTEXTS;
}
return sanitizeAutomationContexts(response.automationContexts);
}
async function saveAutomationContextsToServer(items: AutomationContextRecord[]) {
const resolved = sanitizeAutomationContexts(items);
const response = await requestAutomationContexts<{ ok: boolean; automationContexts: Partial<AutomationContextRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ automationContexts: resolved }),
});
return sanitizeAutomationContexts(response.automationContexts);
}
export function upsertAutomationContext(items: AutomationContextRecord[], input: AutomationContextInput) {
const nextItem = normalizeAutomationContext(input);
if (!nextItem) {
return sanitizeAutomationContexts(items);
}
const nextItems = items.filter((item) => item.id !== nextItem.id);
nextItems.push(nextItem);
return sanitizeAutomationContexts(nextItems);
}
export function deleteAutomationContext(items: AutomationContextRecord[], automationContextId: string) {
const normalizedId = normalizeText(automationContextId);
if (!normalizedId) {
return sanitizeAutomationContexts(items);
}
return sanitizeAutomationContexts(items.filter((item) => item.id !== normalizedId));
}
export function buildAutomationContextOptions(
items: AutomationContextRecord[],
selectedContextIds: string[] = [],
) {
const contexts = sanitizeAutomationContexts(items);
const selectedSet = new Set(selectedContextIds);
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
return contexts
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
.map((item) => ({
label: item.title,
value: item.id,
}));
}
export function resolveDefaultAutomationContextIds(items: AutomationContextRecord[]) {
return sanitizeAutomationContexts(items)
.filter((item) => item.enabled && item.defaultSelected)
.map((item) => item.id);
}
export function useAutomationContextRegistry() {
const [automationContexts, setAutomationContextsState] = useState<AutomationContextRecord[]>(DEFAULT_AUTOMATION_CONTEXTS);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
useEffect(() => {
let cancelled = false;
const load = async () => {
setIsLoading(true);
setErrorMessage('');
try {
const nextAutomationContexts = await loadAutomationContextsFromServer();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(nextAutomationContexts);
}
} catch (error) {
if (!cancelled && mountedRef.current) {
setAutomationContextsState(DEFAULT_AUTOMATION_CONTEXTS);
setErrorMessage(error instanceof Error ? error.message : '자동화 Context를 불러오지 못했습니다.');
}
} finally {
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
return () => {
cancelled = true;
window.removeEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
};
}, []);
const setAutomationContexts = async (nextItems: AutomationContextRecord[]) => {
const saved = await saveAutomationContextsToServer(nextItems);
if (mountedRef.current) {
setAutomationContextsState(saved);
setErrorMessage('');
}
emitAutomationContextsChange();
return saved;
};
return {
automationContexts,
setAutomationContexts,
isLoading,
errorMessage,
};
}

View File

@@ -13,10 +13,20 @@ export const AUTOMATION_BEHAVIOR_TYPES = [
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
defaultSelected: boolean;
updatedAt: string;
};
export type AutomationTypeRecord = {
id: string;
name: string;
description: string;
contexts: AutomationTypeContextRecord[];
behaviorType: AutomationBehaviorType;
enabled: boolean;
updatedAt: string;
@@ -26,6 +36,7 @@ export type AutomationTypeInput = {
id?: string;
name: string;
description?: string;
contexts?: Partial<AutomationTypeContextRecord>[];
behaviorType?: AutomationBehaviorType;
enabled?: boolean;
};
@@ -43,11 +54,39 @@ export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string>
};
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'general-inquiry',
name: '일반 문의',
description: '일반 문의/확인 요청으로 처리합니다.',
contexts: [
{
id: 'general-inquiry-default',
title: '기본 확인',
content: '소스 수정 없이 조회, 상태 확인, 응답 정리를 우선합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
description: '기본 자동화 처리용 유형입니다.',
contexts: [
{
id: 'none-default',
title: '기본 처리',
content:
'## 기본 처리\n- 현재 저장소 규칙과 요청 본문을 우선 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 정리합니다.\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -56,6 +95,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
contexts: [
{
id: 'plan-default',
title: '문서형 처리',
content: 'Plan 등록/문서형 요청으로 처리하고, 구현보다 등록/정리 결과를 우선 남깁니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -64,6 +113,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
contexts: [
{
id: 'command-execution-default',
title: '명령 실행',
content: '저장소 수정 없이 명령 실행, API 확인, 상태 조회 중심으로 처리합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -72,6 +131,16 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
contexts: [
{
id: 'non-source-work-default',
title: '비소스 작업',
content: '문서, 운영, 데이터 확인 등 비소스 작업으로 처리하고 코드 수정은 요청 시에만 제한적으로 수행합니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -79,7 +148,18 @@ const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'auto_worker',
name: 'autoWorker',
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
description: '자동화 작업메모로 처리니다.',
contexts: [
{
id: 'auto-worker-default',
title: '자동화 기본 규칙',
content:
'## context 사용 규칙\n- 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다.\n- Codex Live 문맥이나 일반 채팅 문맥은 자동화 기본 context로 섞지 않습니다.\n\n## 처리 원칙\n- 세부 절차는 현재 운영 설정을 따릅니다.',
enabled: true,
defaultSelected: true,
updatedAt: '2026-04-29T00:00:00.000Z',
},
],
behaviorType: 'auto_worker',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
@@ -95,6 +175,10 @@ export function normalizeAutomationTypeId(
): PlanAutomationType | BoardAutomationType {
const normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'stock-alert') {
return 'general-inquiry';
}
if (normalized === 'plan_registration') {
return 'plan';
}
@@ -128,7 +212,67 @@ function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecor
return 0;
}
function normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
function compareContextUpdatedAt(left: AutomationTypeContextRecord, right: AutomationTypeContextRecord) {
const leftTime = Date.parse(left.updatedAt);
const rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record: Partial<AutomationTypeContextRecord>): AutomationTypeContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
title: title || 'Context',
content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeAutomationContexts(items: Partial<AutomationTypeContextRecord>[] | null | undefined) {
const byId = new Map<string, AutomationTypeContextRecord>();
const bySemanticKey = new Map<string, AutomationTypeContextRecord>();
(items ?? [])
.map((item) => normalizeAutomationContext(item))
.filter((item): item is AutomationTypeContextRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
const current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function normalizeAutomationType(
record: Partial<Omit<AutomationTypeRecord, 'contexts'>> & { contexts?: Partial<AutomationTypeContextRecord>[] },
): AutomationTypeRecord | null {
const name = normalizeText(record.name);
if (!name) {
@@ -143,6 +287,7 @@ function normalizeAutomationType(record: Partial<AutomationTypeRecord>): Automat
id,
name,
description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
@@ -217,7 +362,7 @@ const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
@@ -329,6 +474,36 @@ export function buildAutomationTypeOptions(
}));
}
export function buildAutomationContextOptions(
items: AutomationTypeRecord[],
automationTypeId: string | null | undefined,
selectedContextIds: string[] = [],
) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
const automationType = items.find((item) => item.id === normalizedId) ?? null;
const contexts = sanitizeAutomationContexts(automationType?.contexts);
const selectedSet = new Set(selectedContextIds);
const enabledIds = new Set(contexts.filter((item) => item.enabled).map((item) => item.id));
return contexts
.filter((item) => enabledIds.has(item.id) || selectedSet.has(item.id))
.map((item) => ({
label: item.title,
value: item.id,
}));
}
export function resolveDefaultAutomationContextIds(
items: AutomationTypeRecord[],
automationTypeId: string | null | undefined,
) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
const automationType = items.find((item) => item.id === normalizedId) ?? null;
return sanitizeAutomationContexts(automationType?.contexts)
.filter((item) => item.enabled && item.defaultSelected)
.map((item) => item.id);
}
export function useAutomationTypeRegistry() {
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
const [isLoading, setIsLoading] = useState(true);

View File

@@ -1,5 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import {
LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_CHAT_TYPE_ID,
LAYOUT_EDITOR_CHAT_TYPE_NAME,
} from './chatTypeDefaults';
export type ChatPermissionRole = 'guest' | 'token-user';
@@ -38,6 +43,14 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
},
{
id: LAYOUT_EDITOR_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-27T00:00:00.000Z',
},
{
id: 'api-request-template',
name: 'API요청',
@@ -275,17 +288,7 @@ export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string)
return sanitizeChatTypes(chatTypes);
}
return sanitizeChatTypes(
chatTypes.map((item) =>
item.id === normalizedId
? {
...item,
enabled: false,
updatedAt: new Date().toISOString(),
}
: item,
),
);
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
}
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {

View File

@@ -0,0 +1,6 @@
export const LAYOUT_EDITOR_CHAT_TYPE_ID = 'layout-editor-execution';
export const LAYOUT_EDITOR_CHAT_TYPE_NAME = 'Layout editor 실행';
export const LAYOUT_EDITOR_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';

View File

@@ -64,6 +64,7 @@ export function ConversationRoomPane({
isMobileViewport={false}
isChatTypeSelectionLocked={true}
isComposerAttachmentUploading={false}
isSendWithoutContextEnabled={false}
onViewportScroll={() => {}}
onViewportTouchEnd={() => {}}
onViewportTouchMove={() => {}}
@@ -74,6 +75,7 @@ export function ConversationRoomPane({
onSelectChatType={() => {}}
onSend={() => {}}
onSendImmediate={() => {}}
onToggleSendWithoutContext={() => {}}
onClearDraft={() => {}}
onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -119,67 +119,88 @@ export function useConversationComposerController({
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => {
if (files.length === 0 || isComposerAttachmentUploading) {
if (files.length === 0) {
return { items: [] };
}
setIsComposerAttachmentUploading(true);
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
activeComposerUploadCountRef.current += 1;
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
if (activeComposerUploadCountRef.current === 1) {
setIsComposerAttachmentUploading(true);
}
const fileName = files[index]?.name || `파일 ${index + 1}`;
const reason =
result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패';
failedItems.push({ fileName, reason });
});
try {
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
setIsComposerAttachmentUploading(false);
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
const fileName = files[index]?.name || `파일 ${index + 1}`;
const reason =
result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
: '업로드 실패';
failedItems.push({ fileName, reason });
});
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
};
} finally {
activeComposerUploadCountRef.current = Math.max(0, activeComposerUploadCountRef.current - 1);
if (activeComposerUploadCountRef.current === 0) {
setIsComposerAttachmentUploading(false);
}
}
};
const queuedUpload = composerUploadQueueRef.current.then(uploadBatch, uploadBatch);
composerUploadQueueRef.current = queuedUpload.catch(() => ({ items: [] }));
return queuedUpload;
},
[
activeSessionId,
composerUploadQueueRef,
createLocalMessage,
isComposerAttachmentUploading,
mergeComposerAttachments,
setComposerAttachments,
setIsComposerAttachmentUploading,

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
@@ -16,6 +17,8 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
@@ -49,51 +52,117 @@ export function useConversationListData({
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
const [conversationSearch, setConversationSearch] = useState('');
const isMountedRef = useRef(true);
const listRequestIdRef = useRef(0);
const pendingRequestRef = useRef<Promise<void> | null>(null);
const loadConversationItems = async () => {
setIsConversationListLoading(true);
try {
const items = await chatGateway.listConversations();
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch {
setConversationItems((previous) => previous);
} finally {
setIsConversationListLoading(false);
const loadConversationItems = useCallback(async (options?: { silent?: boolean }) => {
if (pendingRequestRef.current) {
return pendingRequestRef.current;
}
};
const requestId = listRequestIdRef.current + 1;
listRequestIdRef.current = requestId;
const isSilent = options?.silent === true;
if (!isSilent) {
setIsConversationListLoading(true);
}
const requestPromise = (async () => {
try {
const items = await chatGateway.listConversations();
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => {
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
emitChatConversationsUpdated(nextItems);
return nextItems;
});
} catch {
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => previous);
} finally {
pendingRequestRef.current = null;
if (!isMountedRef.current || listRequestIdRef.current !== requestId || isSilent) {
return;
}
setIsConversationListLoading(false);
}
})();
pendingRequestRef.current = requestPromise;
return requestPromise;
}, [requestedSessionId]);
useEffect(() => {
let isCancelled = false;
void chatGateway
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems((previous) => previous);
}
})
.finally(() => {
if (!isCancelled) {
setIsConversationListLoading(false);
}
});
isMountedRef.current = true;
setIsConversationListLoading(true);
void loadConversationItems();
return () => {
isCancelled = true;
isMountedRef.current = false;
};
}, []);
}, [loadConversationItems]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId != null || document.visibilityState !== 'visible') {
return;
}
intervalId = window.setInterval(() => {
void loadConversationItems({ silent: true });
}, CONVERSATION_LIST_POLL_INTERVAL_MS);
};
const stopPolling = () => {
if (intervalId == null) {
return;
}
window.clearInterval(intervalId);
intervalId = null;
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadConversationItems({ silent: true });
startPolling();
return;
}
stopPolling();
};
const handleFocus = () => {
void loadConversationItems({ silent: true });
startPolling();
};
startPolling();
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [loadConversationItems]);
return {
conversationItems,

View File

@@ -42,13 +42,14 @@ export function useConversationViewController({
}: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewText, setPreviewText] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState('');
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
@@ -64,6 +65,7 @@ export function useConversationViewController({
setComposerAttachments([]);
setCopiedMessageId(null);
setActivePreviewId(null);
setActivePreviewOverride(null);
setIsPreviewModalOpen(false);
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
@@ -80,7 +82,7 @@ export function useConversationViewController({
]);
useEffect(() => {
if (!activePreviewId) {
if (!activePreviewId || activePreviewOverride) {
return;
}
@@ -90,7 +92,7 @@ export function useConversationViewController({
setActivePreviewId(null);
setIsPreviewModalOpen(false);
}, [activePreviewId, previewItems]);
}, [activePreviewId, activePreviewOverride, previewItems]);
useEffect(() => {
if (!isPreviewModalOpen || !activePreview) {
@@ -205,6 +207,7 @@ export function useConversationViewController({
previewError,
previewText,
setActivePreviewId,
setActivePreviewOverride,
setIsPreviewModalOpen,
};
}

View File

@@ -261,14 +261,22 @@ export function useConversationViewportController({
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
if (!viewport || isLoadingOlderMessages) {
touchStartYRef.current = null;
touchPullActiveRef.current = false;
return;
}
const isAtTop = viewport.scrollTop <= 0;
if (isAtTop && hasOlderMessages) {
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
touchPullActiveRef.current = false;
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
@@ -279,7 +287,15 @@ export function useConversationViewportController({
const viewport = viewportRef.current;
const currentY = event.touches[0]?.clientY ?? null;
if (!viewport || currentY == null || viewport.scrollTop > 0) {
if (!viewport || currentY == null) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
setIsPullToLoadArmed(false);
return;
}
if (viewport.scrollTop > 0) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
@@ -313,7 +329,13 @@ export function useConversationViewportController({
if (shouldLoadOlder) {
void onLoadOlderMessages();
}
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
}, [
hasOlderMessages,
isLoadingOlderMessages,
isPullToLoadArmed,
onLoadOlderMessages,
resetPullToLoad,
]);
useEffect(() => {
if (connectionState === 'disconnected') {

View File

@@ -0,0 +1,67 @@
const CODEX_LIVE_DRAFT_STORAGE_KEY = 'codex-live:draft-bridge';
export type CodexLiveDraftPayload = {
text: string;
source: string;
createdAt: string;
autoSend?: boolean;
sendMode?: 'queue' | 'direct';
};
export function stashCodexLiveDraft(payload: CodexLiveDraftPayload) {
if (typeof window === 'undefined') {
return false;
}
const text = payload.text.trim();
if (!text) {
return false;
}
window.sessionStorage.setItem(
CODEX_LIVE_DRAFT_STORAGE_KEY,
JSON.stringify({
text,
source: payload.source.trim() || 'unknown',
createdAt: payload.createdAt.trim() || new Date().toISOString(),
autoSend: payload.autoSend === true,
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
}),
);
return true;
}
export function consumeCodexLiveDraft() {
if (typeof window === 'undefined') {
return null;
}
const raw = window.sessionStorage.getItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
if (!raw) {
return null;
}
window.sessionStorage.removeItem(CODEX_LIVE_DRAFT_STORAGE_KEY);
try {
const payload = JSON.parse(raw) as Partial<CodexLiveDraftPayload>;
const text = typeof payload.text === 'string' ? payload.text.trim() : '';
if (!text) {
return null;
}
return {
text,
source: typeof payload.source === 'string' ? payload.source.trim() || 'unknown' : 'unknown',
createdAt:
typeof payload.createdAt === 'string' && payload.createdAt.trim()
? payload.createdAt.trim()
: new Date().toISOString(),
autoSend: payload.autoSend === true,
sendMode: payload.sendMode === 'direct' ? 'direct' : 'queue',
} satisfies CodexLiveDraftPayload;
} catch {
return null;
}
}

View File

@@ -88,6 +88,7 @@ function parseRoute(pathname: string): {
first === 'schedule' ||
first === 'history' ||
first === 'automation-type' ||
first === 'automation-context' ||
first === 'server-command')
) {
return {
@@ -155,8 +156,16 @@ function getIsMobileViewport() {
return window.matchMedia('(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
if (!isMobileViewport) {
function getIsSidebarOverlayViewport(topMenu: TopMenuKey) {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, topMenu: TopMenuKey) {
if (!isSidebarOverlayViewport) {
return false;
}
@@ -208,7 +217,10 @@ export function MainLayout() {
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu),
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu), routeState.topMenu),
);
const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() =>
getIsSidebarOverlayViewport(routeState.topMenu),
);
const [contentExpanded, setContentExpanded] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
@@ -218,7 +230,7 @@ export function MainLayout() {
'working' | 'release-pending-main' | 'automation-failed' | null
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts();
useEffect(() => {
@@ -240,8 +252,22 @@ export function MainLayout() {
}, []);
useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
}, [isMobileViewport, routeState.topMenu]);
const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)');
const updateViewport = () => {
setIsSidebarOverlayViewport(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, [routeState.topMenu]);
useEffect(() => {
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport, routeState.topMenu));
}, [isSidebarOverlayViewport, routeState.topMenu]);
useEffect(() => {
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
@@ -256,10 +282,10 @@ export function MainLayout() {
useEffect(() => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) {
if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) {
navigate(buildPlayPath('layout'), { replace: true });
}
}, [navigate, routeState.playMenu, savedLayouts]);
}, [navigate, routeState.playMenu, savedLayouts, savedLayoutsReady]);
useEffect(() => {
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
@@ -407,6 +433,7 @@ export function MainLayout() {
componentSamples,
widgetSamples,
savedLayouts,
savedLayoutsReady,
setSavedLayouts,
searchOptions,
}}
@@ -427,21 +454,21 @@ export function MainLayout() {
}}
onChangeTopMenu={(menu) => {
navigate(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu), menu));
}}
onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
setActivePlanQuickFilter(filter);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(targetPlanMenu));
setSidebarCollapsed(isMobileViewport);
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'), 'plans'));
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
}}
/>
)}
<Layout>
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
hasAccess={hasAccess}
@@ -461,13 +488,13 @@ export function MainLayout() {
onOpenKeysChange={setSidebarOpenKeys}
onSelectApiMenu={(key) => {
navigate(buildApisPath(key as ApiSectionKey));
if (isMobileViewport) {
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key));
if (isMobileViewport) {
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
@@ -475,20 +502,20 @@ export function MainLayout() {
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
setPlanQuickFilterRequestKey((previous) => previous + 1);
navigate(buildPlansPath(key));
if (isMobileViewport) {
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectChatMenu={(key) => {
navigate(buildChatPath(key));
if (isMobileViewport) {
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
if (isMobileViewport) {
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
}}
@@ -498,7 +525,7 @@ export function MainLayout() {
/>
)}
{isMobileViewport && !sidebarCollapsed ? null : (
{isSidebarOverlayViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet />
</MainContent>

View File

@@ -29,6 +29,7 @@ export type MainLayoutContextValue = {
componentSamples: LoadedSampleEntry[];
widgetSamples: LoadedSampleEntry[];
savedLayouts: SavedLayoutRecord[];
savedLayoutsReady: boolean;
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
searchOptions: SearchKeywordOption[];
};

View File

@@ -157,6 +157,18 @@ export function buildSearchOptions({
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:automation-context',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
group: 'Page',
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('automation-context'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,

View File

@@ -13,6 +13,7 @@ export function useMainLayoutData() {
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
const [savedLayoutsReady, setSavedLayoutsReady] = useState(false);
useEffect(() => {
let mounted = true;
@@ -47,11 +48,13 @@ export function useMainLayoutData() {
.then((layouts) => {
if (mounted) {
setSavedLayouts(layouts);
setSavedLayoutsReady(true);
}
})
.catch(() => {
if (mounted) {
setSavedLayouts([]);
setSavedLayoutsReady(true);
}
});
@@ -98,6 +101,7 @@ export function useMainLayoutData() {
widgetSamples,
docsDocuments,
savedLayouts,
savedLayoutsReady,
setSavedLayouts,
docFolders,
};

View File

@@ -2,6 +2,7 @@ import {
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DisconnectOutlined,
DownloadOutlined,
DownOutlined,
ExclamationCircleOutlined,
@@ -33,11 +34,16 @@ import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { triggerResourceDownload } from './downloadUtils';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
@@ -80,6 +86,16 @@ type InlinePreviewTarget = {
kind: InlinePreviewKind;
};
type OpenPreviewTarget =
| string
| {
id: string;
label: string;
url: string;
kind: InlinePreviewKind;
source?: 'message' | 'context';
};
type PendingComposerUpload = {
key: string;
name: string;
@@ -102,8 +118,14 @@ type MessageRenderPayload = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
rankedLinkTargets: RankedLinkPreviewTarget[];
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
};
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
@@ -167,7 +189,7 @@ function buildInlinePreviewLabel(url: string) {
}
}
function buildPreviewFileName(item: PreviewOption) {
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
@@ -177,10 +199,203 @@ function buildPreviewFileName(item: PreviewOption) {
}
}
function normalizeRankedLinkTitle(value: string) {
return value
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function extractRankedLinkTargets(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
const seen = new Set<string>();
const pushRankedLink = (title: string, url: string) => {
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
const key = `${normalizedTitle}::${normalizedUrl}`;
if (seen.has(key)) {
return;
}
seen.add(key);
rankedLinkTargets.push({
title: normalizedTitle,
url: normalizedUrl,
});
};
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
const trimmedLine = line.trim();
if (!trimmedLine) {
keptLines.push(line);
continue;
}
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
markdownMatches.forEach((match) => {
const [, label, href] = match;
if (href?.trim()) {
pushRankedLink(label?.trim() || href.trim(), href);
}
});
continue;
}
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
if (!titleMatch) {
keptLines.push(line);
continue;
}
const collectedLines = [line];
const title = titleMatch[1]?.trim() ?? '';
let url = '';
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
let cursor = index + 1;
while (cursor < lines.length) {
const candidate = lines[cursor] ?? '';
const trimmedCandidate = candidate.trim();
if (!trimmedCandidate) {
collectedLines.push(candidate);
cursor += 1;
continue;
}
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
break;
}
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
if (linkMatch) {
url = linkMatch[1]?.trim() ?? url;
collectedLines.push(candidate);
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
cursor += 1;
continue;
}
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
hasRank = true;
collectedLines.push(candidate);
cursor += 1;
continue;
}
break;
}
if (title && url && hasRank) {
pushRankedLink(title, url);
index = cursor - 1;
continue;
}
keptLines.push(...collectedLines);
index = cursor - 1;
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
rankedLinkTargets,
};
}
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
function isClipboardImageFile(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
if (normalizedType.startsWith('image/')) {
return true;
}
const normalizedName = String(file.name ?? '').trim().toLowerCase();
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
}
function isGeneratedClipboardImageName(file: File) {
const normalizedName = String(file.name ?? '').trim().toLowerCase();
if (!normalizedName) {
return true;
}
return /^(image|clipboard|pasted image)([-\s]?\d+)?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(normalizedName);
}
function getClipboardImageMimeRank(file: File) {
const normalizedType = String(file.type ?? '').trim().toLowerCase();
switch (normalizedType) {
case 'image/png':
return 0;
case 'image/jpeg':
return 1;
case 'image/webp':
return 2;
case 'image/gif':
return 3;
case 'image/bmp':
return 4;
case 'image/heic':
case 'image/heif':
return 5;
case 'image/tiff':
case 'image/tif':
return 6;
default:
return 7;
}
}
function resolvePreferredClipboardImageFiles(files: File[]) {
if (files.length <= 1) {
return files;
}
const sortedFiles = [...files]
.sort((left, right) => {
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
if (rankDifference !== 0) {
return rankDifference;
}
return right.size - left.size;
})
.slice(0, 1);
if (files.every(isGeneratedClipboardImageName)) {
return sortedFiles;
}
return sortedFiles;
}
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file instanceof File)
.filter((file) => file.size > 0);
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
const imageFiles = candidateFiles.filter(isClipboardImageFile);
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
let responseMessage = '';
@@ -252,7 +467,15 @@ function renderMessageInlineParts(line: string): ReactNode[] {
const href = normalizeInlinePreviewUrl(rawHref.trim());
renderedParts.push(
<a key={`${href}-${start}`} href={href} target="_blank" rel="noreferrer">
<a
key={`${href}-${start}`}
href={href}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(href, event);
}}
>
{label.trim() || href}
</a>,
);
@@ -300,18 +523,28 @@ function renderMessageBody(text: string) {
});
}
function extractMessageRenderPayload(text: string): MessageRenderPayload {
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
const extractedMessageParts = extractChatMessageParts(message.text);
const text = extractedMessageParts.strippedText;
const linkCardTargets = [
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
const visibleText = stripHiddenPreviewTags(previewSourceText);
return {
previewSourceText,
visibleText,
diffBlocks,
rankedLinkTargets,
linkCardTargets,
};
}
@@ -320,6 +553,10 @@ function summarizeQueuedText(text: string) {
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
}
function normalizeAttachmentName(value: string) {
return String(value ?? '').trim().toLowerCase();
}
function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
@@ -552,8 +789,11 @@ function InlineMessagePreview({
className="app-chat-preview-card__action"
icon={<DownloadOutlined />}
aria-label="preview 다운로드"
href={target.url}
download
onClick={() => {
void triggerResourceDownload(target.url, buildPreviewFileName(target)).catch((error: unknown) => {
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
});
}}
/>
<Button
type="link"
@@ -699,6 +939,7 @@ type ChatConversationViewProps = {
isMobileViewport: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
isSendWithoutContextEnabled: boolean;
onViewportScroll: () => void;
onViewportTouchEnd: () => void;
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
@@ -709,10 +950,11 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onToggleSendWithoutContext: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void;
@@ -746,6 +988,7 @@ export function ChatConversationView({
isMobileViewport,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
isSendWithoutContextEnabled,
onViewportScroll,
onViewportTouchEnd,
onViewportTouchMove,
@@ -756,6 +999,7 @@ export function ChatConversationView({
onSelectChatType,
onSend,
onSendImmediate,
onToggleSendWithoutContext,
onClearDraft,
onScrollToBottom,
onToggleResourceStrip,
@@ -1056,11 +1300,17 @@ export function ChatConversationView({
}
const uploadedAttachmentNames = new Set(
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter(
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter((item) => {
const normalizedName = normalizeAttachmentName(item.name);
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
return false;
}
return item.status === 'uploaded' || item.status === 'failed';
});
if (resolvedUploads.length > 0) {
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
@@ -1071,6 +1321,7 @@ export function ChatConversationView({
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const syncPendingComposerUploads = async (files: File[]) => {
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
const nextPendingUploads = files.map((file) => ({
key: buildComposerFilePickKey(file),
name: file.name,
@@ -1079,7 +1330,7 @@ export function ChatConversationView({
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
setPendingComposerUploads((current) => [
...current.filter((item) => !pendingKeys.has(item.key)),
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
...nextPendingUploads,
]);
@@ -1135,24 +1386,14 @@ export function ChatConversationView({
if (!clipboardData) {
return;
}
const itemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
const files = resolveComposerPasteFiles(clipboardData);
if (files.length === 0) {
return;
}
event.preventDefault();
const uniqueFiles = Array.from(
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void syncPendingComposerUploads(uniqueFiles);
void syncPendingComposerUploads(files);
};
const dismissPendingComposerUpload = (key: string) => {
@@ -1398,14 +1639,15 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0;
const hasPreviewCards =
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [
@@ -1534,6 +1776,12 @@ export function ChatConversationView({
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{linkCardTargets.map((target) => (
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{rankedLinkTargets.map((target) => (
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{diffBlocks.map((diffText, index) => {
const previewKey = `${message.id}-diff-${index}`;
@@ -1577,12 +1825,20 @@ export function ChatConversationView({
key={previewKey}
target={target}
isExpanded={expandedPreviewKey === previewKey}
hasModalPreview={Boolean(matchedPreview)}
hasModalPreview
onOpenModalPreview={() => {
if (matchedPreview) {
onOpenPreview(matchedPreview.id, { fullscreen: true });
return;
}
onOpenPreview(
matchedPreview
? matchedPreview.id
: {
id: previewKey,
label: target.label,
url: target.url,
kind: target.kind,
source: 'message',
},
{ fullscreen: true },
);
}}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
@@ -1595,7 +1851,6 @@ export function ChatConversationView({
</div>
);
})}
</div>
{activeSystemStatus ? (
@@ -1656,6 +1911,17 @@ export function ChatConversationView({
</div>
<div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons">
<Button
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
className={`app-chat-panel__composer-contextless-toggle${
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
}`}
icon={<DisconnectOutlined />}
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
onClick={onToggleSendWithoutContext}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"

View File

@@ -0,0 +1,48 @@
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { openChatExternalLink } from './linkNavigation';
import type { ChatMessagePart } from './types';
export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePart, { type: 'link_card' }> }) {
return (
<section className="app-chat-preview-card app-chat-preview-card--link-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
<LinkOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.title}</span>
<span className="app-chat-preview-card__kind">link card</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="link"
size="small"
className="app-chat-preview-card__open-link"
icon={<ExportOutlined />}
onClick={(event) => {
void openChatExternalLink(target.url, event);
}}
>
{target.actionLabel?.trim() || '열기'}
</Button>
</div>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
<a
className="app-chat-preview-card__ranked-link-anchor"
href={target.url}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(target.url, event);
}}
>
{target.url}
</a>
</div>
</section>
);
}

View File

@@ -9,7 +9,7 @@ import {
PictureOutlined,
VideoCameraOutlined,
} from '@ant-design/icons';
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
import { Alert, Button, Empty, Space, Spin, Typography, message } from 'antd';
import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer';
@@ -326,6 +326,13 @@ export function ChatPreviewBody({
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
}
const handleDownloadResource = () => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
void triggerResourceDownload(target.url, fileName).catch((error: unknown) => {
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
});
};
if (target.kind === 'file') {
return (
<div className="app-chat-panel__preview-file">
@@ -334,15 +341,7 @@ export function ChatPreviewBody({
</Paragraph>
<Space wrap>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
</Space>
</div>
);
@@ -414,15 +413,7 @@ export function ChatPreviewBody({
</Paragraph>
<Space wrap>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadResource} />
</Space>
</div>
);

View File

@@ -0,0 +1,52 @@
import { ExportOutlined, LinkOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { openChatExternalLink } from './linkNavigation';
export type RankedLinkPreviewTarget = {
title: string;
url: string;
};
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) {
return (
<section className="app-chat-preview-card app-chat-preview-card--ranked-link">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--ranked-link" aria-hidden="true">
<LinkOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.title}</span>
<span className="app-chat-preview-card__kind">link preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="link"
size="small"
className="app-chat-preview-card__open-link"
icon={<ExportOutlined />}
onClick={(event) => {
void openChatExternalLink(target.url, event);
}}
>
</Button>
</div>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--ranked-link">
<a
className="app-chat-preview-card__ranked-link-anchor"
href={target.url}
target="_blank"
rel="noreferrer noopener"
onClick={(event) => {
openChatExternalLink(target.url, event);
}}
>
{target.url}
</a>
</div>
</section>
);
}

View File

@@ -24,7 +24,74 @@ export function shouldOpenDownloadInNewWindow() {
return isStandaloneDisplayMode() && isMobileLikeViewport();
}
export function triggerResourceDownload(url: string, fileName?: string) {
function decodeDownloadFileName(value: string) {
const normalized = String(value ?? '').trim();
if (!normalized) {
return '';
}
try {
return decodeURIComponent(normalized);
} catch {
return normalized;
}
}
function resolveFileNameFromUrl(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.href : 'https://local.invalid');
return decodeDownloadFileName(parsed.pathname.split('/').filter(Boolean).at(-1) ?? '');
} catch {
return '';
}
}
function parseContentDispositionFileName(headerValue: string | null) {
const normalized = String(headerValue ?? '').trim();
if (!normalized) {
return '';
}
const utf8Match = normalized.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeDownloadFileName(utf8Match[1]);
}
const quotedMatch = normalized.match(/filename="([^"]+)"/i);
if (quotedMatch?.[1]) {
return decodeDownloadFileName(quotedMatch[1]);
}
const plainMatch = normalized.match(/filename=([^;]+)/i);
return plainMatch?.[1] ? decodeDownloadFileName(plainMatch[1].replace(/^["']|["']$/g, '')) : '';
}
function isHtmlFileName(fileName: string) {
return /\.html?$/i.test(fileName.trim());
}
function downloadBlob(blob: Blob, fileName: string) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => {
URL.revokeObjectURL(objectUrl);
}, 0);
}
function triggerAnchorDownload(url: string, fileName?: string) {
if (typeof document === 'undefined') {
throw new Error('download-unavailable');
}
@@ -45,3 +112,57 @@ export function triggerResourceDownload(url: string, fileName?: string) {
link.click();
document.body.removeChild(link);
}
function buildDownloadErrorMessage(response: Response) {
if (response.status === 401) {
return '인증이 없어 파일을 내려받지 못했습니다.';
}
if (response.status === 403) {
return '권한이 없어 파일을 내려받지 못했습니다.';
}
if (response.status === 404) {
return '파일을 찾지 못했습니다.';
}
return `다운로드에 실패했습니다. (${response.status})`;
}
export async function triggerResourceDownload(url: string, fileName?: string) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
throw new Error('download-unavailable');
}
const parsedUrl = new URL(url, window.location.href);
const preferredFileName = fileName?.trim() || resolveFileNameFromUrl(parsedUrl.toString()) || 'resource';
if (parsedUrl.origin !== window.location.origin) {
triggerAnchorDownload(parsedUrl.toString(), preferredFileName);
return;
}
const response = await fetch(parsedUrl.toString(), {
credentials: 'include',
});
if (!response.ok) {
throw new Error(buildDownloadErrorMessage(response));
}
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
const contentDisposition = response.headers.get('content-disposition');
const responseFileName = parseContentDispositionFileName(contentDisposition);
const resolvedFileName = responseFileName || preferredFileName;
const blob = await response.blob();
if (contentType.includes('text/html') && !isHtmlFileName(resolvedFileName)) {
const htmlPreview = (await blob.text()).trimStart().toLowerCase();
if (htmlPreview.startsWith('<!doctype html') || htmlPreview.startsWith('<html') || htmlPreview.includes('<head')) {
throw new Error('실제 파일 대신 앱 HTML이 반환되어 다운로드를 중단했습니다.');
}
}
downloadBlob(blob, resolvedFileName);
}

View File

@@ -0,0 +1,65 @@
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
type LinkNavigationEvent = {
preventDefault?: () => void;
stopPropagation?: () => void;
};
function canUseSessionStorage() {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
}
function persistExternalLinkOpenTimestamp(openedAt: number) {
if (!canUseSessionStorage()) {
return;
}
window.sessionStorage.setItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY, String(openedAt));
}
function clearExternalLinkOpenTimestamp() {
if (!canUseSessionStorage()) {
return;
}
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
}
export function shouldSkipForegroundResyncAfterExternalLink() {
if (!canUseSessionStorage()) {
return false;
}
const rawOpenedAt = window.sessionStorage.getItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
clearExternalLinkOpenTimestamp();
if (!rawOpenedAt) {
return false;
}
const openedAt = Number(rawOpenedAt);
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
}
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
event?.preventDefault?.();
event?.stopPropagation?.();
if (typeof window === 'undefined') {
return;
}
persistExternalLinkOpenTimestamp(Date.now());
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
if (openedWindow) {
return;
}
const anchor = document.createElement('a');
anchor.href = url;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.click();
}

View File

@@ -0,0 +1,164 @@
import type { ChatMessagePart } from './types';
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\(([^)]+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeUrl(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
return '';
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const segments = rawBody
.split('|')
.map((segment) => segment.trim())
.filter(Boolean);
if (segments.length < 2) {
return null;
}
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
if (!title || !url) {
return null;
}
return {
type: 'link_card',
title,
url,
actionLabel,
};
}
export function extractChatMessageParts(text: string) {
const lines = String(text ?? '').split('\n');
const keptLines: string[] = [];
const parts: ChatMessagePart[] = [];
const seenLinkKeys = new Set<string>();
const pushPart = (nextPart: ChatMessagePart | null) => {
if (!nextPart) {
return false;
}
const dedupeKey = `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}`;
if (seenLinkKeys.has(dedupeKey)) {
return true;
}
seenLinkKeys.add(dedupeKey);
parts.push(nextPart);
return true;
};
for (const line of lines) {
const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line);
continue;
}
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
}
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
parts,
};
}

View File

@@ -1,5 +1,6 @@
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractChatMessageParts } from './messageParts';
import { extractHiddenPreviewUrls } from './previewMarkers';
import type { ChatMessage } from './types';
@@ -106,7 +107,21 @@ export function extractPreviewItems(messages: ChatMessage[]) {
const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => {
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
const extractedMessageParts = extractChatMessageParts(message.text);
const structuredLinkUrls = [
...(Array.isArray(message.parts) ? message.parts : []),
...extractedMessageParts.parts,
]
.filter(
(part): part is Extract<(typeof extractedMessageParts.parts)[number], { type: 'link_card' }> =>
part.type === 'link_card' && Boolean(part.url),
)
.map((part) => part.url);
const matches = [
...extractAutoDetectedPreviewUrls(message.text),
...extractHiddenPreviewUrls(message.text),
...structuredLinkUrls,
];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);

View File

@@ -1,5 +1,13 @@
import type { ErrorLogItem } from '../errorLogApi';
export type ChatMessagePart =
| {
type: 'link_card';
title: string;
url: string;
actionLabel?: string | null;
};
export type ChatMessage = {
id: number;
author: 'codex' | 'system' | 'user';
@@ -8,6 +16,7 @@ export type ChatMessage = {
clientRequestId?: string | null;
deliveryStatus?: 'retrying' | 'failed' | null;
retryCount?: number;
parts?: ChatMessagePart[];
};
export type ChatComposerAttachment = {

View File

@@ -28,6 +28,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'automation-context': 'Context 유형',
'server-command': 'Command',
};
@@ -52,6 +53,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context',
'server-command': 'plan-menu-server-command',
};

View File

@@ -113,6 +113,18 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
{
id: 'page:plans:automation-context',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-context']}`,
group: 'Page',
keywords: ['plans', 'plan', 'context', 'context type', '컨텍스트', 'Context 유형', '부모 context'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('automation-context');
setFocusedComponentId(null);
},
onSelectWindow,
},
...(hasAccess
? [
{

View File

@@ -77,6 +77,9 @@ export type ClientNotificationPayload = {
body: string;
data?: Record<string, string>;
threadId?: string;
targetClientIds?: string[];
targetAppOrigins?: string[];
targetAppDomains?: string[];
};
export type ClientNotificationSendResult = {
@@ -100,8 +103,26 @@ export type ClientNotificationSendResult = {
export type PwaNotificationTokenPayload = {
token: string;
deviceId?: string;
appOrigin?: string;
appDomain?: string;
};
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
export type NotificationMessageListStatus = 'all' | 'unread';
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
@@ -724,6 +745,8 @@ export async function registerWebPushSubscription(
subscription,
deviceId,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
appOrigin: getCurrentAppOrigin(),
appDomain: getCurrentAppDomain(),
enabled: true,
}),
});
@@ -744,6 +767,8 @@ export async function registerPwaNotificationToken(payload: PwaNotificationToken
body: JSON.stringify({
token: payload.token,
deviceId: payload.deviceId,
appOrigin: payload.appOrigin || getCurrentAppOrigin(),
appDomain: payload.appDomain || getCurrentAppDomain(),
enabled: true,
}),
});

View File

@@ -1,4 +1,5 @@
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
import { BoardPage } from '../../../features/board';
import { HistoryPage } from '../../../features/history';
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
@@ -62,6 +63,14 @@ export function PlansPage() {
);
}
if (selectedPlanMenu === 'automation-context') {
return (
<div className="app-main-panel">
<AutomationContextManagementPage />
</div>
);
}
if (selectedPlanMenu === 'server-command') {
return (
<div className="app-main-panel">

View File

@@ -1,16 +1,22 @@
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { MemoLayoutPage } from '../../../features/layout/memo';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
export function PlayPage() {
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
const { selectedPlayMenu, savedLayouts, setSavedLayouts } = useMainLayoutContext();
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
const selectedSavedLayout = selectedSavedLayoutId
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
: null;
const isMemoLayout = selectedSavedLayout?.name === '메모';
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
return (
<div className={panelClassName}>
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedSavedLayoutId ? (
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
{selectedSavedLayoutId && !isMemoLayout ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
) : null}
</div>

View File

@@ -16,6 +16,7 @@ export type PlanSectionKey =
| 'schedule'
| 'history'
| 'automation-type'
| 'automation-context'
| 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout';
@@ -49,6 +50,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'automation-context': 'Context 유형',
'server-command': 'Command',
};
@@ -68,6 +70,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context',
'server-command': 'plan-menu-server-command',
};
@@ -203,6 +206,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
key: 'automation-type',
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
},
{
key: 'automation-context',
label: renderPlanMenuLabel('automation-context', PLAN_SIDEBAR_LABELS['automation-context']),
},
],
},
{

View File

@@ -0,0 +1,22 @@
.input-base-sample-preview {
display: block;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
}
.input-base-sample-preview__control.ant-input {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
height: 100% !important;
min-height: 100%;
min-height: 44px;
max-height: none;
margin: 0;
border-radius: 14px;
padding-inline: 14px;
box-shadow: none;
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import type { SampleMeta } from '../../../../../widgets/core';
import { InputUI } from '../InputUI';
import './BaseSample.css';
export const sampleMeta: SampleMeta = {
id: 'input-base',
@@ -18,12 +19,15 @@ export function Sample() {
const [value, setValue] = useState('초기값');
return (
<InputUI
value={value}
placeholder="입력 후 Enter 또는 blur"
onChange={(event) => {
setValue(event.target.value);
}}
/>
<div className="input-base-sample-preview">
<InputUI
className="input-base-sample-preview__control"
value={value}
placeholder="입력 후 Enter 또는 blur"
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export function Sample() {
}}
/>
<Text> : {committedValue}</Text>
<Text type="secondary"> : {commitCount}</Text>
<Text type="secondary">{`확정 횟수: ${commitCount}`}</Text>
</Flex>
</Card>
);

View File

@@ -12,6 +12,7 @@ export function SelectUI({
value,
defaultValue,
onChange,
formatLabel,
showSearch = true,
allowClear = true,
placeholder = '항목을 선택하세요',
@@ -21,10 +22,10 @@ export function SelectUI({
() =>
data.map((item) => ({
value: item.code,
label: item.value,
label: formatLabel ? formatLabel(item) : item.value,
item,
})),
[data],
[data, formatLabel],
);
const itemMap = useMemo(

View File

@@ -13,4 +13,5 @@ export type SelectUIProps = Omit<
value?: string;
defaultValue?: string;
onChange?: (code?: string, item?: SelectOptionItem) => void;
formatLabel?: (item: SelectOptionItem) => string;
};

View File

@@ -7,8 +7,33 @@
- 컴포넌트 샘플 레이아웃
- 위젯 샘플 레이아웃
- Markdown preview 리스트 레이아웃
- `Layout Editor`와 저장 레이아웃 흐름
## 규칙
- 공통 레이아웃 시스템이 아니라 현재 프로젝트 화면에 종속되면 `features/layout`에 배치
- 공통 재사용 가능성이 높으면 `components` 또는 `widgets`로 승격 검토
## Layout Editor 기준
`Layout Editor`는 위젯 자체의 기본 기능을 정의하는 화면이 아닙니다. 이 화면은 레이아웃 안에 배치된 컴포넌트가 현재 화면에서 어떤 역할을 해야 하는지 기술하는 편집기입니다.
용어 기준:
- `컴포넌트 기능 명세`: 현재 레이아웃에 배치된 컴포넌트의 화면 동작, 상호작용, 구현 포인트를 적는 항목
- `저장 레이아웃`: 구조, 배치, 기능 명세를 함께 저장한 편집 결과
- `Codex 요청`: 저장된 레이아웃과 그 안의 기능 명세를 기준으로 구현 요청을 만드는 액션
허용 범위:
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.

View File

@@ -0,0 +1,240 @@
.memo-layout-page {
width: 100%;
height: 100%;
min-height: 0;
background:
radial-gradient(circle at top left, rgba(250, 204, 21, 0.18), transparent 30%),
linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%);
}
.memo-layout-page__splitter,
.memo-layout-page__splitter .ant-splitter-panel {
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__pane {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
padding: 18px;
box-sizing: border-box;
}
.memo-layout-page__pane--title {
align-items: stretch;
}
.memo-layout-page__title-input.ant-input {
align-self: stretch;
width: 100%;
height: 100%;
padding: 24px 26px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
box-shadow:
0 22px 48px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
color: #0f172a;
font-size: clamp(26px, 4vw, 40px);
font-weight: 700;
line-height: 1.15;
}
.memo-layout-page__title-input.ant-input::placeholder {
color: rgba(100, 116, 139, 0.7);
}
.memo-layout-page__pane--memo {
flex-direction: column;
gap: 12px;
}
.memo-layout-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.memo-layout-page__toolbar-group {
display: flex;
align-items: center;
gap: 6px;
}
.memo-layout-page__toolbar .ant-btn {
width: 32px;
min-width: 32px;
height: 32px;
border-radius: 12px;
color: #475569;
}
.memo-layout-page__toolbar .ant-btn:not(:disabled):hover {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
}
.memo-layout-page__body {
display: flex;
flex: 1 1 auto;
gap: 12px;
min-height: 0;
}
.memo-layout-page__body--list-open .memo-layout-page__editor {
border-top-left-radius: 22px;
border-bottom-left-radius: 22px;
}
.memo-layout-page__list-shell {
flex: 0 0 260px;
min-width: 220px;
min-height: 0;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 24px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.memo-layout-page__empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 0;
}
.memo-layout-page__list {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px;
overflow: auto;
box-sizing: border-box;
}
.memo-layout-page__list-item {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px 14px;
border: 0;
border-radius: 18px;
background: rgba(248, 250, 252, 0.96);
text-align: left;
cursor: pointer;
}
.memo-layout-page__list-item:hover {
background: rgba(241, 245, 249, 1);
}
.memo-layout-page__list-item--active {
background: rgba(254, 240, 138, 0.42);
}
.memo-layout-page__list-time {
color: rgba(100, 116, 139, 0.94);
font-size: 12px;
}
.memo-layout-page__list-preview {
color: #0f172a;
font-size: 14px;
line-height: 1.45;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.memo-layout-page__editor {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
border: 1px solid rgba(245, 158, 11, 0.18);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.42)),
repeating-linear-gradient(
180deg,
rgba(255, 248, 216, 0.98) 0,
rgba(255, 248, 216, 0.98) 37px,
rgba(236, 221, 177, 0.78) 37px,
rgba(236, 221, 177, 0.78) 38px
);
box-shadow:
0 18px 44px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
overflow: hidden;
}
.memo-layout-page__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
padding: 14px 18px 0;
color: rgba(100, 116, 139, 0.92);
font-size: 12px;
}
.memo-layout-page__meta > :first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memo-layout-page__textarea.ant-input {
flex: 1 1 auto;
min-height: 0;
padding: 10px 18px 32px;
color: #3f3a2f;
font-size: 16px;
line-height: 38px;
background: transparent;
resize: none;
}
.memo-layout-page__textarea.ant-input::placeholder {
color: rgba(120, 113, 91, 0.72);
}
@media (max-width: 768px) {
.memo-layout-page__pane {
padding: 12px;
}
.memo-layout-page__body {
flex-direction: column;
}
.memo-layout-page__list-shell {
flex: 0 0 180px;
min-width: 0;
}
.memo-layout-page__title-input.ant-input {
padding: 18px 20px;
border-radius: 22px;
font-size: 24px;
}
.memo-layout-page__editor {
border-radius: 22px;
}
}

View File

@@ -0,0 +1,345 @@
import {
CheckOutlined,
DeleteOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
SaveOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Modal, Splitter, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { InputUI } from '../../../components/inputs/primitives/input';
import {
createTextMemoNote,
deleteTextMemoNote,
fetchTextMemoNotes,
updateTextMemoNote,
type TextMemoNoteRecord,
} from '../../../widgets/text-memo-widget/textMemoApi';
import './MemoLayoutPage.css';
type MemoLayoutPageProps = {
layoutId: string;
};
type MemoNote = TextMemoNoteRecord;
const PRIMARY_SIZE = '42%';
const PRIMARY_MIN = '24%';
const SECONDARY_MIN = '20%';
const MAX_NOTE_COUNT = 12;
const MAX_BODY_LENGTH = 1200;
function getFirstLine(value: string) {
const [firstLine = ''] = value.split(/\r?\n/u);
return firstLine;
}
function replaceFirstLine(body: string, nextTitle: string) {
const normalizedTitle = nextTitle.trim();
const lineBreakIndex = body.search(/\r?\n/u);
if (lineBreakIndex < 0) {
return normalizedTitle;
}
const nextTail = body.slice(lineBreakIndex);
return `${normalizedTitle}${nextTail}`;
}
function getPreviewText(body: string) {
const preview = body.replace(/\s+/gu, ' ').trim();
return preview || '새 메모';
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
export function MemoLayoutPage({ layoutId }: MemoLayoutPageProps) {
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [notes, setNotes] = useState<MemoNote[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [body, setBody] = useState('');
const [isListOpen, setIsListOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
setIsLoading(true);
try {
const items = await fetchTextMemoNotes();
if (cancelled) {
return;
}
setNotes(items);
if (items[0]) {
setSelectedId(items[0].id);
setBody(items[0].body);
} else {
setSelectedId(null);
setBody('');
}
} catch (error) {
if (!cancelled) {
void messageApi.error(error instanceof Error ? error.message : '메모를 불러오지 못했습니다.');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [messageApi]);
const selectedIndex = useMemo(
() => (selectedId ? notes.findIndex((note) => note.id === selectedId) : -1),
[notes, selectedId],
);
const selectedNote = selectedIndex >= 0 ? notes[selectedIndex] : null;
const inputValue = getFirstLine(body);
const hasDraft = body.trim().length > 0;
const isDirty = selectedNote ? selectedNote.body !== body : hasDraft;
const selectNote = (noteId: string) => {
const nextNote = notes.find((item) => item.id === noteId);
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const moveSelection = (direction: -1 | 1) => {
if (notes.length === 0) {
return;
}
const fallbackIndex = direction > 0 ? 0 : notes.length - 1;
const nextIndex = selectedIndex < 0 ? fallbackIndex : (selectedIndex + direction + notes.length) % notes.length;
const nextNote = notes[nextIndex];
if (!nextNote) {
return;
}
setSelectedId(nextNote.id);
setBody(nextNote.body);
};
const handleCreate = () => {
setSelectedId(null);
setBody('');
setIsListOpen(false);
};
const handleSave = async () => {
const trimmedBody = body.trim();
if (!trimmedBody || isSaving) {
return;
}
setIsSaving(true);
try {
if (selectedNote) {
const updated = await updateTextMemoNote(selectedNote.id, { body: trimmedBody });
const nextNotes = [updated, ...notes.filter((note) => note.id !== updated.id)].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(updated.id);
setBody(updated.body);
} else {
const created = await createTextMemoNote({ body: trimmedBody });
const nextNotes = [created, ...notes].slice(0, MAX_NOTE_COUNT);
setNotes(nextNotes);
setSelectedId(created.id);
setBody(created.body);
}
void messageApi.success('저장됨');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '메모 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedNote && !hasDraft) {
return;
}
void modalApi.confirm({
title: selectedNote ? '선택한 메모를 삭제할까요?' : '작성 중인 메모를 삭제할까요?',
content: selectedNote ? '삭제 후 되돌릴 수 없습니다.' : '작성 중인 내용이 사라집니다.',
okText: '삭제',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
async onOk() {
if (!selectedNote) {
setBody('');
return;
}
await deleteTextMemoNote(selectedNote.id);
const nextNotes = notes.filter((note) => note.id !== selectedNote.id);
const fallbackNote = nextNotes[0] ?? null;
setNotes(nextNotes);
setSelectedId(fallbackNote?.id ?? null);
setBody(fallbackNote?.body ?? '');
void messageApi.success('삭제됨');
},
});
};
return (
<div className="memo-layout-page" data-layout-id={layoutId}>
{contextHolder}
{modalContextHolder}
<Splitter layout="vertical" className="memo-layout-page__splitter">
<Splitter.Panel size={PRIMARY_SIZE} min={PRIMARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--title">
<InputUI
value={inputValue}
placeholder="제목"
className="memo-layout-page__title-input"
onChange={(event) => {
const nextValue = event.target.value.slice(0, MAX_BODY_LENGTH);
setBody((previousBody) => replaceFirstLine(previousBody, nextValue));
}}
/>
</section>
</Splitter.Panel>
<Splitter.Panel min={SECONDARY_MIN} resizable>
<section className="memo-layout-page__pane memo-layout-page__pane--memo">
<div className="memo-layout-page__toolbar" role="toolbar" aria-label="메모 도구">
<div className="memo-layout-page__toolbar-group">
<Button type="text" aria-label="새 메모" icon={<PlusOutlined />} onClick={handleCreate} />
<Button
type={isListOpen ? 'default' : 'text'}
aria-label="메모 목록"
icon={<UnorderedListOutlined />}
onClick={() => {
setIsListOpen((previous) => !previous);
}}
/>
<Button
type="text"
aria-label="이전 메모"
icon={<LeftOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(-1);
}}
/>
<Button
type="text"
aria-label="다음 메모"
icon={<RightOutlined />}
disabled={notes.length === 0}
onClick={() => {
moveSelection(1);
}}
/>
</div>
<div className="memo-layout-page__toolbar-group">
<Button
type="text"
aria-label="삭제"
icon={<DeleteOutlined />}
disabled={!selectedNote && !hasDraft}
onClick={handleDelete}
/>
<Button
type="text"
aria-label="저장"
icon={isDirty ? <SaveOutlined /> : <CheckOutlined />}
disabled={!hasDraft || isSaving || !isDirty}
onClick={() => {
void handleSave();
}}
/>
</div>
</div>
<div className={`memo-layout-page__body${isListOpen ? ' memo-layout-page__body--list-open' : ''}`}>
{isListOpen ? (
<div className="memo-layout-page__list-shell">
{notes.length === 0 ? (
<div className="memo-layout-page__empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={false} />
</div>
) : (
<div className="memo-layout-page__list">
{notes.map((note) => (
<button
key={note.id}
type="button"
className={`memo-layout-page__list-item${
note.id === selectedId ? ' memo-layout-page__list-item--active' : ''
}`}
onClick={() => {
selectNote(note.id);
setIsListOpen(false);
}}
>
<span className="memo-layout-page__list-time">{formatMemoTimestamp(note.updatedAt)}</span>
<span className="memo-layout-page__list-preview">{getPreviewText(note.body)}</span>
</button>
))}
</div>
)}
</div>
) : null}
<div className="memo-layout-page__editor">
<div className="memo-layout-page__meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : ''}</span>
<span>{body.length}/{MAX_BODY_LENGTH}</span>
</div>
<Input.TextArea
value={body}
placeholder="메모 입력"
className="memo-layout-page__textarea"
autoSize={false}
disabled={isLoading}
maxLength={MAX_BODY_LENGTH}
onChange={(event) => {
setBody(event.target.value);
}}
/>
</div>
</div>
</section>
</Splitter.Panel>
</Splitter>
</div>
);
}

View File

@@ -0,0 +1 @@
export { MemoLayoutPage } from './MemoLayoutPage';

View File

@@ -0,0 +1,97 @@
.stock-alert-layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.stock-alert-layout__filter {
flex: 0 0 auto;
height: auto;
min-height: auto;
justify-content: center;
padding: 16px;
}
.stock-alert-layout__filter .ant-select {
width: 100%;
}
.stock-alert-layout__grid {
gap: 12px;
padding: 12px;
}
.stock-alert-layout__toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.stock-alert-layout__toolbar .ant-btn {
min-width: 40px;
}
.stock-alert-layout__surface {
flex: 1;
min-height: 0;
}
.stock-alert-layout__surface.ag-theme-quartz {
--ag-font-size: 13px;
--ag-border-color: #d9d9d9;
--ag-header-background-color: #fafafa;
--ag-row-border-color: #f0f0f0;
width: 100%;
height: 100%;
border: 1px solid #f0f0f0;
border-radius: 14px;
overflow: hidden;
}
.stock-alert-layout__search-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.stock-alert-layout__search-modal .ant-table-wrapper {
min-height: 0;
}
.stock-alert-layout__change-rate--up {
color: #cf1322;
font-weight: 600;
}
.stock-alert-layout__change-rate--down {
color: #0958d9;
font-weight: 600;
}
.stock-alert-layout__change-rate--flat {
color: #595959;
}
.stock-alert-layout__alert-type-editor {
display: flex;
align-items: center;
min-height: 100%;
width: 100%;
cursor: pointer;
}
.stock-alert-layout__alert-type-select {
width: 100%;
}
.stock-alert-layout__alert-type-select .ant-select-selector {
min-height: 32px;
border-radius: 8px;
}
.stock-alert-layout__alert-type-editor.is-open .stock-alert-layout__alert-type-select .ant-select-selector {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(5 145 255 / 0.12);
}

View File

@@ -0,0 +1,702 @@
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Flex, Input, Modal, Select, Table, message } from 'antd';
import type {
CellValueChangedEvent,
ColDef,
GridApi,
ICellRendererParams,
RowSelectionOptions,
ValueFormatterParams,
} from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import {
createContext,
useDeferredValue,
use,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
} from 'react';
import { renderModalWithEnterConfirm } from '../../../app/main/modalKeyboard';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import {
deleteStockAlertRow,
fetchStockAlerts,
searchStockAlertCandidates,
saveStockAlertRows,
type StockAlertDraftRow,
type StockAlertFilterValue,
type StockAlertSearchItem,
type StockAlertType,
} from './stockAlertApi';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
import './StockAlertLayout.css';
ModuleRegistry.registerModules([AllCommunityModule]);
const FILTER_OPTIONS: SelectOptionItem[] = [
{ code: 'all', value: '전체' },
{ code: 'price', value: '현재가' },
{ code: 'top3', value: '등락폭이 큰 상위3종목' },
];
const ALERT_TYPE_LABEL_MAP = new Map<StockAlertType, string>([
['price', '현재가'],
['top3', '등락폭이 큰 상위3종목'],
]);
const ALERT_TYPE_VALUES = Array.from(ALERT_TYPE_LABEL_MAP.keys());
type StockAlertLayoutContextValue = {
filterValue: StockAlertFilterValue;
rows: StockAlertDraftRow[];
isLoading: boolean;
pendingFocusRowId: string | null;
setFilterValue: (value: StockAlertFilterValue) => void;
updateRow: (rowId: string, patch: Partial<StockAlertDraftRow>) => void;
addRow: (item: StockAlertSearchItem) => boolean;
clearPendingFocusRowId: () => void;
refreshRows: () => Promise<void>;
saveRows: () => Promise<void>;
deleteRows: (rowIds: string[]) => Promise<void>;
};
const StockAlertLayoutContext = createContext<StockAlertLayoutContextValue | null>(null);
function useStockAlertLayoutContext() {
const context = use(StockAlertLayoutContext);
if (!context) {
throw new Error('StockAlertLayoutProvider가 필요합니다.');
}
return context;
}
function getAlertTypeLabel(value: StockAlertType) {
return ALERT_TYPE_LABEL_MAP.get(value) ?? value;
}
function toAlertTypeLabels(values: StockAlertType[]) {
return values.map((value) => getAlertTypeLabel(value));
}
function mergeDraftRows(previousRows: StockAlertDraftRow[], nextRows: StockAlertDraftRow[]) {
const dirtyRowMap = new Map(
previousRows
.filter((row) => row.isDirty)
.map((row) => [row.persistedId ?? row.id, row]),
);
return nextRows.map((row) => {
const dirtyRow = dirtyRowMap.get(row.persistedId ?? row.id);
if (!dirtyRow) {
return row;
}
return {
...row,
stockCode: dirtyRow.stockCode,
stockName: dirtyRow.stockName,
alertTypes: dirtyRow.alertTypes,
alertTypeLabels: toAlertTypeLabels(dirtyRow.alertTypes),
isDirty: true,
};
});
}
export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
const [messageApi, contextHolder] = message.useMessage();
const [filterValue, setFilterValue] = useState<StockAlertFilterValue>('all');
const [rows, setRows] = useState<StockAlertDraftRow[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pendingFocusRowId, setPendingFocusRowId] = useState<string | null>(null);
const refreshRows = async () => {
setIsLoading(true);
try {
const nextRows = await fetchStockAlerts(filterValue);
setRows((previousRows) => mergeDraftRows(previousRows, nextRows));
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 목록을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void refreshRows();
}, [filterValue]);
const updateRow = (rowId: string, patch: Partial<StockAlertDraftRow>) => {
setRows((previousRows) =>
previousRows.map((row) =>
row.id === rowId
? {
...row,
...patch,
alertTypes: (patch.alertTypes ?? row.alertTypes) as StockAlertType[],
alertTypeLabels: toAlertTypeLabels((patch.alertTypes ?? row.alertTypes) as StockAlertType[]),
isDirty: true,
}
: row,
),
);
};
const addRow = (item: StockAlertSearchItem) => {
const nextAlertTypes: StockAlertType[] = [filterValue === 'all' ? 'price' : filterValue];
if (rows.some((row) => row.stockCode === item.stockCode)) {
messageApi.warning('이미 추가된 종목입니다.');
return false;
}
const nextRowId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
setRows((previousRows) => [
{
id: nextRowId,
persistedId: null,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: nextAlertTypes,
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
},
...previousRows,
]);
setPendingFocusRowId(nextRowId);
return true;
};
const saveRows = async () => {
const dirtyRows = rows.filter((row) => row.isDirty);
if (!dirtyRows.length) {
return;
}
if (dirtyRows.some((row) => !row.stockName.trim())) {
messageApi.error('종목명을 입력한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.stockCode.trim())) {
messageApi.error('종목 검색으로 추가한 뒤 저장해 주세요.');
return;
}
if (dirtyRows.some((row) => !row.alertTypes.length)) {
messageApi.error('알림유형을 하나 이상 선택해 주세요.');
return;
}
setIsLoading(true);
try {
await saveStockAlertRows(dirtyRows);
await refreshRows();
messageApi.success('종목 알림을 저장했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 저장에 실패했습니다.');
setIsLoading(false);
}
};
const deleteRows = async (rowIds: string[]) => {
if (!rowIds.length) {
return;
}
setIsLoading(true);
try {
const persistedIds = rowIds
.map((rowId) => rows.find((row) => row.id === rowId)?.persistedId ?? null)
.filter((value): value is string => Boolean(value));
await Promise.all(persistedIds.map((id) => deleteStockAlertRow(id)));
setRows((previousRows) => previousRows.filter((row) => !rowIds.includes(row.id)));
await refreshRows();
messageApi.success('선택한 종목 알림을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 알림 삭제에 실패했습니다.');
setIsLoading(false);
}
};
const contextValue = useMemo<StockAlertLayoutContextValue>(
() => ({
filterValue,
rows,
isLoading,
pendingFocusRowId,
setFilterValue,
updateRow,
addRow,
clearPendingFocusRowId: () => {
setPendingFocusRowId(null);
},
refreshRows,
saveRows,
deleteRows,
}),
[filterValue, isLoading, pendingFocusRowId, rows],
);
return (
<StockAlertLayoutContext.Provider value={contextValue}>
{contextHolder}
{children}
</StockAlertLayoutContext.Provider>
);
}
export function StockAlertFilterPane() {
const { filterValue, setFilterValue } = useStockAlertLayoutContext();
return (
<Flex className="stock-alert-layout stock-alert-layout__filter">
<SelectUI
data={FILTER_OPTIONS}
value={filterValue}
allowClear={false}
placeholder="알림유형"
onChange={(nextCode) => {
setFilterValue((nextCode as StockAlertFilterValue | undefined) ?? 'all');
}}
/>
</Flex>
);
}
function formatPrice(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return new Intl.NumberFormat('ko-KR').format(value);
}
function formatChangeRate(value: number | null) {
if (typeof value !== 'number' || Number.isNaN(value)) {
return '';
}
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
}
function formatQuotedAt(value: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
function ChangeRateCellRenderer({ value }: ICellRendererParams<StockAlertDraftRow, number | null>) {
const numericValue = typeof value === 'number' ? value : null;
const className =
numericValue === null
? 'stock-alert-layout__change-rate--flat'
: numericValue > 0
? 'stock-alert-layout__change-rate--up'
: numericValue < 0
? 'stock-alert-layout__change-rate--down'
: 'stock-alert-layout__change-rate--flat';
return <span className={className}>{formatChangeRate(numericValue)}</span>;
}
type AlertTypeCellRendererProps = ICellRendererParams<StockAlertDraftRow> & {
isOpen?: boolean;
onOpen?: (rowId: string) => void;
onClose?: () => void;
};
function AlertTypeCellEditorRenderer({ data, isOpen = false, onOpen, onClose }: AlertTypeCellRendererProps) {
const { updateRow } = useStockAlertLayoutContext();
if (!data) {
return null;
}
return (
<div
className={`stock-alert-layout__alert-type-editor${isOpen ? ' is-open' : ''}`}
onClick={(event) => {
event.stopPropagation();
onOpen?.(data.id);
}}
>
<Select<StockAlertType[]>
mode="multiple"
size="small"
open={isOpen}
autoFocus={isOpen}
value={data.alertTypes}
options={ALERT_TYPE_VALUES.map((value) => ({
value,
label: getAlertTypeLabel(value),
}))}
placeholder="알림유형 선택"
maxTagCount="responsive"
allowClear={false}
popupMatchSelectWidth={false}
className="stock-alert-layout__alert-type-select"
onChange={(nextValues) => {
updateRow(data.id, {
alertTypes: nextValues as StockAlertType[],
});
}}
onOpenChange={(nextOpen) => {
if (nextOpen) {
onOpen?.(data.id);
return;
}
onClose?.();
}}
/>
</div>
);
}
function StockSearchModal({
open,
onCancel,
onSelect,
}: {
open: boolean;
onCancel: () => void;
onSelect: (item: StockAlertSearchItem) => void;
}) {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
const [items, setItems] = useState<StockAlertSearchItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [messageApi, contextHolder] = message.useMessage();
const search = async (rawValue: string) => {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
setItems([]);
return;
}
setIsLoading(true);
try {
const nextItems = await searchStockAlertCandidates(trimmedValue, 20);
setItems(nextItems);
if (!nextItems.length) {
messageApi.info('조회 결과가 없습니다.');
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '종목 검색에 실패했습니다.');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setIsLoading(false);
}
}, [open]);
return (
<>
{contextHolder}
<Modal
open={open}
title="종목 검색"
onCancel={onCancel}
footer={null}
width={720}
destroyOnHidden
modalRender={renderModalWithEnterConfirm}
>
<div className="stock-alert-layout__search-modal">
<Input.Search
value={keyword}
placeholder="종목명 또는 종목코드"
enterButton="조회"
allowClear
onChange={(event) => {
setKeyword(event.target.value);
}}
onSearch={(value) => {
void search(value);
}}
/>
<Table<StockAlertSearchItem>
size="small"
rowKey={(record) => record.stockCode}
loading={isLoading}
pagination={false}
dataSource={items}
scroll={{ y: 360 }}
locale={{
emptyText: deferredKeyword.trim() ? '조회 결과가 없습니다.' : '종목명 또는 종목코드를 입력하세요.',
}}
onRow={(record) => ({
onDoubleClick: () => {
onSelect(record);
},
})}
columns={[
{
title: '종목코드',
dataIndex: 'stockCode',
width: 140,
},
{
title: '종목명',
dataIndex: 'stockName',
},
{
title: '시장구분',
dataIndex: 'market',
width: 140,
},
{
key: 'action',
width: 88,
render: (_, record) => (
<Button
type="link"
onClick={() => {
onSelect(record);
}}
>
</Button>
),
},
]}
/>
</div>
</Modal>
</>
);
}
export function StockAlertGridPane() {
const {
rows,
isLoading,
pendingFocusRowId,
updateRow,
addRow,
clearPendingFocusRowId,
refreshRows,
saveRows,
deleteRows,
} = useStockAlertLayoutContext();
const [selectedRowIds, setSelectedRowIds] = useState<string[]>([]);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [activeAlertTypeEditorRowId, setActiveAlertTypeEditorRowId] = useState<string | null>(null);
const gridApiRef = useRef<GridApi<StockAlertDraftRow> | null>(null);
const rowSelection = useMemo<RowSelectionOptions>(
() => ({
mode: 'multiRow',
enableClickSelection: true,
checkboxes: true,
headerCheckbox: true,
}),
[],
);
const columnDefs = useMemo<ColDef<StockAlertDraftRow>[]>(
() => [
{
field: 'stockName',
headerName: '종목명',
editable: false,
minWidth: 170,
flex: 1.3,
},
{
field: 'changeRate',
headerName: '등락률',
editable: false,
minWidth: 130,
flex: 0.9,
cellRenderer: ChangeRateCellRenderer,
},
{
field: 'currentPrice',
headerName: '현재가',
editable: false,
minWidth: 120,
flex: 0.9,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
},
{
field: 'quotedAt',
headerName: '기준일시',
editable: false,
minWidth: 190,
flex: 1.2,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
},
{
field: 'alertTypes',
headerName: '알림유형',
editable: false,
minWidth: 220,
flex: 1.1,
cellRenderer: AlertTypeCellEditorRenderer,
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
isOpen: params.data?.id === activeAlertTypeEditorRowId,
onOpen: (rowId: string) => {
setActiveAlertTypeEditorRowId(rowId);
},
onClose: () => {
setActiveAlertTypeEditorRowId((currentValue) => (currentValue === params.data?.id ? null : currentValue));
},
}),
sortable: false,
filter: false,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, StockAlertType[] | null>) =>
Array.isArray(params.value) ? toAlertTypeLabels(params.value).join(', ') : '',
},
{
field: 'stockCode',
headerName: '종목코드',
hide: true,
},
],
[activeAlertTypeEditorRowId],
);
const defaultColDef = useMemo<ColDef<StockAlertDraftRow>>(
() => ({
sortable: true,
filter: true,
resizable: true,
}),
[],
);
const handleCellValueChanged = (event: CellValueChangedEvent<StockAlertDraftRow>) => {
if (!event.data) {
return;
}
if (event.colDef.field === 'stockName') {
updateRow(event.data.id, {
stockName: String(event.newValue ?? ''),
});
}
};
useEffect(() => {
if (!pendingFocusRowId || !gridApiRef.current) {
return;
}
const rowIndex = rows.findIndex((row) => row.id === pendingFocusRowId);
if (rowIndex < 0) {
return;
}
gridApiRef.current.ensureIndexVisible(rowIndex, 'top');
gridApiRef.current.setFocusedCell(rowIndex, 'alertTypes');
setActiveAlertTypeEditorRowId(pendingFocusRowId);
clearPendingFocusRowId();
}, [clearPendingFocusRowId, pendingFocusRowId, rows]);
return (
<>
<StockSearchModal
open={isSearchModalOpen}
onCancel={() => {
setIsSearchModalOpen(false);
}}
onSelect={(item) => {
const added = addRow(item);
if (added) {
setIsSearchModalOpen(false);
}
}}
/>
<div className="stock-alert-layout stock-alert-layout__grid">
<div className="stock-alert-layout__toolbar">
<Button
icon={<PlusOutlined />}
onClick={() => {
setIsSearchModalOpen(true);
}}
/>
<Button icon={<DeleteOutlined />} danger disabled={!selectedRowIds.length} onClick={() => void deleteRows(selectedRowIds)} />
<Button icon={<SaveOutlined />} type="primary" onClick={() => void saveRows()} />
<Button icon={<ReloadOutlined />} onClick={() => void refreshRows()} />
</div>
<div className="stock-alert-layout__surface ag-theme-quartz">
<AgGridReact<StockAlertDraftRow>
loading={isLoading}
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowSelection={rowSelection}
suppressRowClickSelection={false}
getRowId={(params) => params.data.id}
onGridReady={(event) => {
gridApiRef.current = event.api;
}}
onCellValueChanged={handleCellValueChanged}
onCellClicked={(event) => {
const rowId = event.data?.id ?? null;
if (event.colDef.field === 'alertTypes' && rowId) {
setActiveAlertTypeEditorRowId(rowId);
return;
}
setActiveAlertTypeEditorRowId(null);
}}
onSelectionChanged={(event) => {
setSelectedRowIds(event.api.getSelectedRows().map((row) => row.id));
}}
/>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1 @@
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';

View File

@@ -0,0 +1,171 @@
import { appendClientIdHeader } from '../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../app/main/tokenAccess';
export type StockAlertFilterValue = 'all' | 'price' | 'top3';
export type StockAlertType = Exclude<StockAlertFilterValue, 'all'>;
export type StockAlertItem = {
id: string;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type StockAlertDraftRow = {
id: string;
persistedId: string | null;
stockCode: string;
stockName: string;
alertTypes: StockAlertType[];
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
quotedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
isDirty: boolean;
isNew: boolean;
};
export type StockAlertSearchItem = {
stockCode: string;
stockName: string;
market: string;
};
const WORK_SERVER_TIMEOUT_MS = 10000;
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
return {
id: item.id,
persistedId: item.id,
stockCode: item.stockCode,
stockName: item.stockName,
alertTypes: item.alertTypes,
alertTypeLabels: item.alertTypeLabels,
currentPrice: item.currentPrice,
changeRate: item.changeRate,
quotedAt: item.quotedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
isDirty: false,
isNew: false,
};
}
export function createEmptyStockAlertRow(): StockAlertDraftRow {
const localId = `stock-alert-draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id: localId,
persistedId: null,
stockCode: '',
stockName: '',
alertTypes: ['price'],
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
isDirty: true,
isNew: true,
};
}
async function request<T>(path: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new Error(payload.message || '종목 알림 요청에 실패했습니다.');
} catch {
throw new Error(text || '종목 알림 요청에 실패했습니다.');
}
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function fetchStockAlerts(filterValue: StockAlertFilterValue) {
const searchParams = new URLSearchParams();
if (filterValue !== 'all') {
searchParams.set('alertType', filterValue);
}
const path = `/stock-alerts${searchParams.size ? `?${searchParams.toString()}` : ''}`;
const response = await request<{ items: StockAlertItem[] }>(path);
return response.items.map(toDraftRow);
}
export async function searchStockAlertCandidates(query: string, limit = 20) {
const searchParams = new URLSearchParams({
query: query.trim(),
limit: String(limit),
});
const response = await request<{ items: StockAlertSearchItem[] }>(`/stock-alerts/search?${searchParams.toString()}`);
return response.items;
}
export async function saveStockAlertRows(rows: StockAlertDraftRow[]) {
const payload = rows.map((row) => ({
id: row.persistedId ?? undefined,
stockCode: row.stockCode,
stockName: row.stockName,
alertTypes: row.alertTypes,
}));
const response = await request<{ items: StockAlertItem[] }>('/stock-alerts/batch', {
method: 'PUT',
body: JSON.stringify({ items: payload }),
});
return response.items.map(toDraftRow);
}
export async function deleteStockAlertRow(id: string) {
await request<{ ok: boolean; id: string }>(`/stock-alerts/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}

View File

@@ -30,12 +30,19 @@ import {
message,
} from 'antd';
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAppConfig, type AppConfig } from '../../app/main/appConfig';
import {
buildAutomationTypeOptions,
resolveAutomationTypeLabel,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import {
buildAutomationContextOptions,
resolveDefaultAutomationContextIds,
useAutomationContextRegistry,
} from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes';
import { uploadChatComposerFile } from '../../app/main/mainChatPanel';
import { normalizeChatResourceUrl } from '../../app/main/mainChatPanel/chatResourceUrl';
import type { ChatComposerAttachment } from '../../app/main/mainChatPanel/types';
@@ -607,9 +614,11 @@ function createEmptyDraft(appConfig: AppConfig): PlanDraft {
workId: '',
note: '',
automationType: 'none',
automationContextIds: [],
status: '등록',
jangsingProcessingRequired: appConfig.planDefaults.jangsingProcessingRequired,
autoDeployToMain: appConfig.planDefaults.autoDeployToMain,
suppressWebPush: false,
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
};
@@ -630,8 +639,10 @@ export function PlanBoardPage({
initialSelectedPlanId = null,
initialSelectedWorkId = null,
}: PlanBoardPageProps) {
const navigate = useNavigate();
const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry();
const appConfig = useAppConfig();
const autoRefreshIntervalMs = appConfig.automation.autoRefreshIntervalSeconds * 1000;
const [messageApi, contextHolder] = message.useMessage();
@@ -1186,7 +1197,10 @@ export function PlanBoardPage({
}
noteAttachmentSessionIdRef.current = createPlanNoteAttachmentSessionId();
setDraft(createEmptyDraft(appConfig));
setDraft({
...createEmptyDraft(appConfig),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setResolveLatestIssue(false);
setRetryLatestIssue(true);
setEditorOpen(true);
@@ -1598,6 +1612,10 @@ export function PlanBoardPage({
() => buildAutomationTypeOptions(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
);
const automationContextOptions = useMemo(
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
[automationContexts, draft.automationContextIds],
);
const automationTypeLabel = useMemo(
() => resolveAutomationTypeLabel(automationTypes, draft.automationType),
[automationTypes, draft.automationType],
@@ -2102,6 +2120,42 @@ export function PlanBoardPage({
)}
</div>
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong>Context</Text>
<Space size={8} wrap>
<Text type="secondary"> , </Text>
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
Context
</Button>
</Space>
</Flex>
{requestReceived ? (
<div className="plan-board-page__readonly-field" aria-readonly="true">
<Text>{draft.automationContextIds.length ? `${draft.automationContextIds.length}개 선택` : '선택 안함'}</Text>
<Tag color="processing"> </Tag>
</div>
) : (
<Select
mode="multiple"
allowClear
className="plan-board-page__select"
value={draft.automationContextIds}
options={automationContextOptions}
popupClassName="plan-board-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
placeholder="선택된 Context 없음"
onChange={(automationContextIds) => {
updateDraft((previous) => ({
...previous,
automationContextIds,
}));
}}
/>
)}
</div>
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong></Text>
@@ -2201,6 +2255,25 @@ export function PlanBoardPage({
) : null}
</div>
<div>
<Checkbox
checked={draft.suppressWebPush ?? false}
disabled={isRequestLocked}
onChange={(event) => {
const suppressWebPush = event.target.checked;
updateDraft((previous) => ({
...previous,
suppressWebPush,
}));
}}
>
</Checkbox>
<div style={{ marginTop: 8 }}>
<Text type="secondary"> .</Text>
</div>
</div>
<div>
<Text strong> / </Text>
<Space direction="vertical" size={4} style={{ display: 'flex' }}>
@@ -3248,9 +3321,11 @@ function toDraft(item: PlanItem): PlanDraft {
workId: item.workId,
note: item.note,
automationType: item.automationType,
automationContextIds: item.automationContextIds ?? [],
status: item.status,
jangsingProcessingRequired: item.jangsingProcessingRequired,
autoDeployToMain: item.autoDeployToMain,
suppressWebPush: item.suppressWebPush,
repeatRequestEnabled: item.repeatRequestEnabled,
repeatIntervalMinutes: item.repeatIntervalMinutes,
};
@@ -3949,6 +4024,9 @@ function summarizeAutomationUsageSnapshotTokens(snapshot: PlanAutomationUsageSna
const validEntries = entries.filter(([value]) => Number.isFinite(value) && value > 0);
if (validEntries.length === 0) {
if (Number(snapshot.sourceWorkCount ?? 0) > 0) {
return '총 0';
}
return null;
}

View File

@@ -18,10 +18,17 @@ import {
message,
} from 'antd';
import { memo, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import { useNavigate } from 'react-router-dom';
import {
buildAutomationTypeOptions,
useAutomationTypeRegistry,
} from '../../app/main/automationTypeAccess';
import {
buildAutomationContextOptions,
resolveDefaultAutomationContextIds,
useAutomationContextRegistry,
} from '../../app/main/automationContextAccess';
import { buildPlansPath } from '../../app/main/routes';
import { useTokenAccess } from '../../app/main/tokenAccess';
import './planBoard.css';
import './planSchedule.css';
@@ -32,21 +39,28 @@ import {
deletePlanScheduledTask,
fetchPlanScheduledTasks,
setupPlanBoard,
type PlanScheduleExecutionMode,
updatePlanScheduledTask,
type PlanScheduleMode,
type PlanScheduleRepeatUnit,
type PlanScheduledTask,
type PlanScheduledTaskDraft,
type PlanScheduledTaskSaveResult,
} from './api';
const { Paragraph, Text, Title } = Typography;
const { TextArea } = Input;
const FUNCTION_CHECK_OPTIONS = ['완료', '오동작'];
const EXECUTION_MODE_OPTIONS: { label: string; value: PlanScheduleExecutionMode }[] = [
{ label: 'Codex 직접 처리', value: 'codex' },
{ label: '별도 서비스 관리', value: 'managed-service' },
];
const SCHEDULE_MODE_TAB_ITEMS: { key: PlanScheduleMode; label: string }[] = [
{ key: 'interval', label: '반복 주기' },
{ key: 'daily', label: '매일 시간' },
];
const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] = [
{ label: '초', value: 'second' },
{ label: '분', value: 'minute' },
{ label: '시간', value: 'hour' },
{ label: '일', value: 'day' },
@@ -54,6 +68,7 @@ const REPEAT_UNIT_OPTIONS: { label: string; value: PlanScheduleRepeatUnit }[] =
{ label: '월', value: 'month' },
];
const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
second: '초',
minute: '분',
hour: '시간',
day: '일',
@@ -61,7 +76,9 @@ const REPEAT_UNIT_LABELS: Record<PlanScheduleRepeatUnit, string> = {
month: '개월',
};
const REPEAT_PRESET_OPTIONS: { label: string; value: number; unit: PlanScheduleRepeatUnit }[] = [
{ label: '10', value: 10, unit: 'minute' },
{ label: '10', value: 10, unit: 'second' },
{ label: '30초', value: 30, unit: 'second' },
{ label: '1분', value: 1, unit: 'minute' },
{ label: '30분', value: 30, unit: 'minute' },
{ label: '1시간', value: 1, unit: 'hour' },
{ label: '6시간', value: 6, unit: 'hour' },
@@ -80,29 +97,42 @@ const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, minute) => ({
const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000;
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
function getRepeatIntervalSeconds(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
if (unit === 'second') {
return normalizedValue;
}
if (unit === 'day') {
return normalizedValue * 24 * 60;
return normalizedValue * 24 * 60 * 60;
}
if (unit === 'week') {
return normalizedValue * 7 * 24 * 60;
return normalizedValue * 7 * 24 * 60 * 60;
}
if (unit === 'month') {
return normalizedValue * 30 * 24 * 60;
return normalizedValue * 30 * 24 * 60 * 60;
}
if (unit === 'hour') {
return normalizedValue * 60;
return normalizedValue * 60 * 60;
}
return normalizedValue;
return normalizedValue * 60;
}
function getRepeatIntervalMinutes(value: number, unit: PlanScheduleRepeatUnit) {
return Math.max(1, Math.ceil(getRepeatIntervalSeconds(value, unit) / 60));
}
function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
if (unit === 'second') {
return 86400;
}
if (unit === 'month') {
return 12;
}
@@ -122,14 +152,27 @@ function getRepeatIntervalValueMax(unit: PlanScheduleRepeatUnit) {
return 525600;
}
function buildScheduleSaveMessage(
isUpdate: boolean,
saveResult: PlanScheduledTaskSaveResult,
) {
const baseMessage = isUpdate ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.';
if (!saveResult.registeredPlan) {
return baseMessage;
}
return `${baseMessage} 자동화도 바로 접수되어 Plan #${saveResult.registeredPlan.id}가 생성됐습니다.`;
}
function normalizeRepeatIntervalValue(value: number, unit: PlanScheduleRepeatUnit) {
const roundedValue = Math.max(1, Math.round(Number(value) || 1));
return Math.min(getRepeatIntervalValueMax(unit), roundedValue);
}
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackMinutes: number) {
function formatRepeatInterval(value: number, unit: PlanScheduleRepeatUnit, fallbackSeconds: number) {
if (!value || !unit) {
return `${fallbackMinutes}마다`;
return `${fallbackSeconds}마다`;
}
return `${value}${REPEAT_UNIT_LABELS[unit]}마다`;
@@ -140,7 +183,7 @@ function normalizeScheduleMode(value: PlanScheduleMode | null | undefined): Plan
}
function normalizeDailyRunTime(value: string | null | undefined) {
return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : DEFAULT_DAILY_RUN_TIME;
}
function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValue: string) {
@@ -148,6 +191,42 @@ function updateDailyRunTime(value: string, part: 'hour' | 'minute', nextPartValu
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
}
function normalizeOptionalTimeOfDay(value: string | null | undefined) {
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null;
}
function updateOptionalTimeOfDay(
value: string | null | undefined,
part: 'hour' | 'minute',
nextPartValue: string | undefined,
) {
if (nextPartValue === undefined) {
return null;
}
const [hour, minute] = (normalizeOptionalTimeOfDay(value) ?? '00:00').split(':');
return part === 'hour' ? `${nextPartValue}:${minute}` : `${hour}:${nextPartValue}`;
}
function formatRepeatWindowLabel(startTime: string | null | undefined, endTime: string | null | undefined) {
const normalizedStartTime = normalizeOptionalTimeOfDay(startTime);
const normalizedEndTime = normalizeOptionalTimeOfDay(endTime);
if (!normalizedStartTime && !normalizedEndTime) {
return '시간 제한 없음';
}
if (normalizedStartTime && normalizedEndTime) {
return `${normalizedStartTime}~${normalizedEndTime}`;
}
if (normalizedStartTime) {
return `${normalizedStartTime} 이후`;
}
return `${normalizedEndTime} 이전`;
}
function formatScheduleCycle(item: PlanScheduledTask) {
const scheduleMode = normalizeScheduleMode(item.scheduleMode);
@@ -155,7 +234,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
}
return formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalMinutes);
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${formatRepeatWindowLabel(item.repeatWindowStartTime, item.repeatWindowEndTime)}`;
}
function getValidDate(value: string | null | undefined) {
@@ -227,7 +306,7 @@ function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date())
}
const baseAt = lastRegisteredAt ?? getValidDate(item.createdAt) ?? now;
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalMinutes * 60 * 1000);
const nextRunAt = new Date(baseAt.getTime() + item.repeatIntervalSeconds * 1000);
return nextRunAt.getTime() <= now.getTime() ? now : nextRunAt;
}
@@ -238,16 +317,24 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
workId: '',
note: '',
automationType: 'none',
automationContextIds: [],
releaseTarget: defaultReleaseTarget,
jangsingProcessingRequired: true,
autoDeployToMain: true,
suppressWebPush: false,
enabled: true,
immediateRunEnabled: true,
refreshContextSnapshotOnNextRun: false,
executionMode: 'codex',
recreateManagedServiceOnNextSave: false,
scheduleMode: 'interval',
repeatIntervalValue: 60,
repeatIntervalUnit: 'minute',
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
repeatIntervalMinutes: 60,
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
repeatWindowStartTime: null,
repeatWindowEndTime: null,
};
}
@@ -263,16 +350,24 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
workId: item.workId,
note: item.note,
automationType: item.automationType,
automationContextIds: item.automationContextIds ?? [],
releaseTarget: item.releaseTarget,
jangsingProcessingRequired: item.jangsingProcessingRequired,
autoDeployToMain: item.autoDeployToMain,
suppressWebPush: item.suppressWebPush,
enabled: item.enabled,
immediateRunEnabled: item.immediateRunEnabled,
refreshContextSnapshotOnNextRun: item.refreshContextSnapshotOnNextRun,
executionMode: item.executionMode ?? 'codex',
recreateManagedServiceOnNextSave: item.recreateManagedServiceOnNextSave ?? false,
scheduleMode: normalizeScheduleMode(item.scheduleMode),
repeatIntervalValue,
repeatIntervalUnit,
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
};
}
@@ -309,10 +404,6 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
messages.push('반복 등록할 작업 메모를 입력하세요.');
}
if (draft.scheduleMode === 'interval' && getRepeatIntervalMinutes(draft.repeatIntervalValue, draft.repeatIntervalUnit) < 10) {
messages.push('반복 주기는 최소 10분 이상으로 설정하세요.');
}
if (!draft.enabled) {
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
}
@@ -323,6 +414,7 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
export function PlanSchedulePage() {
const { hasAccess } = useTokenAccess();
const { automationTypes } = useAutomationTypeRegistry();
const { automationContexts } = useAutomationContextRegistry();
const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<PlanScheduledTask[]>([]);
const [draft, setDraft] = useState(() => createEmptyScheduleDraft());
@@ -335,15 +427,22 @@ export function PlanSchedulePage() {
[draft.id, items],
);
const validationMessages = useMemo(() => validateScheduleDraft(draft, items), [draft, items]);
const automationContextOptions = useMemo(
() => buildAutomationContextOptions(automationContexts, draft.automationContextIds),
[automationContexts, draft.automationContextIds],
);
async function loadItems() {
setLoading(true);
setErrorMessage(null);
try {
setItems(await fetchPlanScheduledTasks());
const nextItems = await fetchPlanScheduledTasks();
setItems(nextItems);
return nextItems;
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '스케줄 목록을 불러오지 못했습니다.');
return [];
} finally {
setLoading(false);
}
@@ -388,12 +487,20 @@ export function PlanSchedulePage() {
const draftToSave = {
...draft,
repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, draft.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
repeatWindowStartTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowStartTime) : null,
repeatWindowEndTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowEndTime) : null,
};
const savedItem = draft.id ? await updatePlanScheduledTask(draftToSave) : await createPlanScheduledTask(draftToSave);
messageApi.success(draft.id ? '스케줄을 수정했습니다.' : '스케줄을 등록했습니다.');
setDraft(toDraft(savedItem));
await loadItems();
const saveResult = draft.id
? await updatePlanScheduledTask(draftToSave)
: await createPlanScheduledTask(draftToSave);
const nextItems = await loadItems();
const latestSavedItem = nextItems.find((item) => item.id === saveResult.item.id) ?? saveResult.item;
messageApi.success(buildScheduleSaveMessage(Boolean(draft.id), saveResult));
setDraft(toDraft(latestSavedItem));
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '스케줄 저장에 실패했습니다.');
} finally {
@@ -420,7 +527,10 @@ export function PlanSchedulePage() {
try {
await deletePlanScheduledTask(draft.id);
messageApi.success('스케줄을 삭제했습니다.');
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
setDraft({
...createEmptyScheduleDraft(draft.releaseTarget),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setEditorOpen(false);
await loadItems();
} catch (error) {
@@ -440,7 +550,10 @@ export function PlanSchedulePage() {
}
function handleCreateNew() {
setDraft(createEmptyScheduleDraft(draft.releaseTarget));
setDraft({
...createEmptyScheduleDraft(draft.releaseTarget),
automationContextIds: resolveDefaultAutomationContextIds(automationContexts),
});
setEditorOpen(true);
}
@@ -533,6 +646,7 @@ export function PlanSchedulePage() {
detailContent={
<PlanScheduleDetail
automationTypeOptions={buildAutomationTypeOptions(automationTypes, draft.automationType)}
automationContextOptions={automationContextOptions}
draft={draft}
hasAccess={hasAccess}
selectedItem={selectedItem}
@@ -584,10 +698,18 @@ const PlanScheduleList = memo(function PlanScheduleList({
{item.note ? (hasAccess ? item.note : maskNotePreviewByWord(item.note)) : '작업 내용이 없습니다.'}
</Paragraph>
<Space wrap size={8}>
<Tag color={item.executionMode === 'managed-service' ? 'geekblue' : 'default'}>
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
</Tag>
<Tag>{formatScheduleCycle(item)}</Tag>
<Tag color="blue"> {formatNextPlanScheduleRunAt(item)}</Tag>
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
{item.refreshContextSnapshotOnNextRun ? <Tag color="purple"> </Tag> : null}
{item.executionMode === 'managed-service' && item.managedServiceKey ? (
<Tag color="cyan">{item.managedServiceKey}</Tag>
) : null}
<Tag>{item.autoDeployToMain ? 'main 자동등록' : 'release만'}</Tag>
{item.suppressWebPush ? <Tag color="gold"> </Tag> : null}
<Tag> {item.jangsingProcessingRequired ? '완료' : '오동작'}</Tag>
</Space>
<Flex justify="space-between" align="center" gap={8} wrap style={{ marginTop: 10 }}>
@@ -603,6 +725,7 @@ const PlanScheduleList = memo(function PlanScheduleList({
function PlanScheduleDetail({
automationTypeOptions,
automationContextOptions,
draft,
hasAccess,
selectedItem,
@@ -611,6 +734,7 @@ function PlanScheduleDetail({
onCopyText,
}: {
automationTypeOptions: Array<{ label: string; value: string }>;
automationContextOptions: Array<{ label: string; value: string }>;
draft: PlanScheduledTaskDraft;
hasAccess: boolean;
selectedItem: PlanScheduledTask | null;
@@ -618,6 +742,8 @@ function PlanScheduleDetail({
onChangeDraft: Dispatch<SetStateAction<PlanScheduledTaskDraft>>;
onCopyText: (text: string) => Promise<void>;
}) {
const navigate = useNavigate();
return (
<div className="plan-schedule-page__detail">
{selectedItem ? (
@@ -629,7 +755,15 @@ function PlanScheduleDetail({
description={
<Space direction="vertical" size={4}>
<Text> : {formatNextPlanScheduleRunAt(selectedItem)}</Text>
<Text> : {selectedItem.refreshContextSnapshotOnNextRun ? '다음 실행 1회' : '없음'}</Text>
<Text> : {selectedItem.executionMode === 'managed-service' ? '별도 서비스 관리형' : 'Codex 직접 처리'}</Text>
{selectedItem.executionMode === 'managed-service' ? (
<Text> : {selectedItem.managedServiceKey ?? `schedule-${selectedItem.id}`}</Text>
) : null}
<Text> : {formatPlanScheduleDateTime(selectedItem.lastRegisteredAt)}</Text>
{selectedItem.executionMode === 'managed-service' ? (
<Text> : {selectedItem.managedServiceGeneratedAt ? formatPlanScheduleDateTime(selectedItem.managedServiceGeneratedAt) : '미생성'}</Text>
) : null}
<Text>: {formatPlanScheduleDateTime(selectedItem.createdAt)}</Text>
<Text>: {formatPlanScheduleDateTime(selectedItem.updatedAt)}</Text>
</Space>
@@ -704,7 +838,58 @@ function PlanScheduleDetail({
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(automationType) => onChangeDraft((previous) => ({ ...previous, automationType }))}
onChange={(automationType) =>
onChangeDraft((previous) => ({
...previous,
automationType,
}))
}
/>
</div>
<div>
<Text strong> </Text>
<Segmented
options={EXECUTION_MODE_OPTIONS}
value={draft.executionMode}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
executionMode: value as PlanScheduleExecutionMode,
recreateManagedServiceOnNextSave:
value === 'managed-service' ? previous.recreateManagedServiceOnNextSave : false,
}))
}
/>
<div style={{ marginTop: 8 }}>
<Text type="secondary">
{draft.executionMode === 'managed-service'
? `스케줄 PK를 포함한 고정 패키지 경로로 별도 서비스 번들을 관리합니다.`
: '현재처럼 Codex 기반 자동화 메모 등록 흐름으로 처리합니다.'}
</Text>
</div>
</div>
<div>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong>Context</Text>
<Space size={8} wrap>
<Text type="secondary"> , </Text>
<Button size="small" onClick={() => navigate(buildPlansPath('automation-context'))}>
Context
</Button>
</Space>
</Flex>
<Select
mode="multiple"
allowClear
className="plan-schedule-page__select"
value={draft.automationContextIds}
options={automationContextOptions}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
placeholder="선택된 Context 없음"
onChange={(automationContextIds) => onChangeDraft((previous) => ({ ...previous, automationContextIds }))}
/>
</div>
<div>
@@ -743,6 +928,7 @@ function PlanScheduleDetail({
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
@@ -760,6 +946,7 @@ function PlanScheduleDetail({
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
@@ -782,6 +969,7 @@ function PlanScheduleDetail({
onChangeDraft((previous) => ({
...previous,
repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, previous.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, previous.repeatIntervalUnit),
}));
}}
@@ -798,6 +986,10 @@ function PlanScheduleDetail({
...previous,
repeatIntervalValue: normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
repeatIntervalUnit: value,
repeatIntervalSeconds: getRepeatIntervalSeconds(
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
value,
),
repeatIntervalMinutes: getRepeatIntervalMinutes(
normalizeRepeatIntervalValue(previous.repeatIntervalValue, value),
value,
@@ -819,6 +1011,7 @@ function PlanScheduleDetail({
scheduleMode: option.unit === 'day' && option.value === 1 ? 'daily' : 'interval',
repeatIntervalValue: option.value,
repeatIntervalUnit: option.unit,
repeatIntervalSeconds: getRepeatIntervalSeconds(option.value, option.unit),
repeatIntervalMinutes: getRepeatIntervalMinutes(option.value, option.unit),
}))
}
@@ -827,6 +1020,77 @@ function PlanScheduleDetail({
</Button>
))}
</Space>
<Space align="center" wrap style={{ marginTop: 12 }}>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'hour', value),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowStartTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'minute', value),
}))
}
/>
<Text type="secondary"> </Text>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'hour', value),
}))
}
/>
<Select
allowClear
placeholder="제한없음"
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(draft.repeatWindowEndTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowEndTime: updateOptionalTimeOfDay(previous.repeatWindowEndTime, 'minute', value),
}))
}
/>
</Space>
<div style={{ marginTop: 8 }}>
<Text type="secondary"> .</Text>
</div>
</>
)}
</div>
@@ -841,6 +1105,53 @@ function PlanScheduleDetail({
</Checkbox>
</div>
<div>
<Checkbox
checked={draft.refreshContextSnapshotOnNextRun}
disabled={!hasAccess}
onChange={(event) =>
onChangeDraft((previous) => ({
...previous,
refreshContextSnapshotOnNextRun: event.target.checked,
}))
}
>
/
</Checkbox>
<div>
<Text type="secondary"> 1 `.auto_codex/schedule/...` .</Text>
</div>
</div>
{draft.executionMode === 'managed-service' ? (
<div>
<Checkbox
checked={draft.recreateManagedServiceOnNextSave}
disabled={!hasAccess}
onChange={(event) =>
onChangeDraft((previous) => ({
...previous,
recreateManagedServiceOnNextSave: event.target.checked,
}))
}
>
Plan
</Checkbox>
<div>
<Text type="secondary">
Plan을 ,
{' '}
<Text code>.auto_codex/schedule/&lt;id&gt;</Text>
{' '}
.
</Text>
</div>
{selectedItem?.managedServiceDirectory ? (
<div style={{ marginTop: 8 }}>
<Text code>{selectedItem.managedServiceDirectory}</Text>
</div>
) : null}
</div>
) : null}
<div>
<Checkbox
checked={draft.autoDeployToMain}
@@ -850,6 +1161,18 @@ function PlanScheduleDetail({
</Checkbox>
</div>
<div>
<Checkbox
checked={draft.suppressWebPush}
disabled={!hasAccess}
onChange={(event) => onChangeDraft((previous) => ({ ...previous, suppressWebPush: event.target.checked }))}
>
</Checkbox>
<div>
<Text type="secondary"> .</Text>
</div>
</div>
<div>
<Text strong></Text>
<Segmented

View File

@@ -270,8 +270,10 @@ export async function createPlanItem(draft: PlanDraft) {
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
});
@@ -289,8 +291,10 @@ export async function updatePlanItem(draft: PlanDraft) {
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
});
@@ -382,6 +386,9 @@ function normalizePlanItem(item: PlanItem): PlanItem {
...item,
automationType: normalizePlanAutomationType(item.automationType),
automationBehaviorType: normalizeAutomationTypeId(item.automationBehaviorType),
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '',
usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot),
};
@@ -451,39 +458,67 @@ export type PlanScheduledTask = {
workId: string;
note: string;
automationType: PlanAutomationType;
automationContextIds: string[];
releaseTarget: string;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
suppressWebPush: boolean;
enabled: boolean;
immediateRunEnabled: boolean;
refreshContextSnapshotOnNextRun: boolean;
executionMode: PlanScheduleExecutionMode;
managedServiceKey: string | null;
managedServicePackageName: string | null;
managedServiceDirectory: string | null;
managedServiceManifestPath: string | null;
managedServiceGeneratedAt: string | null;
recreateManagedServiceOnNextSave: boolean;
scheduleMode: PlanScheduleMode;
repeatIntervalValue: number;
repeatIntervalUnit: PlanScheduleRepeatUnit;
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
lastRegisteredAt: string | null;
createdAt: string;
updatedAt: string;
};
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
export type PlanScheduleMode = 'interval' | 'daily';
export type PlanScheduleRepeatUnit = 'minute' | 'hour' | 'day' | 'week' | 'month';
export type PlanScheduleRepeatUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
export type PlanScheduledTaskDraft = {
id: number | null;
workId: string;
note: string;
automationType: PlanAutomationType;
automationContextIds: string[];
releaseTarget: string;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
suppressWebPush: boolean;
enabled: boolean;
immediateRunEnabled: boolean;
refreshContextSnapshotOnNextRun: boolean;
executionMode: PlanScheduleExecutionMode;
recreateManagedServiceOnNextSave: boolean;
scheduleMode: PlanScheduleMode;
repeatIntervalValue: number;
repeatIntervalUnit: PlanScheduleRepeatUnit;
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
};
export type PlanScheduledTaskSaveResult = {
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
};
async function requestPlanScheduleTask<T>(pathSuffix = '', init?: RequestInit) {
@@ -521,33 +556,60 @@ export async function fetchPlanScheduledTasks() {
return response.items.map((item) => ({
...item,
automationType: normalizePlanAutomationType(item.automationType),
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave),
}));
}
export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>('', {
const response = await requestPlanScheduleTask<{
ok: boolean;
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
}>('', {
method: 'POST',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
});
return {
...response.item,
automationType: normalizePlanAutomationType(response.item.automationType),
};
item: {
...response.item,
automationType: normalizePlanAutomationType(response.item.automationType),
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
} satisfies PlanScheduledTaskSaveResult;
}
export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
@@ -555,29 +617,51 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
throw new Error('수정할 스케줄 ID가 없습니다.');
}
const response = await requestPlanScheduleTask<{ ok: boolean; item: PlanScheduledTask }>(`/${draft.id}`, {
const response = await requestPlanScheduleTask<{
ok: boolean;
item: PlanScheduledTask;
registeredPlan: PlanItem | null;
registeredBoardPosts: Array<{ id: number } & Record<string, unknown>>;
}>(`/${draft.id}`, {
method: 'PATCH',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
releaseTarget: draft.releaseTarget,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
enabled: draft.enabled,
immediateRunEnabled: draft.immediateRunEnabled,
refreshContextSnapshotOnNextRun: draft.refreshContextSnapshotOnNextRun,
executionMode: draft.executionMode,
recreateManagedServiceOnNextSave: draft.recreateManagedServiceOnNextSave,
scheduleMode: draft.scheduleMode,
repeatIntervalValue: draft.repeatIntervalValue,
repeatIntervalUnit: draft.repeatIntervalUnit,
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
});
return {
...response.item,
automationType: normalizePlanAutomationType(response.item.automationType),
};
item: {
...response.item,
automationType: normalizePlanAutomationType(response.item.automationType),
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
} satisfies PlanScheduledTaskSaveResult;
}
export async function deletePlanScheduledTask(id: number) {

View File

@@ -111,11 +111,13 @@ export type PlanItem = {
note: string;
automationType: PlanAutomationType;
automationBehaviorType?: string;
automationContextIds: string[];
releaseReviewNote: string;
noteMasked?: boolean;
status: PlanStatus;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
suppressWebPush: boolean;
repeatRequestEnabled: boolean;
repeatIntervalMinutes: number;
assignedBranch: string | null;
@@ -137,9 +139,11 @@ export type PlanDraft = {
workId: string;
note: string;
automationType: PlanAutomationType;
automationContextIds: string[];
status: PlanStatus;
jangsingProcessingRequired: boolean;
autoDeployToMain: boolean;
suppressWebPush: boolean;
repeatRequestEnabled: boolean;
repeatIntervalMinutes: number;
};

View File

@@ -45,6 +45,7 @@ export * from './layer';
export * from './store';
export * from './widgets/core';
export * from './widgets/ag-grid-widget';
export * from './widgets/api-sample-card';
export * from './widgets/dashboard-report-card';
export * from './widgets/gps-sample-card';

View File

@@ -27,6 +27,51 @@ self.addEventListener('message', (event) => {
}
});
function normalizeNotificationValue(value) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeNotificationAliases(value) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => normalizeNotificationValue(item)).filter(Boolean);
}
function shouldCloseExistingNotification(notification, payload) {
const data = payload.data ?? {};
const notificationScope = normalizeNotificationValue(data.notificationScope);
const notificationSource = normalizeNotificationValue(data.source);
const notificationKey = normalizeNotificationValue(data.notificationKey);
const notificationAliases = normalizeNotificationAliases(data.notificationAliases);
const replaceExistingScope =
data.replaceExistingScope === true || normalizeNotificationValue(data.replaceExistingScope).toLowerCase() === 'true';
if (!notificationScope || (!replaceExistingScope && notificationScope !== 'automation')) {
return false;
}
const existingData = notification.data && typeof notification.data === 'object' ? notification.data : {};
const existingScope = normalizeNotificationValue(existingData.notificationScope);
const existingSource = normalizeNotificationValue(existingData.source);
const existingNotificationKey = normalizeNotificationValue(existingData.notificationKey);
const existingTag = normalizeNotificationValue(notification.tag);
const replaceTargets = new Set([
notificationScope,
notificationSource,
notificationKey,
...notificationAliases,
].filter(Boolean));
return (
replaceTargets.has(existingScope) ||
replaceTargets.has(existingSource) ||
replaceTargets.has(existingNotificationKey) ||
replaceTargets.has(existingTag)
);
}
self.addEventListener('push', (event) => {
if (!event.data) {
return;
@@ -46,18 +91,13 @@ self.addEventListener('push', (event) => {
const title = payload.title || 'AI Code App';
const body = payload.body || '새 알림이 도착했습니다.';
const notificationScope = payload.data?.notificationScope;
const notificationKey =
payload.data?.notificationKey ||
[payload.threadId ?? 'ai-code-app-notification', payload.data?.eventType ?? 'event', Date.now()].join(':');
event.waitUntil(
self.registration.getNotifications().then((notifications) => {
if (notificationScope === 'automation') {
notifications
.filter((notification) => notification.data?.notificationScope === 'automation')
.forEach((notification) => notification.close());
}
notifications.filter((notification) => shouldCloseExistingNotification(notification, payload)).forEach((notification) => notification.close());
return self.registration.showNotification(title, {
body,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
import {
CheckOutlined,
DeleteOutlined,
LeftOutlined,
PlusOutlined,
RightOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button, Empty, Input, Tag, Typography } from 'antd';
import type { SyntheticEvent } from 'react';
import { InputUI } from '../../components/inputs/primitives/input';
import { SelectUI, type SelectOptionItem } from '../../components/inputs/select';
import type {
LayoutPreviewBaseInputState,
LayoutPreviewEmptyPaneState,
LayoutPreviewMemoState,
LayoutPreviewSelectState,
} from './layoutPreviewRuntime';
const { Text } = Typography;
function stopPreviewEvent(event: SyntheticEvent) {
event.stopPropagation();
}
function formatMemoTimestamp(value: string) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
function getMemoPreview(body: string) {
return body.replace(/\s+/g, ' ').trim() || '새 메모';
}
function formatEmptyPaneTimestamp(value: string | null) {
if (!value) {
return '아직 메모가 없습니다.';
}
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value));
}
const EMPTY_PANE_READINESS_META: Record<
LayoutPreviewEmptyPaneState['readiness'],
{ label: string; tone: 'default' | 'processing' | 'success' }
> = {
unassigned: { label: '컴포넌트 대기', tone: 'default' },
drafting: { label: '요구 정리 중', tone: 'processing' },
ready: { label: '준비 완료', tone: 'success' },
};
export function LayoutPreviewTextMemoPane({
state,
onStartDraft,
onToggleList,
onDeleteSelection,
onSaveDraft,
onSelectNote,
onMoveSelection,
onDraftChange,
}: {
state: LayoutPreviewMemoState;
onStartDraft: () => void;
onToggleList: () => void;
onDeleteSelection: () => void;
onSaveDraft: () => void;
onSelectNote: (noteId: string) => void;
onMoveSelection: (direction: -1 | 1) => void;
onDraftChange: (nextValue: string) => void;
}) {
const selectedIndex = state.selectedId ? state.notes.findIndex((note) => note.id === state.selectedId) : -1;
const selectedNote = selectedIndex >= 0 ? state.notes[selectedIndex] : null;
const hasDraft = state.draftBody.trim().length > 0;
return (
<div className="layout-playground__memo-widget-preview" onClick={stopPreviewEvent}>
<div className="layout-playground__memo-widget-preview-toolbar" role="toolbar" aria-label="메모 도구">
<div className="layout-playground__memo-widget-preview-toolbar-group">
<Button type="text" shape="circle" htmlType="button" icon={<PlusOutlined />} aria-label="새 메모" onClick={onStartDraft} />
<Button
type="text"
shape="circle"
htmlType="button"
icon={<DeleteOutlined />}
aria-label="메모 삭제"
disabled={!selectedNote && !hasDraft}
onClick={onDeleteSelection}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<UnorderedListOutlined />}
aria-label="메모 목록"
onClick={onToggleList}
/>
</div>
<div className="layout-playground__memo-widget-preview-toolbar-group">
<Button
type="text"
shape="circle"
htmlType="button"
icon={<LeftOutlined />}
disabled={selectedIndex <= 0}
onClick={() => {
onMoveSelection(-1);
}}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<RightOutlined />}
disabled={selectedIndex < 0 || selectedIndex >= state.notes.length - 1}
onClick={() => {
onMoveSelection(1);
}}
/>
<Button
type="text"
shape="circle"
htmlType="button"
icon={<CheckOutlined />}
aria-label="저장"
disabled={!hasDraft && !selectedNote}
onClick={onSaveDraft}
/>
</div>
</div>
<div className={`layout-playground__memo-widget-preview-body${state.isListOpen ? ' layout-playground__memo-widget-preview-body--list' : ''}`}>
{state.isLoading ? (
<div className="layout-playground__memo-widget-preview-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="메모를 준비하는 중입니다." />
</div>
) : state.isListOpen ? (
<div className="layout-playground__memo-widget-preview-sheet">
{state.notes.length ? (
<div className="layout-playground__memo-widget-preview-list">
{state.notes.map((note) => (
<button
key={note.id}
type="button"
className={`layout-playground__memo-widget-preview-item${note.id === state.selectedId ? ' layout-playground__memo-widget-preview-item--active' : ''}`}
onClick={() => {
onSelectNote(note.id);
}}
>
<span className="layout-playground__memo-widget-preview-item-time">
{formatMemoTimestamp(note.updatedAt)}
</span>
<span className="layout-playground__memo-widget-preview-item-copy">{getMemoPreview(note.body)}</span>
</button>
))}
</div>
) : (
<div className="layout-playground__memo-widget-preview-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 메모가 없습니다." />
</div>
)}
</div>
) : (
<div className="layout-playground__memo-widget-preview-editor">
<div className="layout-playground__memo-widget-preview-meta">
<span>{selectedNote ? formatMemoTimestamp(selectedNote.updatedAt) : '새 메모'}</span>
<span>{state.draftBody.length}/1200</span>
</div>
<Input.TextArea
className="layout-playground__memo-widget-preview-input"
value={state.draftBody}
placeholder="본문 메모를 입력하세요."
variant="borderless"
maxLength={1200}
autoSize={false}
onChange={(event) => {
onDraftChange(event.target.value);
}}
/>
</div>
)}
</div>
</div>
);
}
export function LayoutPreviewBaseInputPane({
state,
fillPane = false,
placeholder = '입력 후 Enter 또는 blur',
}: {
state: LayoutPreviewBaseInputState;
fillPane?: boolean;
placeholder?: string;
}) {
return (
<div
className={`layout-playground__base-input-preview${fillPane ? ' layout-playground__base-input-preview--fill' : ''}`}
onClick={stopPreviewEvent}
>
<InputUI
className="layout-playground__base-input-preview-field"
value={state.value}
placeholder={placeholder}
readOnly
/>
</div>
);
}
export function LayoutPreviewSelectPane({
state,
data,
onChange,
}: {
state: LayoutPreviewSelectState;
data: SelectOptionItem[];
onChange: (nextCode?: string, item?: SelectOptionItem) => void;
}) {
const resolvedSelectedCode =
state.selectedCode && data.some((item) => item.code === state.selectedCode)
? state.selectedCode
: data[0]?.code;
const formatComboLabel = (item: SelectOptionItem) => item.value;
return (
<div className="layout-playground__select-preview" onClick={stopPreviewEvent}>
<SelectUI
data={data}
value={resolvedSelectedCode}
allowClear
placeholder="콤보 값을 선택하세요"
formatLabel={formatComboLabel}
onChange={onChange}
/>
</div>
);
}
export function LayoutPreviewEmptyPane({
paneLabel,
paneDescription,
sizeSummary,
state,
onReadinessChange,
onNoteChange,
}: {
paneLabel: string;
paneDescription: string;
sizeSummary: string;
state: LayoutPreviewEmptyPaneState;
onReadinessChange: (nextValue: LayoutPreviewEmptyPaneState['readiness']) => void;
onNoteChange: (nextValue: string) => void;
}) {
const readinessMeta = EMPTY_PANE_READINESS_META[state.readiness];
return (
<div className="layout-playground__empty-pane-preview" onClick={stopPreviewEvent}>
<div className="layout-playground__empty-pane-preview-head">
<div className="layout-playground__empty-pane-preview-copy">
<Text strong>{paneLabel}</Text>
<Text type="secondary">{paneDescription}</Text>
</div>
<Tag color={readinessMeta.tone}>{readinessMeta.label}</Tag>
</div>
<div className="layout-playground__empty-pane-preview-meta">
<div className="layout-playground__empty-pane-preview-meta-item">
<span> </span>
<strong>{sizeSummary}</strong>
</div>
<div className="layout-playground__empty-pane-preview-meta-item">
<span> </span>
<strong>{formatEmptyPaneTimestamp(state.updatedAt)}</strong>
</div>
</div>
<div className="layout-playground__empty-pane-preview-actions">
<Button type={state.readiness === 'unassigned' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('unassigned')}>
</Button>
<Button type={state.readiness === 'drafting' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('drafting')}>
</Button>
<Button type={state.readiness === 'ready' ? 'primary' : 'default'} size="small" onClick={() => onReadinessChange('ready')}>
</Button>
</div>
<Input.TextArea
className="layout-playground__empty-pane-preview-note"
value={state.note}
placeholder="이 section에 들어갈 역할이나 메모를 간단히 정리하세요."
autoSize={{ minRows: 4, maxRows: 8 }}
maxLength={400}
onChange={(event) => {
onNoteChange(event.target.value);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import { Button, Tag, Typography } from 'antd';
import type { ReactNode } from 'react';
const { Paragraph, Text } = Typography;
export type LayoutSavedPaneSpec = {
summary: string;
};
export function LayoutSavedPanePlaceholder({
label,
selected,
spec,
action,
}: {
label: string;
selected: boolean;
spec: LayoutSavedPaneSpec | null;
action?: ReactNode;
}) {
return (
<div className={`layout-playground__saved-pane-placeholder${selected ? ' is-selected' : ''}`}>
<div className="layout-playground__saved-pane-placeholder-head">
<Tag color={selected ? 'blue' : 'default'}>{label}</Tag>
<Tag bordered={false} className="layout-playground__saved-pane-placeholder-status">
</Tag>
</div>
<div className="layout-playground__saved-pane-placeholder-body">
<Text strong className="layout-playground__saved-pane-placeholder-title">
.
</Text>
<Paragraph className="layout-playground__saved-pane-placeholder-copy">
section은 . .
</Paragraph>
{spec ? (
<div className="layout-playground__saved-pane-placeholder-meta">
<div className="layout-playground__saved-pane-placeholder-meta-item layout-playground__saved-pane-placeholder-meta-item--wide">
<span> </span>
<strong>{spec.summary}</strong>
</div>
</div>
) : null}
</div>
{action ? <div className="layout-playground__saved-pane-placeholder-actions">{action}</div> : null}
</div>
);
}
export function LayoutSavedSelectionSummary({
items,
selectedId,
onSelect,
spec,
}: {
items: Array<{ id: string; label: string }>;
selectedId: string | null;
onSelect: (id: string) => void;
spec: LayoutSavedPaneSpec | null;
}) {
return (
<div className="layout-playground__saved-selection-summary">
<div className="layout-playground__saved-selection-summary-copy">
<Text strong>{items.find((item) => item.id === selectedId)?.label ?? 'Pane 선택 없음'}</Text>
<Text type="secondary">
{spec ? spec.summary : '분할 정보가 없습니다.'}
</Text>
</div>
<div className="layout-playground__saved-selection-summary-actions">
{items.map((item) => (
<Button
key={item.id}
size="small"
type={item.id === selectedId ? 'primary' : 'default'}
onClick={() => {
onSelect(item.id);
}}
>
{item.label}
</Button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { LAYOUT_EDITOR_CHAT_TYPE_ID } from '../../app/main/chatTypeDefaults';
type LayoutCodexChatType = {
id: string;
name: string;
description: string;
};
const PRIORITIZED_CHAT_TYPE_IDS = [LAYOUT_EDITOR_CHAT_TYPE_ID, 'general-request', 'api-request-template'] as const;
export function resolvePreferredLayoutCodexChatType(chatTypes: LayoutCodexChatType[]) {
for (const id of PRIORITIZED_CHAT_TYPE_IDS) {
const matched = chatTypes.find((item) => item.id === id);
if (matched) {
return matched;
}
}
return chatTypes.find((item) => item.id !== 'general-inquiry') ?? chatTypes[0] ?? null;
}

View File

@@ -0,0 +1,484 @@
import { useEffect, useMemo, useState } from 'react';
import type { SelectOptionItem } from '../../components/inputs/select';
type LayoutComponentBinding = {
optionId: string;
label: string;
};
type LayoutLeafNode = {
id: string;
componentBinding: LayoutComponentBinding | null;
};
export type LayoutPreviewBindingKind = 'base-input' | 'select-input' | 'text-memo-widget' | 'sample';
export type LayoutPreviewMemoNote = {
id: string;
body: string;
updatedAt: string;
};
export type LayoutPreviewMemoState = {
draftBody: string;
selectedId: string | null;
isListOpen: boolean;
isLoading: boolean;
notes: LayoutPreviewMemoNote[];
};
export type LayoutPreviewBaseInputState = {
value: string;
};
export type LayoutPreviewSelectState = {
selectedCode: string | undefined;
selectedItem: SelectOptionItem | null;
};
export type LayoutPreviewEmptyPaneState = {
readiness: 'unassigned' | 'drafting' | 'ready';
note: string;
updatedAt: string | null;
};
export type LayoutPreviewInteractionRule = {
sourceLeafId: string;
targetLeafId: string;
};
type LayoutPreviewRuntime = {
memoStates: Record<string, LayoutPreviewMemoState>;
baseInputStates: Record<string, LayoutPreviewBaseInputState>;
selectStates: Record<string, LayoutPreviewSelectState>;
emptyPaneStates: Record<string, LayoutPreviewEmptyPaneState>;
setMemoDraftBody: (leafId: string, nextValue: string) => void;
toggleMemoList: (leafId: string) => void;
saveMemoDraft: (leafId: string) => void;
startMemoDraft: (leafId: string) => void;
selectMemoNote: (leafId: string, noteId: string) => void;
moveMemoSelection: (leafId: string, direction: -1 | 1) => void;
deleteMemoSelection: (leafId: string) => void;
setSelectValue: (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => void;
setEmptyPaneReadiness: (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => void;
setEmptyPaneNote: (leafId: string, nextValue: string) => void;
};
const EMPTY_MEMO_STATE: LayoutPreviewMemoState = {
draftBody: '',
selectedId: null,
isListOpen: false,
isLoading: false,
notes: [],
};
const EMPTY_BASE_INPUT_STATE: LayoutPreviewBaseInputState = {
value: '',
};
const EMPTY_SELECT_STATE: LayoutPreviewSelectState = {
selectedCode: undefined,
selectedItem: null,
};
const EMPTY_EMPTY_PANE_STATE: LayoutPreviewEmptyPaneState = {
readiness: 'unassigned',
note: '',
updatedAt: null,
};
function createMemoNote(body: string): LayoutPreviewMemoNote {
const timestamp = new Date().toISOString();
return {
id: `memo-preview-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
body,
updatedAt: timestamp,
};
}
function extractMemoFirstLine(body: string) {
return body
.split(/\r?\n/u)
.map((line) => line.trim())
.find(Boolean) ?? '';
}
export function resolveLayoutPreviewBindingKind(binding: LayoutComponentBinding | null): LayoutPreviewBindingKind {
if (!binding) {
return 'sample';
}
if (
binding.optionId === 'component:input:deferred-input' ||
binding.optionId === 'component:input:input-base' ||
binding.label === 'Base Input'
) {
return 'base-input';
}
if (
binding.optionId === 'component:select-input:select-input-base' ||
binding.optionId === 'component:select-input:select-input' ||
binding.label === 'Select Input'
) {
return 'select-input';
}
if (binding.optionId === 'widget:text-memo-widget:text-memo-widget' || binding.label === 'Text Memo Widget') {
return 'text-memo-widget';
}
return 'sample';
}
function syncRecordKeys<T>(
previous: Record<string, T>,
nextKeys: string[],
createValue: () => T,
) {
const nextSet = new Set(nextKeys);
let changed = false;
const nextRecord: Record<string, T> = {};
nextKeys.forEach((key) => {
if (key in previous) {
nextRecord[key] = previous[key];
return;
}
nextRecord[key] = createValue();
changed = true;
});
Object.keys(previous).forEach((key) => {
if (!nextSet.has(key)) {
changed = true;
}
});
return changed ? nextRecord : previous;
}
export function useLayoutPreviewRuntime(
leafNodes: LayoutLeafNode[],
interactionRules: LayoutPreviewInteractionRule[],
): LayoutPreviewRuntime {
const leafBindingKindMap = useMemo(
() => new Map(leafNodes.map((leaf) => [leaf.id, resolveLayoutPreviewBindingKind(leaf.componentBinding)])),
[leafNodes],
);
const baseInputLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'base-input')
.map((leaf) => leaf.id),
[leafNodes],
);
const memoLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'text-memo-widget')
.map((leaf) => leaf.id),
[leafNodes],
);
const selectLeafIds = useMemo(
() =>
leafNodes
.filter((leaf) => resolveLayoutPreviewBindingKind(leaf.componentBinding) === 'select-input')
.map((leaf) => leaf.id),
[leafNodes],
);
const [memoStates, setMemoStates] = useState<Record<string, LayoutPreviewMemoState>>({});
const [baseInputStates, setBaseInputStates] = useState<Record<string, LayoutPreviewBaseInputState>>({});
const [selectStates, setSelectStates] = useState<Record<string, LayoutPreviewSelectState>>({});
const [emptyPaneStates, setEmptyPaneStates] = useState<Record<string, LayoutPreviewEmptyPaneState>>({});
const memoToBaseInputTargets = useMemo(() => {
const nextMap = new Map<string, string[]>();
interactionRules.forEach((rule) => {
const sourceKind = leafBindingKindMap.get(rule.sourceLeafId);
const targetKind = leafBindingKindMap.get(rule.targetLeafId);
if (sourceKind !== 'text-memo-widget' || targetKind !== 'base-input') {
return;
}
const nextTargets = nextMap.get(rule.sourceLeafId) ?? [];
if (!nextTargets.includes(rule.targetLeafId)) {
nextTargets.push(rule.targetLeafId);
}
nextMap.set(rule.sourceLeafId, nextTargets);
});
return nextMap;
}, [interactionRules, leafBindingKindMap]);
const syncBaseInputsFromMemo = (sourceLeafId: string, draftBody: string) => {
const targetLeafIds = memoToBaseInputTargets.get(sourceLeafId) ?? [];
if (!targetLeafIds.length) {
return;
}
const nextValue = extractMemoFirstLine(draftBody);
setBaseInputStates((previous) => {
const nextState = { ...previous };
targetLeafIds.forEach((leafId) => {
nextState[leafId] = {
...(previous[leafId] ?? EMPTY_BASE_INPUT_STATE),
value: nextValue,
};
});
return nextState;
});
};
useEffect(() => {
setBaseInputStates((previous) => syncRecordKeys(previous, baseInputLeafIds, () => ({ ...EMPTY_BASE_INPUT_STATE })));
}, [baseInputLeafIds]);
useEffect(() => {
setMemoStates((previous) => syncRecordKeys(previous, memoLeafIds, () => ({ ...EMPTY_MEMO_STATE })));
}, [memoLeafIds]);
useEffect(() => {
setSelectStates((previous) => syncRecordKeys(previous, selectLeafIds, () => ({ ...EMPTY_SELECT_STATE })));
}, [selectLeafIds]);
useEffect(() => {
const emptyPaneLeafIds = leafNodes.filter((leaf) => !leaf.componentBinding).map((leaf) => leaf.id);
setEmptyPaneStates((previous) => syncRecordKeys(previous, emptyPaneLeafIds, () => ({ ...EMPTY_EMPTY_PANE_STATE })));
}, [leafNodes]);
const setMemoDraftBody = (leafId: string, nextValue: string) => {
syncBaseInputsFromMemo(leafId, nextValue);
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
draftBody: nextValue,
},
}));
};
const toggleMemoList = (leafId: string) => {
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
isListOpen: !(previous[leafId]?.isListOpen ?? false),
},
}));
};
const saveMemoDraft = (leafId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
const trimmedBody = current.draftBody.trim();
if (!trimmedBody) {
return;
}
syncBaseInputsFromMemo(leafId, trimmedBody);
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
const matchedNote = nextCurrent.selectedId ? nextCurrent.notes.find((note) => note.id === nextCurrent.selectedId) ?? null : null;
const nextNote = matchedNote
? {
...matchedNote,
body: trimmedBody,
updatedAt: new Date().toISOString(),
}
: createMemoNote(trimmedBody);
const filteredNotes = nextCurrent.notes.filter((note) => note.id !== nextNote.id);
return {
...previous,
[leafId]: {
...nextCurrent,
draftBody: nextNote.body,
selectedId: nextNote.id,
isListOpen: false,
notes: [nextNote, ...filteredNotes].slice(0, 12),
},
};
});
};
const startMemoDraft = (leafId: string) => {
syncBaseInputsFromMemo(leafId, '');
setMemoStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_MEMO_STATE),
draftBody: '',
selectedId: null,
isListOpen: false,
},
}));
};
const selectMemoNote = (leafId: string, noteId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
const selectedNote = current.notes.find((note) => note.id === noteId) ?? null;
if (!selectedNote) {
return;
}
syncBaseInputsFromMemo(leafId, selectedNote.body);
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
const nextSelectedNote = nextCurrent.notes.find((note) => note.id === noteId) ?? null;
if (!nextSelectedNote) {
return previous;
}
return {
...previous,
[leafId]: {
...nextCurrent,
selectedId: nextSelectedNote.id,
draftBody: nextSelectedNote.body,
isListOpen: false,
},
};
});
};
const deleteMemoSelection = (leafId: string) => {
const current = memoStates[leafId] ?? EMPTY_MEMO_STATE;
if (!current.selectedId && !current.draftBody.trim()) {
return;
}
if (!current.selectedId) {
syncBaseInputsFromMemo(leafId, '');
} else {
const nextNotes = current.notes.filter((note) => note.id !== current.selectedId);
const nextSelectedNote = nextNotes[0] ?? null;
syncBaseInputsFromMemo(leafId, nextSelectedNote?.body ?? '');
}
setMemoStates((previous) => {
const nextCurrent = previous[leafId] ?? EMPTY_MEMO_STATE;
if (!nextCurrent.selectedId && !nextCurrent.draftBody.trim()) {
return previous;
}
if (!nextCurrent.selectedId) {
return {
...previous,
[leafId]: {
...nextCurrent,
draftBody: '',
},
};
}
const nextNotes = nextCurrent.notes.filter((note) => note.id !== nextCurrent.selectedId);
const nextSelectedNote = nextNotes[0] ?? null;
return {
...previous,
[leafId]: {
...nextCurrent,
notes: nextNotes,
selectedId: nextSelectedNote?.id ?? null,
draftBody: nextSelectedNote?.body ?? '',
},
};
});
};
const moveMemoSelection = (leafId: string, direction: -1 | 1) => {
setMemoStates((previous) => {
const current = previous[leafId] ?? EMPTY_MEMO_STATE;
if (!current.notes.length) {
return previous;
}
const baseIndex = current.selectedId
? current.notes.findIndex((note) => note.id === current.selectedId)
: 0;
const safeBaseIndex = baseIndex >= 0 ? baseIndex : 0;
const nextIndex = safeBaseIndex + direction;
if (nextIndex < 0 || nextIndex >= current.notes.length) {
return previous;
}
const nextSelectedNote = current.notes[nextIndex];
syncBaseInputsFromMemo(leafId, nextSelectedNote.body);
return {
...previous,
[leafId]: {
...current,
selectedId: nextSelectedNote.id,
draftBody: nextSelectedNote.body,
isListOpen: false,
},
};
});
};
const setSelectValue = (leafId: string, nextCode?: string, nextItem?: SelectOptionItem) => {
setSelectStates((previous) => ({
...previous,
[leafId]: {
selectedCode: nextCode,
selectedItem: nextItem ?? null,
},
}));
};
const setEmptyPaneReadiness = (leafId: string, readiness: LayoutPreviewEmptyPaneState['readiness']) => {
setEmptyPaneStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
readiness,
updatedAt: new Date().toISOString(),
},
}));
};
const setEmptyPaneNote = (leafId: string, nextValue: string) => {
setEmptyPaneStates((previous) => ({
...previous,
[leafId]: {
...(previous[leafId] ?? EMPTY_EMPTY_PANE_STATE),
note: nextValue,
updatedAt: nextValue.trim() ? new Date().toISOString() : previous[leafId]?.updatedAt ?? null,
},
}));
};
return {
memoStates,
baseInputStates,
selectStates,
emptyPaneStates,
setMemoDraftBody,
toggleMemoList,
saveMemoDraft,
startMemoDraft,
selectMemoNote,
moveMemoSelection,
deleteMemoSelection,
setSelectValue,
setEmptyPaneReadiness,
setEmptyPaneNote,
};
}

View File

@@ -43,6 +43,22 @@ const PLAY_LAYOUTS_TABLE = 'play_layouts';
let setupPromise: Promise<void> | null = null;
function normalizeLayoutTimestamp(value: unknown, fallback: string) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
const parsed = Date.parse(trimmed);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
}
}
return fallback;
}
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
@@ -172,11 +188,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
const now = new Date().toISOString();
return {
id: row.id,
name: row.name,
createdAt: row.created_at,
updatedAt: row.updated_at,
createdAt: normalizeLayoutTimestamp(row.created_at, now),
updatedAt: normalizeLayoutTimestamp(row.updated_at, now),
axis: row.axis,
sizeUnit: row.size_unit,
primarySize: Number(row.primary_size),
@@ -191,11 +209,15 @@ function toRecord(row: SavedLayoutRow): SavedLayoutRecord {
}
function toRow(record: SavedLayoutRecord): SavedLayoutRow {
const now = new Date().toISOString();
const createdAt = normalizeLayoutTimestamp(record.createdAt, now);
const updatedAt = normalizeLayoutTimestamp(record.updatedAt, createdAt);
return {
id: record.id,
name: record.name,
created_at: record.createdAt,
updated_at: record.updatedAt,
created_at: createdAt,
updated_at: updatedAt,
axis: record.axis,
size_unit: record.sizeUnit,
primary_size: record.primarySize,
@@ -286,7 +308,20 @@ export async function saveLayout(record: SavedLayoutRecord) {
await request<{ ok: boolean }>(`/crud/${PLAY_LAYOUTS_TABLE}/update`, {
method: 'PATCH',
body: JSON.stringify({
data: row,
data: {
name: row.name,
updated_at: row.updated_at,
axis: row.axis,
size_unit: row.size_unit,
primary_size: row.primary_size,
primary_min: row.primary_min,
secondary_min: row.secondary_min,
resizable: row.resizable,
selected_leaf_id: row.selected_leaf_id,
total_panes: row.total_panes,
summary: row.summary,
tree: row.tree,
},
where: [{ field: 'id', operator: 'eq', value: record.id }],
}),
});

View File

@@ -0,0 +1,65 @@
.ag-grid-widget-frame {
display: flex;
width: 100%;
min-height: 0;
}
.ag-grid-widget-frame > .widget-shell,
.ag-grid-widget-frame > .widget-shell--plain {
width: 100%;
}
.ag-grid-widget {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
}
.ag-grid-widget__actions {
justify-content: flex-end;
}
.ag-grid-widget__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
min-width: 36px;
padding-inline: 0;
}
.ag-grid-widget__grid-shell {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 320px;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.26);
}
.ag-grid-widget__grid-shell .ag-root-wrapper {
border: 0;
}
.ag-grid-widget__grid-shell .ag-header {
border-bottom-color: rgba(148, 163, 184, 0.24);
}
.ag-grid-widget__grid-shell .ag-cell,
.ag-grid-widget__grid-shell .ag-header-cell {
border-right-color: rgba(148, 163, 184, 0.16);
}
@media (max-width: 640px) {
.ag-grid-widget__actions {
width: 100%;
justify-content: flex-start;
}
.ag-grid-widget__actions .ag-grid-widget__icon-button {
flex: 0 0 auto;
}
}

View File

@@ -0,0 +1,340 @@
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Button, Space, message } from 'antd';
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { WidgetShell } from '../core';
import type { WidgetHandle } from '../core';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
import './AgGridWidget.css';
ModuleRegistry.registerModules([AllCommunityModule]);
const STORAGE_KEY = 'ai-code-app:ag-grid-widget';
type GridRow = {
id: string;
item: string;
status: string;
owner: string;
quantity: number;
[key: string]: string | number;
};
type StoredGridSnapshot = {
columnDefs: ColDef<GridRow>[];
rowData: GridRow[];
};
const BASE_COLUMNS: ColDef<GridRow>[] = [
{ field: 'item', headerName: '품목', editable: true, minWidth: 150, flex: 1.2 },
{ field: 'status', headerName: '상태', editable: true, minWidth: 120, flex: 1 },
{ field: 'owner', headerName: '담당자', editable: true, minWidth: 120, flex: 1 },
{ field: 'quantity', headerName: '수량', editable: true, minWidth: 100, type: 'numericColumn', flex: 0.8 },
];
const DEFAULT_ROWS: GridRow[] = [
{ id: '1', item: '모바일 레이아웃 QA', status: '대기', owner: '민지', quantity: 3 },
{ id: '2', item: '채팅방 최신글 확인', status: '진행', owner: '도윤', quantity: 5 },
{ id: '3', item: '첨부파일 미리보기', status: '검토', owner: '서준', quantity: 2 },
{ id: '4', item: '위젯 저장 상태 점검', status: '완료', owner: '하린', quantity: 8 },
{ id: '5', item: '사용자 액션 로그', status: '대기', owner: '지후', quantity: 1 },
];
export type AgGridWidgetProps = {
title?: string;
cardWrapper?: boolean;
height?: number | string;
};
function normalizeHeight(height?: number | string) {
if (typeof height === 'number') {
return `${height}px`;
}
return height ?? '100%';
}
function cloneColumnDefs(columnDefs: ColDef<GridRow>[]) {
return columnDefs.map((columnDef) => ({ ...columnDef }));
}
function cloneRowData(rowData: GridRow[]) {
return rowData.map((row) => ({ ...row }));
}
function readStoredSnapshot() {
if (typeof window === 'undefined') {
return null;
}
try {
const rawValue = window.localStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return null;
}
const parsed = JSON.parse(rawValue) as StoredGridSnapshot;
if (!Array.isArray(parsed?.columnDefs) || !Array.isArray(parsed?.rowData)) {
return null;
}
return {
columnDefs: cloneColumnDefs(parsed.columnDefs),
rowData: cloneRowData(parsed.rowData),
} satisfies StoredGridSnapshot;
} catch {
return null;
}
}
function getDefaultSnapshot(): StoredGridSnapshot {
return {
columnDefs: cloneColumnDefs(BASE_COLUMNS),
rowData: cloneRowData(DEFAULT_ROWS),
};
}
function getCurrentRows(api: GridApi<GridRow> | null, fallbackRows: GridRow[]) {
if (!api) {
return cloneRowData(fallbackRows);
}
const rows: GridRow[] = [];
api.forEachNode((node) => {
if (node.data) {
rows.push({ ...node.data });
}
});
return rows;
}
function getNextExtraColumnIndex(columnDefs: ColDef<GridRow>[]) {
return (
columnDefs.reduce((maxValue, columnDef) => {
if (typeof columnDef.field !== 'string') {
return maxValue;
}
const match = columnDef.field.match(/^extraColumn(\d+)$/);
if (!match) {
return maxValue;
}
return Math.max(maxValue, Number(match[1]));
}, 0) + 1
);
}
export const AgGridWidget = forwardRef<WidgetHandle, AgGridWidgetProps>(function AgGridWidget(
{ title = 'AG Grid Widget', cardWrapper = true, height },
ref,
) {
const [messageApi, contextHolder] = message.useMessage();
const wrapperRef = useRef<HTMLDivElement | null>(null);
const gridApiRef = useRef<GridApi<GridRow> | null>(null);
const initialSnapshot = useMemo(() => readStoredSnapshot() ?? getDefaultSnapshot(), []);
const [columnDefs, setColumnDefs] = useState<ColDef<GridRow>[]>(initialSnapshot.columnDefs);
const [rowData, setRowData] = useState<GridRow[]>(initialSnapshot.rowData);
const defaultColDef = useMemo<ColDef<GridRow>>(
() => ({
sortable: true,
filter: true,
resizable: true,
editable: true,
}),
[],
);
const resizeColumnsToFit = () => {
window.requestAnimationFrame(() => {
gridApiRef.current?.sizeColumnsToFit({
defaultMinWidth: 96,
});
});
};
useImperativeHandle(
ref,
() => ({
focus: () => {
wrapperRef.current?.focus();
},
scrollIntoView: (options) => {
wrapperRef.current?.scrollIntoView(options);
},
getId: () => 'ag-grid-widget',
getFeatures: () => ['component-sample', 'feature-registry', 'imperative-handle'],
}),
[],
);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const target = wrapperRef.current;
if (!target || typeof ResizeObserver === 'undefined') {
resizeColumnsToFit();
return;
}
const observer = new ResizeObserver(() => {
resizeColumnsToFit();
});
observer.observe(target);
return () => observer.disconnect();
}, [columnDefs.length]);
useEffect(() => {
resizeColumnsToFit();
}, [columnDefs]);
const applySnapshot = (snapshot: StoredGridSnapshot) => {
setColumnDefs(cloneColumnDefs(snapshot.columnDefs));
setRowData(cloneRowData(snapshot.rowData));
window.requestAnimationFrame(() => {
gridApiRef.current?.refreshCells({ force: true });
resizeColumnsToFit();
});
};
const handleGridReady = (event: GridReadyEvent<GridRow>) => {
gridApiRef.current = event.api;
resizeColumnsToFit();
};
const handleAddColumn = () => {
const nextIndex = getNextExtraColumnIndex(columnDefs);
const nextField = `extraColumn${nextIndex}`;
const nextRows = getCurrentRows(gridApiRef.current, rowData).map((row) => ({
...row,
[nextField]: '',
}));
setColumnDefs((previous) => [
...previous,
{
field: nextField,
headerName: `${nextIndex}`,
editable: true,
minWidth: 120,
flex: 1,
},
]);
setRowData(nextRows);
};
const handleRemoveColumn = () => {
const removableColumn = [...columnDefs].reverse().find((columnDef) => {
return typeof columnDef.field === 'string' && !BASE_COLUMNS.some((baseColumn) => baseColumn.field === columnDef.field);
});
if (!removableColumn?.field) {
void messageApi.info('삭제할 추가 열이 없습니다.');
return;
}
const nextRows = getCurrentRows(gridApiRef.current, rowData).map((row) => {
const nextRow = { ...row };
delete nextRow[removableColumn.field as string];
return nextRow;
});
setColumnDefs((previous) => previous.filter((columnDef) => columnDef.field !== removableColumn.field));
setRowData(nextRows);
};
const handleSave = () => {
if (typeof window === 'undefined') {
return;
}
const snapshot: StoredGridSnapshot = {
columnDefs: cloneColumnDefs(columnDefs),
rowData: getCurrentRows(gridApiRef.current, rowData),
};
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
void messageApi.success('그리드를 저장했습니다.');
};
const handleRefresh = () => {
const savedSnapshot = readStoredSnapshot();
if (savedSnapshot) {
applySnapshot(savedSnapshot);
void messageApi.success('저장된 그리드를 다시 불러왔습니다.');
return;
}
applySnapshot(getDefaultSnapshot());
void messageApi.info('기본 그리드로 새로고침했습니다.');
};
return (
<div
ref={wrapperRef}
className="ag-grid-widget-frame"
style={{ height: normalizeHeight(height) }}
tabIndex={-1}
>
{contextHolder}
<WidgetShell
id="ag-grid-widget"
title={title}
cardWrapper={cardWrapper}
featureSlot={
<Space size={8} wrap className="ag-grid-widget__actions">
<Button
aria-label="열 추가"
title="열 추가"
icon={<PlusOutlined />}
className="ag-grid-widget__icon-button"
onClick={handleAddColumn}
/>
<Button
aria-label="열 삭제"
title="열 삭제"
icon={<DeleteOutlined />}
className="ag-grid-widget__icon-button"
onClick={handleRemoveColumn}
/>
<Button
aria-label="저장"
title="저장"
icon={<SaveOutlined />}
className="ag-grid-widget__icon-button"
type="primary"
onClick={handleSave}
/>
<Button
aria-label="새로고침"
title="새로고침"
icon={<ReloadOutlined />}
className="ag-grid-widget__icon-button"
onClick={handleRefresh}
/>
</Space>
}
>
<div className="ag-grid-widget">
<div className="ag-grid-widget__grid-shell ag-theme-quartz">
<AgGridReact<GridRow>
animateRows
columnDefs={columnDefs}
rowData={rowData}
defaultColDef={defaultColDef}
getRowId={(params) => params.data.id}
onGridReady={handleGridReady}
/>
</div>
</div>
</WidgetShell>
</div>
);
});

View File

@@ -0,0 +1,2 @@
export { AgGridWidget } from './AgGridWidget';
export type { AgGridWidgetProps } from './AgGridWidget';

View File

@@ -0,0 +1,18 @@
import type { SampleMeta, SampleRenderProps } from '../../core';
import { AgGridWidget } from '../AgGridWidget';
export const sampleMeta: SampleMeta = {
id: 'ag-grid-widget',
componentId: 'ag-grid-widget',
title: 'AG Grid Widget',
description: '상단 좌측 타이틀과 우측 액션, 하단 100% 데이터 그리드를 가지는 위젯입니다.',
category: 'Widgets',
kind: 'feature',
variantLabel: 'Data Grid',
order: 24,
features: ['component-sample', 'feature-registry', 'imperative-handle'],
};
export function Sample({ disableWidgetCardWrapper }: SampleRenderProps) {
return <AgGridWidget cardWrapper={!disableWidgetCardWrapper} height={480} />;
}

View File

@@ -1,6 +1,13 @@
import type { WidgetRegistryItem } from './core';
export const registeredWidgets: WidgetRegistryItem[] = [
{
id: 'ag-grid-widget',
title: 'AG Grid Widget',
description:
'좌측 타이틀, 우측 열 추가/삭제/저장/새로고침 액션과 전체 높이 데이터 그리드를 제공하는 위젯입니다.',
features: ['component-sample', 'feature-registry', 'imperative-handle'],
},
{
id: 'api-sample-card-widget',
title: 'API Sample Card Widget',

View File

@@ -6,16 +6,24 @@
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.layout-playground__pane--surface .text-memo-widget {
box-sizing: border-box;
height: calc(100% - 8px);
min-height: 0;
padding-bottom: 4px;
}
.text-memo-widget__body {
display: flex;
flex: 1 1 auto;
flex: 1 1 0;
flex-direction: column;
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.text-memo-widget__body--list-open {
@@ -28,12 +36,16 @@
justify-content: space-between;
gap: 12px;
padding: 2px 0 0;
min-width: 0;
flex-wrap: nowrap;
}
.text-memo-widget__toolbar-group {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
flex-wrap: nowrap;
}
.text-memo-widget__toolbar .ant-btn {
@@ -52,7 +64,7 @@
.text-memo-widget__editor {
display: flex;
flex: 1 1 auto;
flex: 1 1 0;
flex-direction: column;
min-height: 0;
position: relative;
@@ -77,16 +89,33 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 28px;
padding: 14px 18px 0;
color: rgba(100, 116, 139, 0.92);
font-size: 12px;
letter-spacing: 0.01em;
flex-wrap: nowrap;
}
.text-memo-widget__meta > :first-child {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-memo-widget__meta > :last-child {
flex: 0 0 auto;
white-space: nowrap;
}
.text-memo-widget__editor .ant-input-textarea {
display: flex;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.text-memo-widget__input.ant-input,
@@ -117,9 +146,8 @@
.text-memo-widget__sheet {
display: flex;
flex: 1 1 auto;
flex: 1 1 0;
width: 100%;
height: 100%;
min-height: 0;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.18);
@@ -184,14 +212,16 @@
@media (max-width: 640px) {
.text-memo-widget__toolbar {
flex-wrap: wrap;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
}
.text-memo-widget__editor {
min-height: 300px;
min-height: min(300px, 100%);
}
.text-memo-widget__sheet {
min-height: 240px;
min-height: min(240px, 100%);
}
}

View File

@@ -1,6 +1,7 @@
import { CheckOutlined, DeleteOutlined, EditOutlined, LeftOutlined, PlusOutlined, RightOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Button, Empty, Input, Modal, message } from 'antd';
import { forwardRef, useEffect, useMemo, useState } from 'react';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard';
import { WidgetShell } from '../core';
import type { WidgetHandle } from '../core';
@@ -89,6 +90,25 @@ function getPreviewText(body: string) {
return preview || '새 메모';
}
function restoreMemoShellScroll(target?: EventTarget | null) {
if (typeof window === 'undefined') {
return;
}
const anchor =
target instanceof HTMLElement
? target.closest('.app-shell')
: document.activeElement instanceof HTMLElement
? document.activeElement.closest('.app-shell')
: document.querySelector('.app-shell');
if (!(anchor instanceof HTMLElement) || anchor.scrollTop <= 0) {
return;
}
anchor.scrollTop = 0;
}
export type TextMemoWidgetProps = {
cardWrapper?: boolean;
};
@@ -106,6 +126,8 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
const [isEditing, setIsEditing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false);
const textAreaRef = useRef<TextAreaRef | null>(null);
useEffect(() => {
let cancelled = false;
@@ -167,6 +189,25 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
};
}, []);
useEffect(() => {
if (!isInputFocused || typeof window === 'undefined') {
return;
}
let frameId = 0;
const syncScroll = () => {
restoreMemoShellScroll(textAreaRef.current?.resizableTextArea?.textArea ?? null);
frameId = window.requestAnimationFrame(syncScroll);
};
frameId = window.requestAnimationFrame(syncScroll);
return () => {
window.cancelAnimationFrame(frameId);
};
}, [isInputFocused]);
const selectedIndex = useMemo(() => {
if (!selectedId) {
return -1;
@@ -410,6 +451,7 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
</div>
<Input.TextArea
ref={textAreaRef}
className="text-memo-widget__input"
value={body}
placeholder="메모 입력"
@@ -418,11 +460,22 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
readOnly={isSaving || (!isEditing && !!selectedNote)}
onChange={(event) => {
setBody(event.target.value);
window.requestAnimationFrame(() => {
restoreMemoShellScroll(event.target);
});
}}
onFocus={() => {
if (!selectedNote) {
setIsEditing(true);
}
setIsInputFocused(true);
window.requestAnimationFrame(() => {
restoreMemoShellScroll();
});
}}
onBlur={() => {
setIsInputFocused(false);
}}
/>
</div>