feat: expand live chat and work server tools
This commit is contained in:
284
src/app/main/AutomationContextManagementPage.tsx
Normal file
284
src/app/main/AutomationContextManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('');
|
||||
}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
361
src/app/main/automationContextAccess.ts
Normal file
361
src/app/main/automationContextAccess.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
6
src/app/main/chatTypeDefaults.ts
Normal file
6
src/app/main/chatTypeDefaults.ts
Normal 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- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
|
||||
@@ -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={() => {}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
67
src/app/main/codexLiveDraftBridge.ts
Normal file
67
src/app/main/codexLiveDraftBridge.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,7 @@ export type MainLayoutContextValue = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
savedLayouts: SavedLayoutRecord[];
|
||||
savedLayoutsReady: boolean;
|
||||
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
|
||||
searchOptions: SearchKeywordOption[];
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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="즉시 요청"
|
||||
|
||||
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal file
48
src/app/main/mainChatPanel/ChatLinkCardPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal file
52
src/app/main/mainChatPanel/ChatRankedLinkPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal file
65
src/app/main/mainChatPanel/linkNavigation.ts
Normal 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();
|
||||
}
|
||||
164
src/app/main/mainChatPanel/messageParts.ts
Normal file
164
src/app/main/mainChatPanel/messageParts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Sample() {
|
||||
}}
|
||||
/>
|
||||
<Text>확정 값: {committedValue}</Text>
|
||||
<Text type="secondary">확정 횟수: {commitCount}</Text>
|
||||
<Text type="secondary">{`확정 횟수: ${commitCount}`}</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,4 +13,5 @@ export type SelectUIProps = Omit<
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (code?: string, item?: SelectOptionItem) => void;
|
||||
formatLabel?: (item: SelectOptionItem) => string;
|
||||
};
|
||||
|
||||
@@ -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` 문맥에서는 후자만 다룹니다.
|
||||
|
||||
240
src/features/layout/memo/MemoLayoutPage.css
Normal file
240
src/features/layout/memo/MemoLayoutPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal file
345
src/features/layout/memo/MemoLayoutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/memo/index.ts
Normal file
1
src/features/layout/memo/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { MemoLayoutPage } from './MemoLayoutPage';
|
||||
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal file
97
src/features/layout/stock-alert/StockAlertLayout.css
Normal 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);
|
||||
}
|
||||
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal file
702
src/features/layout/stock-alert/StockAlertLayout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/features/layout/stock-alert/index.ts
Normal file
1
src/features/layout/stock-alert/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from './StockAlertLayout';
|
||||
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal file
171
src/features/layout/stock-alert/stockAlertApi.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<id></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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
52
src/sw.js
52
src/sw.js
@@ -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
310
src/views/play/LayoutPreviewWidgets.tsx
Normal file
310
src/views/play/LayoutPreviewWidgets.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/views/play/LayoutSavedPanePlaceholder.tsx
Normal file
85
src/views/play/LayoutSavedPanePlaceholder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/views/play/layoutCodexChatType.ts
Normal file
20
src/views/play/layoutCodexChatType.ts
Normal 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;
|
||||
}
|
||||
484
src/views/play/layoutPreviewRuntime.ts
Normal file
484
src/views/play/layoutPreviewRuntime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 }],
|
||||
}),
|
||||
});
|
||||
|
||||
65
src/widgets/ag-grid-widget/AgGridWidget.css
Normal file
65
src/widgets/ag-grid-widget/AgGridWidget.css
Normal 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;
|
||||
}
|
||||
}
|
||||
340
src/widgets/ag-grid-widget/AgGridWidget.tsx
Normal file
340
src/widgets/ag-grid-widget/AgGridWidget.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
2
src/widgets/ag-grid-widget/index.ts
Normal file
2
src/widgets/ag-grid-widget/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { AgGridWidget } from './AgGridWidget';
|
||||
export type { AgGridWidgetProps } from './AgGridWidget';
|
||||
18
src/widgets/ag-grid-widget/samples/Sample.tsx
Normal file
18
src/widgets/ag-grid-widget/samples/Sample.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user