feat: update codex live automation and plan flows

This commit is contained in:
2026-04-24 08:06:36 +09:00
parent 916107dbe5
commit f2d6310efa
47 changed files with 2767 additions and 507 deletions

View File

@@ -0,0 +1,465 @@
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ShrinkOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
deleteAutomationType,
upsertAutomationType,
useAutomationTypeRegistry,
type AutomationBehaviorType,
type AutomationTypeRecord,
} from './automationTypeAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type AutomationTypeFormValue = {
id?: string;
name: string;
description: string;
behaviorType: AutomationBehaviorType;
enabled: boolean;
};
const EMPTY_FORM_VALUE: AutomationTypeFormValue = {
name: '',
description: '',
behaviorType: 'none',
enabled: true,
};
function toFormValue(automationType: AutomationTypeRecord | null): AutomationTypeFormValue {
if (!automationType) {
return EMPTY_FORM_VALUE;
}
return {
id: automationType.id,
name: automationType.name,
description: automationType.description,
behaviorType: automationType.behaviorType,
enabled: automationType.enabled,
};
}
export function AutomationTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { automationTypes, setAutomationTypes, isLoading, errorMessage } = useAutomationTypeRegistry();
const [selectedAutomationTypeId, setSelectedAutomationTypeId] = useState<string | null>(automationTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationTypeFormValue>();
const isPaneMaximized = maximizedPane !== 'none';
const selectedAutomationType = useMemo(
() => automationTypes.find((item) => item.id === selectedAutomationTypeId) ?? null,
[automationTypes, selectedAutomationTypeId],
);
useEffect(() => {
if (selectedAutomationTypeId && automationTypes.some((item) => item.id === selectedAutomationTypeId)) {
return;
}
setSelectedAutomationTypeId(automationTypes[0]?.id ?? null);
}, [automationTypes, selectedAutomationTypeId]);
useEffect(() => {
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [form, isCreating, selectedAutomationType]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
if (isCreating || selectedAutomationType) {
return;
}
setDetailMode('list');
}, [detailMode, isCreating, selectedAutomationType]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const openCreateForm = () => {
setIsCreating(true);
setSelectedAutomationTypeId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (automationTypeId: string) => {
setIsCreating(false);
setSelectedAutomationTypeId(automationTypeId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
if (!selectedAutomationType) {
return;
}
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) {
return;
}
const nextAutomationTypes = deleteAutomationType(automationTypes, selectedAutomationType.id);
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
setSelectedAutomationTypeId(savedAutomationTypes[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 삭제에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
if (!hasAccess) {
return (
<Card title="자동화 유형 관리" className="chat-type-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
/>
</Card>
);
}
return (
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="자동화 유형 관리"
className="chat-type-management-page__card"
extra={
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</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}> </Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${automationTypes.length}`}</Text>
</div>
{automationTypes.length > 0 ? (
<List
dataSource={automationTypes}
renderItem={(item) => {
const itemClassName =
item.id === selectedAutomationTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item';
return (
<List.Item
className={itemClassName}
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.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.description ? <MarkdownPreviewContent content={item.description} maxBlocks={3} /> : '설명 없음'}
</div>
</div>
</List.Item>
);
}}
/>
) : (
<Empty description="등록된 자동화 유형이 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? '자동화 유형 등록' : '자동화 유형 상세'}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
>
<div className="chat-type-management-page__editor">
{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}>{isCreating ? '신규 자동화 유형 등록' : selectedAutomationType?.name ?? '자동화 유형 수정'}</Title>
</div>
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
const nextAutomationTypes = upsertAutomationType(automationTypes, {
...values,
behaviorType:
selectedAutomationType?.behaviorType ?? values.behaviorType ?? EMPTY_FORM_VALUE.behaviorType,
});
setIsSaving(true);
setSaveErrorMessage('');
try {
const savedAutomationTypes = await setAutomationTypes(nextAutomationTypes);
const savedAutomationType = savedAutomationTypes.find(
(item) => item.id === values.id || item.name === values.name,
);
setIsCreating(false);
setSelectedAutomationTypeId(savedAutomationType?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="유형명"
name="name"
rules={[{ required: true, message: '유형명을 입력하세요.' }]}
>
<Input placeholder="예: 자동화 메모" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 처리 기준\n- 이 자동화 유형의 작업 규칙을 Markdown으로 정리하세요.\n\n## 실패 처리\n- 에러/롤백 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 설명이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedAutomationType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>
)}
</div>
);
}

View File

@@ -33,7 +33,6 @@ export function ChatRuntimeBridgeV2() {
chatTypeId: null,
chatTypeLabel: '',
chatTypeDescription: '',
chatTypeIsTemplate: false,
}),
[currentPage, focusedComponentId],
);

View File

@@ -1,27 +1,79 @@
.chat-type-management-page {
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page--detail {
container-type: inline-size;
}
.chat-type-management-page .ant-card,
.chat-type-management-page .ant-card-body,
.chat-type-management-page__card {
width: 100%;
height: 100%;
min-height: 0;
}
.chat-type-management-page .ant-card-head {
min-height: 52px;
padding: 0 14px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra {
padding: 10px 0;
}
.chat-type-management-page .ant-card-body {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 12px 14px;
}
.chat-type-management-page__list,
.chat-type-management-page__editor {
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
gap: 8px;
height: 100%;
overflow: hidden;
}
.chat-type-management-page__list .ant-list {
flex: 1;
min-height: 0;
overflow: auto;
}
.chat-type-management-page__editor-form {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__editor-form .ant-form-item {
margin-bottom: 8px;
}
.chat-type-management-page__list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
}
.chat-type-management-page__list-header .ant-typography {
margin-bottom: 0;
}
.chat-type-management-page__item {
@@ -44,3 +96,246 @@
.chat-type-management-page__item-description.ant-typography {
margin: 8px 0 10px;
}
.chat-type-management-page__item-description {
margin: 8px 0 10px;
}
.chat-type-management-page__item-description .markdown-preview > :last-child {
margin-bottom: 0;
}
.chat-type-management-page__markdown-field {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__field-label {
flex: 0 0 auto;
line-height: 1.2;
}
.chat-type-management-page__markdown-editor {
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__mobile-toggle {
display: none;
}
.chat-type-management-page__editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chat-type-management-page__markdown-grid {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
align-items: stretch;
flex: 1;
min-height: 0;
overflow: hidden;
}
.chat-type-management-page__markdown-grid--maximized {
grid-template-columns: minmax(0, 1fr);
}
.chat-type-management-page__markdown-pane {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__markdown-pane .ant-form-item {
flex: 1;
min-height: 0;
margin-bottom: 0;
}
.chat-type-management-page__markdown-pane--desktop-hidden {
display: none;
}
.chat-type-management-page__markdown-pane-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.chat-type-management-page__markdown-pane .ant-input-textarea,
.chat-type-management-page__markdown-pane .ant-input {
height: 100%;
}
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 180px;
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 180px;
overflow: auto !important;
resize: none;
}
.chat-type-management-page__markdown-preview {
width: 100%;
height: 100%;
min-height: 0;
border: 1px solid #f0f0f0;
border-radius: 12px;
background: #fafafa;
padding: 10px;
display: flex;
flex-direction: column;
gap: 6px;
overflow: hidden;
}
.chat-type-management-page__markdown-preview-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.chat-type-management-page__markdown-preview-body .markdown-preview > :last-child {
margin-bottom: 0;
}
.chat-type-management-page__form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 2px;
}
.chat-type-management-page__form-actions--compact {
padding-top: 0;
}
.chat-type-management-page__meta-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto;
gap: 8px 12px;
align-items: start;
}
.chat-type-management-page__meta-grid--hidden {
display: none;
}
.chat-type-management-page__meta-item {
min-width: 0;
}
.chat-type-management-page__meta-item .ant-form-item-label {
padding-bottom: 4px;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input {
min-height: 40px;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__editor,
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-form,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-editor,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
flex: 1;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
display: none;
}
.chat-type-management-page__card--pane-maximized .ant-card-body {
padding-bottom: 10px;
}
@media (max-width: 960px) {
.chat-type-management-page,
.chat-type-management-page .ant-card,
.chat-type-management-page .ant-card-body,
.chat-type-management-page__card,
.chat-type-management-page__list,
.chat-type-management-page__editor,
.chat-type-management-page__editor-form,
.chat-type-management-page__markdown-field,
.chat-type-management-page__markdown-editor,
.chat-type-management-page__markdown-grid,
.chat-type-management-page__markdown-pane,
.chat-type-management-page__markdown-preview {
min-height: 0;
}
.chat-type-management-page__list-header {
align-items: flex-start;
}
.chat-type-management-page .ant-card-head {
min-height: 48px;
padding: 0 10px;
}
.chat-type-management-page .ant-card-head-title,
.chat-type-management-page .ant-card-extra,
.chat-type-management-page .ant-card-body {
padding: 10px;
}
.chat-type-management-page__mobile-toggle {
display: inline-flex;
}
.chat-type-management-page__editor-toolbar {
flex-wrap: wrap;
}
.chat-type-management-page__meta-grid {
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr);
}
.chat-type-management-page__markdown-pane--mobile-hidden {
display: none;
}
.chat-type-management-page__markdown-textarea,
.chat-type-management-page__markdown-textarea textarea,
.chat-type-management-page__markdown-preview-body {
min-height: 0;
}
.chat-type-management-page__form-actions {
flex-wrap: wrap;
}
}

View File

@@ -1,6 +1,14 @@
import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd';
import {
ArrowsAltOutlined,
ArrowLeftOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
ShrinkOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview';
import {
canUseChatType,
CHAT_PERMISSION_ROLE_LABELS,
@@ -14,13 +22,12 @@ import {
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Paragraph, Text, Title } = Typography;
const { Text, Title } = Typography;
type ChatTypeFormValue = {
id?: string;
name: string;
description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[];
enabled: boolean;
};
@@ -28,7 +35,6 @@ type ChatTypeFormValue = {
const EMPTY_FORM_VALUE: ChatTypeFormValue = {
name: '',
description: '',
isTemplate: false,
permissions: ['token-user'],
enabled: true,
};
@@ -42,7 +48,6 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
id: chatType.id,
name: chatType.name,
description: chatType.description,
isTemplate: chatType.isTemplate,
permissions: chatType.permissions,
enabled: chatType.enabled,
};
@@ -52,12 +57,16 @@ export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
const selectedChatType = useMemo(
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
@@ -88,10 +97,32 @@ export function ChatTypeManagementPage() {
setDetailMode('list');
}, [detailMode, isCreating, selectedChatType]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const mediaQuery = window.matchMedia('(max-width: 960px)');
const update = () => {
setIsMobileViewport(mediaQuery.matches);
if (!mediaQuery.matches) {
setMobileView('edit');
}
};
update();
mediaQuery.addEventListener('change', update);
return () => {
mediaQuery.removeEventListener('change', update);
};
}, []);
const openCreateForm = () => {
setIsCreating(true);
setSelectedChatTypeId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
@@ -100,11 +131,13 @@ export function ChatTypeManagementPage() {
setIsCreating(false);
setSelectedChatTypeId(chatTypeId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
@@ -148,7 +181,11 @@ export function ChatTypeManagementPage() {
}
return (
<div className="chat-type-management-page">
<div
className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}${
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}`}
>
{detailMode === 'list' ? (
<Card
title="컨텍스트 권한 관리"
@@ -200,14 +237,17 @@ export function ChatTypeManagementPage() {
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
{item.isTemplate ? <Tag color="gold">릿</Tag> : null}
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
</Tag>
</Space>
<Paragraph className="chat-type-management-page__item-description">
{item.description || '기본 문맥 설명 없음'}
</Paragraph>
<div className="chat-type-management-page__item-description">
{item.description ? (
<MarkdownPreviewContent content={item.description} maxBlocks={3} />
) : (
'기본 문맥 설명 없음'
)}
</div>
<Space size={[6, 6]} wrap>
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
@@ -226,29 +266,17 @@ export function ChatTypeManagementPage() {
) : (
<Card
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
className="chat-type-management-page__card"
extra={
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
}
className={`chat-type-management-page__card${isPaneMaximized ? ' chat-type-management-page__card--pane-maximized' : ''}`}
>
<div className="chat-type-management-page__editor">
{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}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
<Text type="secondary"> , .</Text>
</div>
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
@@ -273,41 +301,187 @@ export function ChatTypeManagementPage() {
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<Form.Item
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="컨텍스트명"
name="name"
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--permissions"
label="권한 대상"
name="permissions"
>
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--enabled"
label="사용 여부"
name="enabled"
valuePropName="checked"
>
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">
</Text>
<div className="chat-type-management-page__markdown-editor">
<div className="chat-type-management-page__editor-toolbar">
{isMobileViewport ? (
<Segmented
className="chat-type-management-page__mobile-toggle"
options={[
{ label: '입력', value: 'edit' },
{ label: '미리보기', value: 'preview' },
]}
value={mobileView}
onChange={(value) => {
setMobileView(value as 'edit' | 'preview');
setMaximizedPane('none');
}}
/>
) : (
<Space size={8} wrap>
<Button
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '입력 축소' : '입력 최대화'}
</Button>
<Button
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '미리보기 축소' : '미리보기 최대화'}
</Button>
</Space>
)}
</div>
<div
className={`chat-type-management-page__markdown-grid${
maximizedPane !== 'none' ? ' chat-type-management-page__markdown-grid--maximized' : ''
}`}
>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'preview'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'preview' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'edit' ? 'primary' : 'default'}
icon={maximizedPane === 'edit' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'edit' ? 'none' : 'edit'));
}}
>
{maximizedPane === 'edit' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<Form.Item name="description" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={
'## 기본 처리\n- 이 채팅 유형의 실행 기준을 Markdown으로 정리하세요.\n\n## 검증\n- 브라우저/API 확인 기준'
}
/>
</Form.Item>
</div>
<div
className={`chat-type-management-page__markdown-pane${
isMobileViewport && mobileView === 'edit'
? ' chat-type-management-page__markdown-pane--mobile-hidden'
: ''
}${
maximizedPane === 'edit' ? ' chat-type-management-page__markdown-pane--desktop-hidden' : ''
}`}
>
<div className="chat-type-management-page__markdown-preview">
<div className="chat-type-management-page__markdown-pane-header">
<Text type="secondary"></Text>
{isMobileViewport ? (
<Button
size="small"
type={maximizedPane === 'preview' ? 'primary' : 'default'}
icon={maximizedPane === 'preview' ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
onClick={() => {
setMaximizedPane((current) => (current === 'preview' ? 'none' : 'preview'));
}}
>
{maximizedPane === 'preview' ? '축소' : '최대화'}
</Button>
) : null}
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.description !== next.description}>
{({ getFieldValue }) => {
const description = String(getFieldValue('description') ?? '').trim();
return description ? (
<MarkdownPreviewContent content={description} />
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="미리보기할 문맥 설명이 없습니다."
/>
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className={`chat-type-management-page__form-actions${
isPaneMaximized ? ' chat-type-management-page__form-actions--compact' : ''
}`}
>
<Input placeholder="예: 운영 문의" />
</Form.Item>
<Form.Item label="기본 문맥 설명" name="description">
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 8 }}
placeholder="이 컨텍스트에서 기본으로 참고해야 할 문맥을 입력하세요."
/>
</Form.Item>
<Form.Item label="템플릿 요청 여부" name="isTemplate" valuePropName="checked">
<Switch checkedChildren="템플릿" unCheckedChildren="일반" />
</Form.Item>
<Form.Item label="권한 대상" name="permissions">
<Checkbox.Group
options={[
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
]}
/>
</Form.Item>
<Form.Item label="사용 여부" name="enabled" valuePropName="checked">
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
<Button type="primary" htmlType="submit" loading={isSaving}>
{isCreating ? '등록' : '수정 저장'}
</Button>
<Button onClick={openCreateForm} disabled={isSaving}>
</Button>
</Space>
<Space wrap>
{!isCreating && selectedChatType ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
</Button>
</Space>
</div>
</Form>
</div>
</Card>

View File

@@ -76,11 +76,10 @@ type ChatTypeOption = {
value: string;
label: string;
description: string;
isTemplate: boolean;
disabled?: boolean;
};
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
type PreviewItem = {
id: string;
@@ -98,7 +97,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
@@ -109,7 +107,6 @@ type PendingContextConfirm = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
@@ -660,6 +657,21 @@ function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
@@ -675,6 +687,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
@@ -687,6 +703,10 @@ function classifyPreviewKind(url: string): PreviewKind {
return 'pdf';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}
@@ -869,7 +889,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
value: item.id,
label: item.name,
description: item.description,
isTemplate: item.isTemplate,
disabled: !isAllowed,
};
}),
@@ -959,7 +978,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: selectedChatType?.id ?? null,
chatTypeLabel: selectedChatType?.name ?? '',
chatTypeDescription: selectedChatType?.description ?? '',
chatTypeIsTemplate: selectedChatType?.isTemplate ?? false,
};
const {
conversationItems,
@@ -2124,7 +2142,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
chatTypeId: request.chatTypeId,
chatTypeLabel: request.chatTypeLabel,
chatTypeDescription: request.chatTypeDescription,
chatTypeIsTemplate: request.chatTypeIsTemplate,
requestId: request.requestId,
mode: request.mode,
},

View File

@@ -10,6 +10,7 @@ import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage
import { useSearchLayer } from '../../layer';
import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
@@ -169,6 +170,10 @@ export function MainContent({
return <HistoryPage />;
}
if (selectionId === 'page:plans:automation-type') {
return <AutomationTypeManagementPage />;
}
const planStatus = getPlanStatusFromWindowSelection(selectionId);
if (planStatus) {

View File

@@ -15,7 +15,6 @@ import {
Alert,
Button,
Checkbox,
Divider,
Drawer,
Dropdown,
Grid,
@@ -926,9 +925,7 @@ export function MainHeader({
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [serverRestartingKey, setServerRestartingKey] = useState<
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
>(null);
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess();
@@ -1698,50 +1695,6 @@ export function MainHeader({
});
};
const handleRestartCommandRunner = async () => {
if (!hasAccess || serverRestartingKey) {
return;
}
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey('command-runner');
try {
const result = await restartServerCommand('command-runner');
setServerRestartFeedback({
tone: 'success',
message:
result.restartState === 'accepted'
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
: 'Command runner 배포 및 재기동을 완료했습니다.',
});
} catch (error) {
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
});
} finally {
setServerRestartingKey(null);
}
};
const handleConfirmRestartCommandRunner = () => {
if (!hasAccess || serverRestartingKey) {
return;
}
modalApi.confirm({
title: 'Command runner 배포 및 재기동',
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
okText: '배포 및 재기동',
cancelText: '취소',
onOk: async () => {
await handleRestartCommandRunner();
},
});
};
const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) {
return;
@@ -3120,24 +3073,6 @@ export function MainHeader({
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
<Divider style={{ marginBlock: 4 }} />
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Command runner</Text>
<Text type="secondary">
command runner .
</Text>
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
<Button
block
type="primary"
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'command-runner'}
disabled={!canRestartServers}
onClick={handleConfirmRestartCommandRunner}
>
command runner
</Button>
</Space>
</>
) : null}
{activeSettingsModal === 'notification' ? (

View File

@@ -363,11 +363,15 @@
position: fixed;
inset: 72px 0 0;
z-index: 40;
width: 100% !important;
max-width: 100%;
width: 100vw !important;
min-width: 100vw !important;
max-width: 100vw;
flex: 0 0 100vw !important;
height: calc(100vh - 72px);
border-right: 0;
background: rgba(255, 255, 255, 0.98);
transition: none !important;
overflow: hidden;
}
.app-sider--mobile-inline.ant-layout-sider {
@@ -385,6 +389,9 @@
gap: 12px;
height: 100%;
padding: 12px 10px;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.app-sider__intro {

View File

@@ -0,0 +1,401 @@
import { useEffect, useRef, useState } from 'react';
import type { BoardAutomationType } from '../../features/board/types';
import type { PlanAutomationType } from '../../features/planBoard/types';
import { appendClientIdHeader } from './clientIdentity';
export const AUTOMATION_BEHAVIOR_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
] as const;
export type AutomationBehaviorType = (typeof AUTOMATION_BEHAVIOR_TYPES)[number];
export type AutomationTypeRecord = {
id: string;
name: string;
description: string;
behaviorType: AutomationBehaviorType;
enabled: boolean;
updatedAt: string;
};
export type AutomationTypeInput = {
id?: string;
name: string;
description?: string;
behaviorType?: AutomationBehaviorType;
enabled?: boolean;
};
const AUTOMATION_TYPES_API_PATH = '/automation-types';
const AUTOMATION_TYPE_SYNC_EVENT = 'work-app:automation-types-changed';
const AUTOMATION_TYPE_REQUEST_TIMEOUT_MS = 8000;
export const AUTOMATION_BEHAVIOR_LABELS: Record<AutomationBehaviorType, string> = {
none: '기본유형',
plan: '작업 요청 등록',
command_execution: 'Command 실행',
non_source_work: '비 소스작업',
auto_worker: 'autoWorker',
};
const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [
{
id: 'none',
name: '기본유형',
description:
'## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.',
behaviorType: 'none',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'plan',
name: '작업 요청 등록',
description: 'Plan 등록/문서형 자동화 요청으로 처리합니다.',
behaviorType: 'plan',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'command_execution',
name: 'Command 실행',
description: '저장소 수정 없이 명령 실행/조회 중심으로 처리합니다.',
behaviorType: 'command_execution',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'non_source_work',
name: '비 소스작업',
description: '문서/운영/확인 등 비소스 작업으로 처리합니다.',
behaviorType: 'non_source_work',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
{
id: 'auto_worker',
name: 'autoWorker',
description: '자동화 작업메모로 처리하며, 세부 절차는 현재 운영 설정을 따릅니다.',
behaviorType: 'auto_worker',
enabled: true,
updatedAt: '2026-04-23T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
export function normalizeAutomationTypeId(
value: unknown,
): PlanAutomationType | BoardAutomationType {
const normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'plan_registration') {
return 'plan';
}
if (normalized === 'general_development') {
return 'auto_worker';
}
return normalized || 'none';
}
function normalizeBehaviorType(value: unknown): AutomationBehaviorType {
const normalized = normalizeAutomationTypeId(value);
return AUTOMATION_BEHAVIOR_TYPES.includes(normalized as AutomationBehaviorType)
? (normalized as AutomationBehaviorType)
: 'none';
}
function getSemanticKey(record: Pick<AutomationTypeRecord, 'name' | 'behaviorType'>) {
return `${record.behaviorType}:${normalizeText(record.name).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR')}`;
}
function compareUpdatedAt(left: AutomationTypeRecord, right: AutomationTypeRecord) {
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 normalizeAutomationType(record: Partial<AutomationTypeRecord>): AutomationTypeRecord | null {
const name = normalizeText(record.name);
if (!name) {
return null;
}
const id =
normalizeText(record.id) ||
`automation-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return {
id,
name,
description: normalizeText(record.description),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationTypes(items: Partial<AutomationTypeRecord>[]) {
const byId = new Map<string, AutomationTypeRecord>();
const bySemanticKey = new Map<string, AutomationTypeRecord>();
items
.map((item) => normalizeAutomationType(item))
.filter((item): item is AutomationTypeRecord => Boolean(item))
.forEach((item) => {
const currentById = byId.get(item.id);
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (const item of byId.values()) {
const semanticKey = getSemanticKey(item);
const current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
const values = Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
return values.length > 0 ? values : DEFAULT_AUTOMATION_TYPES;
}
function emitAutomationTypesChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(AUTOMATION_TYPE_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_TYPE_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${AUTOMATION_TYPES_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() || '자동화 처리 유형 요청에 실패했습니다.');
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestAutomationTypes<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 loadAutomationTypesFromServer() {
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] | null }>({
method: 'GET',
});
if (response.automationTypes == null) {
return DEFAULT_AUTOMATION_TYPES;
}
return sanitizeAutomationTypes(response.automationTypes);
}
async function saveAutomationTypesToServer(items: AutomationTypeRecord[]) {
const resolved = sanitizeAutomationTypes(items);
const response = await requestAutomationTypes<{ ok: boolean; automationTypes: Partial<AutomationTypeRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ automationTypes: resolved }),
});
return sanitizeAutomationTypes(response.automationTypes);
}
export function upsertAutomationType(items: AutomationTypeRecord[], input: AutomationTypeInput) {
const nextItem = normalizeAutomationType(input);
if (!nextItem) {
return sanitizeAutomationTypes(items);
}
const nextItems = items.filter((item) => item.id !== nextItem.id);
nextItems.push(nextItem);
return sanitizeAutomationTypes(nextItems);
}
export function deleteAutomationType(items: AutomationTypeRecord[], automationTypeId: string) {
const normalizedId = normalizeText(automationTypeId);
if (!normalizedId) {
return sanitizeAutomationTypes(items);
}
return sanitizeAutomationTypes(items.filter((item) => item.id !== normalizedId));
}
export function resolveAutomationTypeLabel(items: AutomationTypeRecord[], automationTypeId: string | null | undefined) {
const normalizedId = normalizeAutomationTypeId(automationTypeId);
return items.find((item) => item.id === normalizedId)?.name ?? normalizedId;
}
export function buildAutomationTypeOptions(
items: AutomationTypeRecord[],
value?: string | null,
) {
const normalizedValue = normalizeAutomationTypeId(value);
const enabledItems = items.filter((item) => item.enabled);
const currentItem = items.find((item) => item.id === normalizedValue);
const source = currentItem && !enabledItems.some((item) => item.id === currentItem.id) ? [...enabledItems, currentItem] : enabledItems;
if (source.length === 0) {
return [{ label: '선택 안함', value: 'none' }];
}
return source.map((item) => ({
label: item.name,
value: item.id,
}));
}
export function useAutomationTypeRegistry() {
const [automationTypes, setAutomationTypesState] = useState<AutomationTypeRecord[]>(DEFAULT_AUTOMATION_TYPES);
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 nextAutomationTypes = await loadAutomationTypesFromServer();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(nextAutomationTypes);
}
} catch (error) {
if (!cancelled && mountedRef.current) {
setAutomationTypesState(DEFAULT_AUTOMATION_TYPES);
setErrorMessage(error instanceof Error ? error.message : '자동화 처리 유형을 불러오지 못했습니다.');
}
} finally {
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
return () => {
cancelled = true;
window.removeEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
};
}, []);
const setAutomationTypes = async (nextItems: AutomationTypeRecord[]) => {
const saved = await saveAutomationTypesToServer(nextItems);
if (mountedRef.current) {
setAutomationTypesState(saved);
setErrorMessage('');
}
emitAutomationTypesChange();
return saved;
};
return {
automationTypes,
setAutomationTypes,
isLoading,
errorMessage,
};
}

View File

@@ -7,7 +7,6 @@ export type ChatTypeRecord = {
id: string;
name: string;
description: string;
isTemplate: boolean;
permissions: ChatPermissionRole[];
enabled: boolean;
updatedAt: string;
@@ -17,7 +16,6 @@ export type ChatTypeInput = {
id?: string;
name: string;
description?: string;
isTemplate?: boolean;
permissions: ChatPermissionRole[];
enabled?: boolean;
};
@@ -39,8 +37,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request',
name: '일반 요청',
description:
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
isTemplate: false,
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 필요하면 브라우저와 API를 직접 테스트합니다.\n- UI 변경이 있으면 모바일 화면 캡처와 preview 리소스를 함께 남깁니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',
@@ -48,8 +45,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'api-request-template',
name: 'API요청',
description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)',
isTemplate: true,
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
@@ -90,15 +86,14 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
id,
name,
description: normalizeText(record.description),
isTemplate: record.isTemplate === true,
permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false,
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
};
}
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name'>) {
return buildChatTypeNameKey(record.name);
}
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
@@ -314,7 +309,6 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
id: input.id,
name: input.name,
description: input.description,
isTemplate: input.isTemplate,
permissions: input.permissions,
enabled: input.enabled,
updatedAt: new Date().toISOString(),

View File

@@ -10,7 +10,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
@@ -21,7 +20,6 @@ type PendingContextConfirm = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
@@ -30,7 +28,6 @@ type SelectedChatType = {
id: string;
name: string;
description: string;
isTemplate: boolean;
} | null;
type RecentContextSummary = {
@@ -170,7 +167,7 @@ export function useConversationComposerController({
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
@@ -180,7 +177,6 @@ export function useConversationComposerController({
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeIsTemplate,
retryCount: 0,
failed: false,
};
@@ -335,26 +331,23 @@ export function useConversationComposerController({
return;
}
if (!selectedChatType.isTemplate) {
const recentContext = summarizeRecentContext(
messagesRef.current,
appConfigChat.maxContextMessages,
appConfigChat.maxContextChars,
);
const recentContext = summarizeRecentContext(
messagesRef.current,
appConfigChat.maxContextMessages,
appConfigChat.maxContextChars,
);
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: false,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
executeSendMessage({
@@ -363,7 +356,6 @@ export function useConversationComposerController({
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: selectedChatType.isTemplate,
includedContextCount: 0,
omittedContextCount: 0,
});

View File

@@ -17,7 +17,6 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};

View File

@@ -5,7 +5,7 @@ type PreviewItem = {
id: string;
label: string;
url: string;
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
kind: 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
source: 'message' | 'context';
};

View File

@@ -87,6 +87,7 @@ function parseRoute(pathname: string): {
first === 'charts' ||
first === 'schedule' ||
first === 'history' ||
first === 'automation-type' ||
first === 'server-command')
) {
return {
@@ -146,6 +147,22 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
return !hasAccess && topMenu !== 'docs';
}
function getIsMobileViewport() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia('(max-width: 768px)').matches;
}
function resolveSidebarCollapsedForViewport(isMobileViewport: boolean, topMenu: TopMenuKey) {
if (!isMobileViewport) {
return false;
}
return topMenu !== 'docs';
}
function resolveSidebarOpenKeys(
topMenu: TopMenuKey,
hasAccess: boolean,
@@ -189,9 +206,11 @@ export function MainLayout() {
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
const layoutData = useMainLayoutData();
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsMobileViewport(), routeState.topMenu),
);
const [contentExpanded, setContentExpanded] = useState(false);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
);
@@ -221,12 +240,7 @@ export function MainLayout() {
}, []);
useEffect(() => {
if (!isMobileViewport) {
setSidebarCollapsed(false);
return;
}
setSidebarCollapsed(routeState.topMenu !== 'docs');
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, routeState.topMenu));
}, [isMobileViewport, routeState.topMenu]);
useEffect(() => {
@@ -370,7 +384,6 @@ export function MainLayout() {
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
const initialSelectedPlanId = Number(searchParams.get('planId'));
const initialSelectedWorkId = searchParams.get('workId');
@@ -414,7 +427,7 @@ export function MainLayout() {
}}
onChangeTopMenu={(menu) => {
navigate(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(false);
setSidebarCollapsed(resolveSidebarCollapsedForViewport(isMobileViewport, menu));
}}
onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
@@ -428,13 +441,12 @@ export function MainLayout() {
)}
<Layout>
{contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
hasAccess={hasAccess}
sidebarCollapsed={sidebarCollapsed}
isMobileViewport={isMobileViewport}
mobileInline={showInlineMobileDocsSidebar}
openKeys={sidebarOpenKeys}
apiMenuItems={apiMenuItems}
docsMenuItems={docsMenuItems}
@@ -455,7 +467,7 @@ export function MainLayout() {
}}
onSelectDocsMenu={(key) => {
navigate(buildDocsPath(key));
if (isMobileViewport && !showInlineMobileDocsSidebar) {
if (isMobileViewport) {
setSidebarCollapsed(true);
}
}}
@@ -486,7 +498,7 @@ export function MainLayout() {
/>
)}
{isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
{isMobileViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet />
</MainContent>

View File

@@ -145,6 +145,18 @@ export function buildSearchOptions({
},
...(hasAccess
? [
{
id: 'page:plans:automation-type',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
group: 'Page',
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('automation-type'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,

View File

@@ -30,7 +30,6 @@ export type ChatViewContext = {
chatTypeId: string | null;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
};
export type ChatConversationSummary = {

View File

@@ -202,7 +202,6 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
chatTypeIsTemplate: context.chatTypeIsTemplate,
},
}),
);

View File

@@ -27,6 +27,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
charts: '차트',
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'server-command': 'Command',
};
@@ -50,6 +51,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
charts: 'plan-menu-charts',
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'server-command': 'plan-menu-server-command',
};

View File

@@ -101,6 +101,18 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
{
id: 'page:plans:automation-type',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['automation-type']}`,
group: 'Page',
keywords: ['plans', 'plan', 'automation type', '자동화 유형', '자동화 처리', '유형 관리'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('automation-type');
setFocusedComponentId(null);
},
onSelectWindow,
},
...(hasAccess
? [
{

View File

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

View File

@@ -7,7 +7,16 @@ import type { PlanFilterStatus } from '../../features/planBoard';
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
export type HeaderTopMenuKey = 'docs' | 'plans';
export type ApiSectionKey = 'components' | 'widgets';
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
export type PlanSectionKey =
| PlanFilterStatus
| 'release'
| 'release-review'
| 'board'
| 'charts'
| 'schedule'
| 'history'
| 'automation-type'
| 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
@@ -39,6 +48,7 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
charts: '차트',
schedule: '스케줄',
history: '이력',
'automation-type': '자동화 유형',
'server-command': 'Command',
};
@@ -57,6 +67,7 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
charts: 'plan-menu-charts',
schedule: 'plan-menu-schedule',
history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type',
'server-command': 'plan-menu-server-command',
};
@@ -188,6 +199,10 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
key: 'history',
label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.history),
},
{
key: 'automation-type',
label: renderPlanMenuLabel('automation-type', PLAN_SIDEBAR_LABELS['automation-type']),
},
],
},
{