chore: sync local workspace changes

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

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { getOrCreateClientId } from './app/main/clientIdentity';
import { reportClientError } from './app/main/errorLogApi';
import { AppShell } from './app/main';
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
import { bindViewportCssVars } from './app/main/viewportCssVars';
import { reportVisitorPageView } from './features/history/api';
import { useAppStore } from './store';
@@ -39,6 +40,8 @@ function App() {
const lastTrackedPageIdRef = useRef<string | null>(null);
const [showInitialLoading, setShowInitialLoading] = useState(true);
useLayoutEffect(() => bindViewportCssVars(), []);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;

View File

@@ -5,18 +5,20 @@ import { ChatPage } from './pages/ChatPage';
import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage';
import { buildDocsPath, buildPlansPath } from './routes';
import { buildChatPath, buildDocsPath } from './routes';
export function AppShell() {
return (
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Navigate to={buildPlansPath('all')} replace />} />
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
<Route path="docs/:folder" element={<DocsPage />} />
<Route path="apis/:section" element={<ApisPage />} />
<Route path="plans/:section" element={<PlansPage />} />
<Route path="chat/:section" element={<ChatPage />} />
<Route path="play/layout" element={<PlayPage />} />
<Route path="play/test" element={<PlayPage />} />
<Route path="play/cbt" element={<PlayPage />} />
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
</Route>

View File

@@ -0,0 +1,426 @@
import {
ArrowsAltOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
SaveOutlined,
ShrinkOutlined,
UnorderedListOutlined,
} 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 {
deleteChatDefaultContext,
pruneChatRoomContextSettings,
pruneChatTypeDefaultSelections,
upsertChatDefaultContext,
useChatContextSettingsRegistry,
type ChatDefaultContextRecord,
} from './chatContextSettingsAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
const { Text, Title } = Typography;
type ChatDefaultContextFormValue = {
id?: string;
title: string;
content: string;
enabled: boolean;
};
const EMPTY_FORM_VALUE: ChatDefaultContextFormValue = {
title: '',
content: '',
enabled: true,
};
function toFormValue(record: ChatDefaultContextRecord | null): ChatDefaultContextFormValue {
if (!record) {
return EMPTY_FORM_VALUE;
}
return {
id: record.id,
title: record.title,
content: record.content,
enabled: record.enabled,
};
}
export function ChatDefaultContextManagementPage() {
const { hasAccess } = useTokenAccess();
const {
defaultContexts,
chatTypeDefaults,
roomContexts,
errorMessage: contextSettingsErrorMessage,
setStore,
} = useChatContextSettingsRegistry();
const [selectedContextId, setSelectedContextId] = useState<string | null>(defaultContexts[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 [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<ChatDefaultContextFormValue>();
const selectedContext = useMemo(
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
[defaultContexts, selectedContextId],
);
useEffect(() => {
if (selectedContextId && defaultContexts.some((item) => item.id === selectedContextId)) {
return;
}
setSelectedContextId(defaultContexts[0]?.id ?? null);
}, [defaultContexts, selectedContextId]);
useEffect(() => {
if (detailMode !== 'detail') {
return;
}
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedContext));
}, [detailMode, form, isCreating, selectedContext]);
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);
setSelectedContextId(null);
setDetailMode('detail');
setMaximizedPane('none');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (contextId: string) => {
setIsCreating(false);
setSelectedContextId(contextId);
setDetailMode('detail');
setMaximizedPane('none');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setMaximizedPane('none');
};
const handleDelete = async () => {
if (!selectedContext) {
return;
}
if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) {
return;
}
const nextDefaultContexts = deleteChatDefaultContext(defaultContexts, selectedContext.id);
const nextChatTypeDefaults = pruneChatTypeDefaultSelections(chatTypeDefaults, selectedContext.id);
const nextRoomContexts = pruneChatRoomContextSettings(roomContexts, selectedContext.id);
await setStore({
defaultContexts: nextDefaultContexts,
chatTypeDefaults: nextChatTypeDefaults,
roomContexts: nextRoomContexts,
});
setSelectedContextId(nextDefaultContexts[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
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' : ''}${
maximizedPane !== 'none' ? ' 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">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Text type="secondary">{`${defaultContexts.length}`}</Text>
</div>
{defaultContexts.length > 0 ? (
<List
dataSource={defaultContexts}
renderItem={(item) => (
<List.Item
className={
item.id === selectedContextId
? '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 />}
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>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="chat-type-management-page__item-description">
{item.content ? <MarkdownPreviewContent content={item.content} maxBlocks={3} /> : '본문 없음'}
</div>
</div>
</List.Item>
)}
/>
) : (
<Empty description="등록된 기본 유형이 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? '기본 유형 등록' : '기본 유형 상세'}
className={`chat-type-management-page__card${
maximizedPane !== 'none' ? ' chat-type-management-page__card--pane-maximized' : ''
}`}
extra={
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
<Button shape="circle" icon={<PlusOutlined />} aria-label="새 입력" onClick={openCreateForm} />
{!isCreating && selectedContext ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
aria-label="삭제"
onClick={() => {
void handleDelete();
}}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
}
>
<div className="chat-type-management-page__editor">
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
<Form
className="chat-type-management-page__editor-form"
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
onFinish={async (values) => {
setSaveErrorMessage('');
try {
const nextDefaultContexts = upsertChatDefaultContext(defaultContexts, values);
const savedContext = nextDefaultContexts.find((item) => item.id === values.id || item.title === values.title) ?? null;
await setStore({
defaultContexts: nextDefaultContexts,
chatTypeDefaults,
roomContexts,
});
setIsCreating(false);
setSelectedContextId(savedContext?.id ?? null);
setDetailMode('detail');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '기본 유형 저장에 실패했습니다.');
}
}}
>
<Form.Item name="id" hidden>
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<div className={`chat-type-management-page__meta-grid${maximizedPane !== 'none' ? ' 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="title"
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>
</div>
<Form.Item name="content" noStyle>
<Input.TextArea
autoSize={isMobileViewport ? false : { minRows: 10, maxRows: 18 }}
className="chat-type-management-page__markdown-textarea"
placeholder={'## 적용 기준\n- 기본 유형의 공통 규칙을 Markdown으로 정의하세요.'}
/>
</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>
</div>
<div className="chat-type-management-page__markdown-preview-body">
<Form.Item noStyle shouldUpdate={(prev, next) => prev.content !== next.content}>
{({ getFieldValue }) => {
const content = String(getFieldValue('content') ?? '').trim();
return content ? (
<MarkdownPreviewContent content={content} />
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="미리보기할 기본 유형 본문이 없습니다." />
);
}}
</Form.Item>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Form>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAppStore } from '../../store';
import { getChatClientSessionId } from './mainChatPanel';
import { chatConnectionGateway, chatGateway } from './chatV2';
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
@@ -17,8 +17,26 @@ function isStandaloneDisplayMode() {
export function ChatRuntimeBridgeV2() {
const { currentPage, focusedComponentId } = useAppStore();
const [sessionId] = useState(() => getChatClientSessionId());
const location = useLocation();
const [, setMessages] = useState<ChatMessage[]>([]);
const sessionId = useMemo(() => {
if (typeof window === 'undefined') {
return '';
}
if (currentPage.topMenu !== 'chat') {
return '';
}
const currentUrl = new URL(window.location.href);
const pathname = currentUrl.pathname.replace(/\/+$/, '') || '/';
if (pathname !== '/chat/live') {
return '';
}
return currentUrl.searchParams.get('sessionId')?.trim() || '';
}, [currentPage.topMenu, location.pathname, location.search]);
const currentContext: ChatViewContext = useMemo(
() => ({

View File

@@ -7,10 +7,6 @@
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 {
@@ -61,6 +57,7 @@
flex: 1;
min-height: 0;
overflow: auto;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page__editor-form {
@@ -81,7 +78,11 @@
flex-direction: column;
gap: 4px;
overflow: auto;
padding: 0 0 8px;
padding: 0 0 calc(10px + env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page__editor-scroll:has(.chat-type-management-page__markdown-grid--maximized) {
overflow: hidden;
}
.chat-type-management-page__editor-form .ant-form-item {
@@ -128,6 +129,55 @@
margin-bottom: 0;
}
.chat-type-management-page__default-context-field {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.82);
border: 1px solid #e2e8f0;
}
.chat-type-management-page__default-context-field--hidden {
display: none;
}
.chat-type-management-page__default-context-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.chat-type-management-page__default-context-options {
width: 100%;
}
.chat-type-management-page__default-context-space {
width: 100%;
}
.chat-type-management-page__default-context-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #e5e7eb;
}
.chat-type-management-page__default-context-option-copy {
padding-left: 24px;
}
.chat-type-management-page__default-context-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chat-type-management-page__markdown-field {
width: 100%;
flex: 1;
@@ -190,6 +240,7 @@
.chat-type-management-page__markdown-grid--maximized {
grid-template-columns: minmax(0, 1fr);
min-height: min(720px, calc(100dvh - 236px));
}
.chat-type-management-page__markdown-pane {
@@ -236,13 +287,13 @@
.chat-type-management-page__markdown-textarea {
height: 100% !important;
min-height: 360px;
min-height: clamp(360px, calc(100dvh - 360px), 720px);
resize: none;
}
.chat-type-management-page__markdown-textarea textarea {
height: 100% !important;
min-height: 360px;
min-height: clamp(360px, calc(100dvh - 360px), 720px);
overflow: auto !important;
resize: none;
}
@@ -325,12 +376,17 @@
.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__editor-scroll,
.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__editor-scroll {
overflow: hidden;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__field-label {
display: none;
}
@@ -414,6 +470,10 @@
align-items: start;
}
.chat-type-management-page__default-context-header {
flex-direction: column;
}
.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content {
justify-content: flex-end;
}
@@ -421,6 +481,8 @@
.chat-type-management-page__markdown-grid {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -465,6 +527,110 @@
overflow: auto !important;
}
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane
.ant-form-item,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-preview {
min-height: clamp(220px, calc(100dvh - 560px), 320px);
}
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-field,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-editor,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-grid,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-preview {
height: auto;
overflow: visible;
}
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane
.ant-form-item,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane
.ant-form-item-control,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane
.ant-form-item-control-input,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-pane
.ant-form-item-control-input-content {
flex: none;
height: auto;
}
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-textarea,
.chat-type-management-page:not(.chat-type-management-page--pane-maximized)
.chat-type-management-page__markdown-textarea textarea {
height: auto !important;
min-height: clamp(320px, calc(100dvh - 430px), 520px) !important;
}
.chat-type-management-page--pane-maximized {
height: calc(100dvh - 52px);
max-height: calc(100dvh - 52px);
}
.chat-type-management-page--pane-maximized .ant-card-head {
min-height: 44px;
}
.chat-type-management-page--pane-maximized .ant-card-head-title,
.chat-type-management-page--pane-maximized .ant-card-extra {
padding-top: 4px;
padding-bottom: 4px;
}
.chat-type-management-page--pane-maximized .ant-card-body {
padding: 4px 8px calc(10px + env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page--pane-maximized .chat-type-management-page__card,
.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__editor-scroll,
.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,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-preview,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea,
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-textarea textarea {
min-height: 0;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-scroll {
gap: 0;
padding-bottom: 2px;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__editor-toolbar {
display: none;
}
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-grid {
height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
min-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 124px - env(safe-area-inset-bottom, 0px));
}
.chat-type-management-page--pane-maximized .chat-type-management-page__markdown-pane {
gap: 4px;
}
.chat-type-management-page--pane-maximized
.chat-type-management-page__markdown-pane
.ant-form-item-control-input-content {
padding-bottom: 2px;
}
.chat-type-management-page__markdown-preview {
padding: 8px 10px;
}

View File

@@ -20,6 +20,11 @@ import {
type ChatPermissionRole,
type ChatTypeRecord,
} from './chatTypeAccess';
import {
resolveChatTypeDefaultContextIds,
upsertChatTypeDefaultContextSelection,
useChatContextSettingsRegistry,
} from './chatContextSettingsAccess';
import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css';
@@ -57,6 +62,13 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
const {
defaultContexts,
chatTypeDefaults,
roomContexts,
errorMessage: contextSettingsErrorMessage,
setStore,
} = useChatContextSettingsRegistry();
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
const [isMobileViewport, setIsMobileViewport] = useState(false);
const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit');
@@ -65,6 +77,7 @@ export function ChatTypeManagementPage() {
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
const [form] = Form.useForm<ChatTypeFormValue>();
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none';
@@ -89,7 +102,14 @@ export function ChatTypeManagementPage() {
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
}, [detailMode, form, isCreating, selectedChatType]);
setSelectedDefaultContextIds(
isCreating
? []
: resolveChatTypeDefaultContextIds(chatTypeDefaults, selectedChatType?.id).filter((contextId) =>
defaultContexts.some((context) => context.id === contextId && context.enabled),
),
);
}, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]);
useEffect(() => {
if (detailMode !== 'detail') {
@@ -268,6 +288,7 @@ export function ChatTypeManagementPage() {
>
<div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
@@ -279,6 +300,9 @@ export function ChatTypeManagementPage() {
dataSource={chatTypes}
renderItem={(item) => {
const isCurrentUserAllowed = canUseChatType(item, userRoles);
const linkedDefaultContexts = resolveChatTypeDefaultContextIds(chatTypeDefaults, item.id)
.map((contextId) => defaultContexts.find((context) => context.id === contextId))
.filter((context): context is NonNullable<typeof context> => Boolean(context));
const itemClassName =
item.id === selectedChatTypeId
? 'chat-type-management-page__item chat-type-management-page__item--active'
@@ -322,6 +346,11 @@ export function ChatTypeManagementPage() {
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
))}
{linkedDefaultContexts.map((context) => (
<Tag key={`${item.id}-${context.id}`} color="gold">
{context.title}
</Tag>
))}
</Space>
</div>
</List.Item>
@@ -341,6 +370,7 @@ export function ChatTypeManagementPage() {
>
<div className="chat-type-management-page__editor">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{contextSettingsErrorMessage ? <Alert showIcon type="error" message={contextSettingsErrorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<Form
@@ -356,6 +386,14 @@ export function ChatTypeManagementPage() {
try {
const savedChatTypes = await setChatTypes(nextChatTypes);
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
const nextChatTypeDefaults = savedChatType
? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds)
: chatTypeDefaults;
await setStore({
defaultContexts,
chatTypeDefaults: nextChatTypeDefaults,
roomContexts,
});
setIsCreating(false);
setSelectedChatTypeId(savedChatType?.id ?? null);
setDetailMode('detail');
@@ -400,6 +438,60 @@ export function ChatTypeManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item>
</div>
<div className={`chat-type-management-page__default-context-field${isPaneMaximized ? ' chat-type-management-page__default-context-field--hidden' : ''}`}>
<div className="chat-type-management-page__default-context-header">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
{defaultContexts.filter((context) => context.enabled).length > 0 ? (
<>
<Checkbox.Group
className="chat-type-management-page__default-context-options"
value={selectedDefaultContextIds}
onChange={(checkedValues) => {
setSelectedDefaultContextIds(
checkedValues
.map((value) => String(value).trim())
.filter((value) => defaultContexts.some((context) => context.id === value && context.enabled)),
);
}}
>
<Space direction="vertical" size={10} className="chat-type-management-page__default-context-space">
{defaultContexts
.filter((context) => context.enabled)
.map((context) => (
<label key={context.id} className="chat-type-management-page__default-context-option">
<Checkbox value={context.id}>{context.title}</Checkbox>
<Text type="secondary" className="chat-type-management-page__default-context-option-copy">
{context.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
</Text>
</label>
))}
</Space>
</Checkbox.Group>
{selectedDefaultContextIds.length > 0 ? (
<div className="chat-type-management-page__default-context-preview">
{selectedDefaultContextIds.map((contextId) => {
const context = defaultContexts.find((item) => item.id === contextId);
return context ? (
<Tag key={`preview-${context.id}`} color="gold">
{context.title}
</Tag>
) : null;
})}
</div>
) : null}
</>
) : (
<Alert
showIcon
type="info"
message="등록된 기본 유형이 없습니다."
description="채팅 관리 > 기본 유형 관리에서 Markdown 스타일 기본 유형을 먼저 등록하세요."
/>
)}
</div>
<div className="chat-type-management-page__markdown-field">
<Text strong className="chat-type-management-page__field-label">

View File

@@ -3,8 +3,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
height: calc(100dvh - 128px);
max-height: calc(100dvh - 128px);
height: 100%;
max-height: 100%;
background:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.12), transparent 34%),
@@ -186,6 +186,147 @@
border-radius: 16px;
}
.app-chat-panel__action-group {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(148, 163, 184, 0.22);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
}
.app-chat-panel__action-group .ant-btn {
border-radius: 999px;
}
.app-chat-panel__action-group--mobile {
justify-content: flex-end;
min-width: 0;
margin-left: auto;
}
.app-chat-panel__mobile-actions {
display: inline-flex;
align-items: center;
}
.app-chat-panel__context-drawer {
display: flex;
flex: 1;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-nav {
flex: 0 0 auto;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-content-holder,
.app-chat-panel__context-drawer-tabs .ant-tabs-content {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden {
display: none;
}
.app-chat-panel__context-drawer-section {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 10px;
padding: 14px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
border: 1px solid rgba(148, 163, 184, 0.18);
}
.app-chat-panel__context-drawer-section--editor {
flex: 1;
height: 100%;
min-height: 0;
}
.app-chat-panel__context-drawer-section-head {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 4px;
}
.app-chat-panel__context-drawer-space,
.app-chat-panel__context-drawer-radio,
.app-chat-panel__context-drawer-checkbox {
width: 100%;
}
.app-chat-panel__context-drawer-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.96);
}
.app-chat-panel__context-drawer-card--readonly {
gap: 6px;
}
.app-chat-panel__context-drawer-card-copy {
padding-left: 24px;
}
.app-chat-panel__context-drawer-card-title {
font-weight: 600;
}
.app-chat-panel__context-drawer-textarea-shell {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
min-height: 220px;
}
.app-chat-panel__context-drawer-textarea {
height: 100%;
flex: 1 1 auto;
min-height: 220px;
resize: none;
}
.app-chat-panel__context-drawer-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-chat-panel__error-layout {
position: relative;
display: flex;
@@ -661,15 +802,15 @@
@media (max-width: 1080px) {
.app-chat-panel {
position: static;
height: calc(100dvh - 112px);
max-height: calc(100dvh - 112px);
height: 100%;
max-height: 100%;
}
}
@media (max-width: 768px) {
.app-chat-panel {
height: calc(100dvh - 76px);
max-height: calc(100dvh - 76px);
height: 100%;
max-height: 100%;
border-radius: 0;
}
@@ -749,4 +890,44 @@
align-items: stretch;
flex-direction: column;
}
.app-chat-panel__action-group {
gap: 4px;
padding: 3px;
}
.app-chat-panel__action-group--mobile {
gap: 6px;
padding: 3px;
}
.app-chat-panel__context-drawer {
flex: 1;
gap: 12px;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-nav {
margin-bottom: 12px;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tab {
padding: 8px 0;
}
.app-chat-panel__context-drawer-section {
padding: 12px;
border-radius: 16px;
}
.app-chat-panel__context-drawer-section--editor {
flex: 1;
}
.app-chat-panel__context-drawer-textarea-shell {
min-height: 0;
}
.app-chat-panel__context-drawer-textarea {
min-height: 320px;
}
}

View File

@@ -39,7 +39,7 @@
.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%;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
@@ -245,6 +245,12 @@
gap: 6px;
}
.app-chat-panel__conversation-section-items {
display: flex;
flex-direction: column;
gap: 6px;
}
.app-chat-panel__conversation-section-header {
display: flex;
align-items: center;
@@ -253,6 +259,25 @@
padding: 2px 2px 0;
}
.app-chat-panel__conversation-section--reorderable {
position: relative;
transition: transform 0.18s ease;
}
.app-chat-panel__conversation-section-header--reorderable,
.app-chat-panel__conversation-section-toggle--reorderable {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.app-chat-panel__conversation-section-header-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.app-chat-panel__conversation-section-header--muted {
margin-top: 6px;
}
@@ -316,6 +341,120 @@
color: #475569;
}
.app-chat-panel__conversation-section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: 16px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12);
color: #0f172a;
text-align: left;
}
.app-chat-panel__conversation-section-mobile-header {
display: flex;
align-items: center;
gap: 8px;
}
.app-chat-panel__conversation-section-mobile-header .app-chat-panel__conversation-section-toggle {
flex: 1 1 auto;
min-width: 0;
}
.app-chat-panel__conversation-section-toggle-main {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.app-chat-panel__conversation-section-toggle-actions,
.app-chat-panel__conversation-section-move-controls {
display: inline-flex;
align-items: center;
gap: 6px;
}
.app-chat-panel__conversation-section-move-controls {
flex-shrink: 0;
}
.app-chat-panel__conversation-section-move-activator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(226, 232, 240, 0.9);
color: #475569;
}
.app-chat-panel__conversation-section-move-activator.is-active {
background: rgba(191, 219, 254, 0.95);
color: #1d4ed8;
}
.app-chat-panel__conversation-section-move-button {
min-width: 0;
padding: 5px 8px;
border: 0;
border-radius: 999px;
background: rgba(226, 232, 240, 0.9);
color: #334155;
font-size: 11px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.app-chat-panel__conversation-section-move-button:disabled {
background: rgba(226, 232, 240, 0.52);
color: rgba(100, 116, 139, 0.72);
}
.app-chat-panel__conversation-section-move-button:not(:disabled):active {
background: rgba(191, 219, 254, 0.95);
color: #1d4ed8;
}
.app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle-caret {
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
}
.app-chat-panel__conversation-section-toggle.is-open {
box-shadow:
inset 0 0 0 1px rgba(59, 130, 246, 0.18),
0 10px 24px rgba(59, 130, 246, 0.08);
}
.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--processing .app-chat-panel__conversation-section-title {
color: #b45309;
}
.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--failed .app-chat-panel__conversation-section-title {
color: #b91c1c;
}
.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-toggle-icon,
.app-chat-panel__conversation-section-toggle--unread .app-chat-panel__conversation-section-title {
color: #1d4ed8;
}
.app-chat-panel__conversation-item {
position: relative;
display: flex;
@@ -649,16 +788,52 @@
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.18);
}
.app-chat-panel__conversation-item-delete.ant-btn {
.app-chat-panel__conversation-item-flag--section {
color: #475569;
background: rgba(226, 232, 240, 0.76);
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.14);
}
.app-chat-panel__conversation-item-flag--request {
color: #0f766e;
background: rgba(204, 251, 241, 0.96);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.16);
}
.app-chat-panel__conversation-item-actions {
display: flex;
flex-direction: column;
align-self: stretch;
justify-content: center;
gap: 2px;
padding: 4px 4px 4px 0;
}
.app-chat-panel__conversation-item-folder.ant-btn,
.app-chat-panel__conversation-item-delete.ant-btn {
flex-shrink: 0;
width: 28px;
min-width: 28px;
height: auto;
margin-right: 4px;
height: 28px;
color: #94a3b8;
}
.app-chat-panel__conversation-item-delete.ant-btn {
margin-right: 0;
}
.app-chat-panel__general-section-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.app-chat-panel__general-section-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.app-chat-panel__conversation-main {
display: flex;
flex: 1 1 0%;
@@ -948,6 +1123,46 @@
text-align: left;
}
.app-chat-panel__resource-chip-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
.app-chat-panel__resource-chip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex: 0 0 22px;
border-radius: 8px;
background: rgba(226, 232, 240, 0.9);
color: #1e293b;
font-size: 12px;
}
.app-chat-panel__resource-chip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-chat-panel__resource-chip-meta {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.08);
color: #334155;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.app-chat-panel__title-input {
width: min(240px, 48vw);
}
@@ -1246,6 +1461,32 @@
}
}
@media (min-width: 820px) and (max-width: 1366px) {
.app-chat-panel--ipad-readable .app-chat-message__body,
.app-chat-panel--ipad-readable .app-chat-message__body.ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block,
.app-chat-panel--ipad-readable .app-chat-message__block .ant-typography,
.app-chat-panel--ipad-readable .app-chat-message__block span,
.app-chat-panel--ipad-readable .app-chat-message__block a {
font-size: 22px !important;
line-height: 1.6;
}
}
@media (min-width: 768px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 19px !important;
}
}
@media (min-width: 768px) and (max-width: 1366px) and (pointer: fine) {
.app-chat-panel .app-chat-message__body,
.app-chat-panel .app-chat-message__body.ant-typography {
font-size: 22px !important;
}
}
.app-chat-panel__conversation-header {
display: flex;
align-items: center;
@@ -1634,11 +1875,15 @@
gap: 8px;
width: 100%;
max-width: none;
padding: 8px 0 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
padding: 8px 1px 12px;
border: 0;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
overflow: hidden;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
box-sizing: border-box;
overflow: clip;
height: auto;
}
@@ -1682,9 +1927,12 @@
}
.app-chat-preview-card--collapsed .app-chat-preview-card__header {
border: 1px solid rgba(148, 163, 184, 0.22);
border: 0;
border-radius: 16px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.65);
}
.app-chat-preview-card__meta {
@@ -1884,8 +2132,9 @@
display: flex;
min-height: 0;
border-top: 1px solid rgba(148, 163, 184, 0.18);
padding-top: 8px;
padding: 8px 0 1px;
width: 100%;
box-sizing: border-box;
}
.app-chat-preview-card--fullscreen {
@@ -1969,6 +2218,8 @@
.app-chat-panel__preview-rich {
width: 100%;
min-width: 0;
padding-bottom: 1px;
box-sizing: border-box;
}
.app-chat-panel__preview-rich .previewer-ui__editor,
@@ -2030,6 +2281,66 @@
min-width: 0;
}
.app-chat-panel__preview-table {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
min-width: 0;
min-height: 0;
}
.app-chat-panel__preview-table-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.app-chat-panel__preview-table-scroll {
overflow: auto;
max-height: min(420px, 70vh);
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
}
.app-chat-panel__preview-table-grid {
width: 100%;
min-width: max-content;
border-collapse: separate;
border-spacing: 0;
font-size: 13px;
line-height: 1.5;
}
.app-chat-panel__preview-table-grid th,
.app-chat-panel__preview-table-grid td {
padding: 10px 12px;
text-align: left;
vertical-align: top;
border-bottom: 1px solid rgba(226, 232, 240, 0.92);
white-space: pre-wrap;
word-break: break-word;
}
.app-chat-panel__preview-table-grid th {
position: sticky;
top: 0;
z-index: 1;
background: #eff6ff;
color: #1e3a8a;
font-weight: 700;
}
.app-chat-panel__preview-table-grid tbody tr:nth-child(even) td {
background: rgba(248, 250, 252, 0.82);
}
.app-chat-panel__preview-table-grid tbody tr:last-child td {
border-bottom: 0;
}
.app-chat-message__preview-image,
.app-chat-message__preview-video,
.app-chat-message__preview-frame {
@@ -2923,6 +3234,7 @@
.app-chat-panel__conversation-list,
.app-chat-panel__conversation-main {
height: 100%;
min-width: 0;
overflow: hidden;
}
@@ -2949,6 +3261,17 @@
-webkit-overflow-scrolling: touch;
}
.app-chat-panel input,
.app-chat-panel textarea,
.app-chat-panel .ant-input,
.app-chat-panel .ant-input-affix-wrapper input,
.app-chat-panel .ant-select-selection-item,
.app-chat-panel .ant-select-selection-placeholder,
.app-chat-panel .ant-select-selector,
.app-chat-panel .ant-input-textarea textarea.ant-input {
font-size: 16px !important;
}
.app-chat-panel__messages,
.app-chat-panel__composer,
.app-chat-panel__resource-strip {
@@ -2977,15 +3300,21 @@
.app-chat-panel__messages,
.app-chat-panel__preview-stage,
.app-chat-panel__resource-strip {
width: 100%;
min-width: 0;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
}
.app-chat-panel__composer {
width: 100%;
min-width: 0;
padding-left: 10px;
padding-right: 10px;
padding-top: 4px;
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
box-sizing: border-box;
}
.app-chat-panel__composer textarea.ant-input {
@@ -2993,6 +3322,7 @@
min-height: clamp(104px, 16dvh, 136px);
padding-top: 8px;
padding-bottom: 8px;
line-height: 1.5;
}
.app-chat-panel__composer-input-shell {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@ import { useAppStore } from '../../store';
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage';
import { ResourceManagementPage } from './ResourceManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
@@ -30,6 +32,7 @@ const { Paragraph, Text, Title } = Typography;
export function MainContent({
contentExpanded,
sidebarOverlayActive = false,
onToggleContentExpanded,
children,
}: MainContentProps) {
@@ -204,10 +207,18 @@ export function MainContent({
return <ChatSourceChangesPage />;
}
if (selectionId === 'page:chat:resources') {
return <ResourceManagementPage />;
}
if (selectionId === 'page:chat:manage') {
return <ChatTypeManagementPage />;
}
if (selectionId === 'page:chat:manage-defaults') {
return <ChatDefaultContextManagementPage />;
}
if (selectionId === 'page:play:layout') {
return <LayoutPlaygroundView />;
}
@@ -217,7 +228,13 @@ export function MainContent({
return (
<Content
className={contentExpanded ? 'app-main-content app-main-content--expanded' : 'app-main-content'}
className={[
'app-main-content',
contentExpanded ? 'app-main-content--expanded' : '',
sidebarOverlayActive ? 'app-main-content--under-sidebar-overlay' : '',
]
.filter(Boolean)
.join(' ')}
onClickCapture={(event) => {
handleFocusCapture(event.target);
}}

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,17 @@
.app-shell {
min-height: 100dvh;
display: flex;
flex-direction: column;
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
max-height: var(--app-viewport-height);
width: 100%;
overflow-x: hidden;
overflow: hidden;
background: transparent;
}
.app-shell:has(.app-chat-panel) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.app-shell:has(.app-chat-panel) > .ant-layout {
.app-shell__body.ant-layout {
flex: 1 1 auto;
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) > .ant-layout {
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
@@ -200,6 +186,7 @@
align-items: center;
gap: 10px;
min-width: 132px;
max-width: min(100%, 240px);
padding: 10px 12px;
border: 0;
border-radius: 14px;
@@ -304,6 +291,130 @@
font-weight: 600;
}
.app-header__settings-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.app-header__settings-meta {
display: block;
min-width: 0;
color: #64748b;
font-size: 12px;
font-weight: 500;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-header__restart-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.2), transparent 28%),
linear-gradient(135deg, rgba(2, 6, 23, 0.84), rgba(15, 23, 42, 0.92));
backdrop-filter: blur(10px);
}
.app-header__restart-overlay-card {
width: min(100%, 420px);
padding: 24px 22px;
border: 1px solid rgba(96, 165, 250, 0.2);
border-radius: 26px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.94), rgba(2, 6, 23, 0.98)),
rgba(15, 23, 42, 0.96);
box-shadow:
0 26px 60px rgba(15, 23, 42, 0.42),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
color: #e2e8f0;
}
.app-header__restart-overlay-eyebrow {
display: inline-flex;
margin-bottom: 10px;
color: rgba(147, 197, 253, 0.88);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
}
.app-header__restart-overlay-title {
display: block;
margin-bottom: 14px;
color: #f8fafc;
font-size: clamp(22px, 4vw, 28px);
line-height: 1.2;
}
.app-header__restart-overlay-status {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
color: #bfdbfe;
font-size: 14px;
font-weight: 600;
}
.app-header__restart-overlay-detail {
margin: 0 0 16px;
color: #cbd5e1;
font-size: 13px;
line-height: 1.55;
}
.app-header__restart-overlay-steps {
display: grid;
gap: 10px;
}
.app-header__restart-overlay-step {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 12px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 14px;
background: rgba(15, 23, 42, 0.52);
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.app-header__restart-overlay-step-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: currentColor;
flex-shrink: 0;
}
.app-header__restart-overlay-step--done {
color: #38bdf8;
}
.app-header__restart-overlay-step--active {
color: #f8fafc;
border-color: rgba(96, 165, 250, 0.28);
background: rgba(30, 41, 59, 0.86);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.14);
}
.app-header__restart-overlay-step--pending {
color: #64748b;
}
.app-header__settings-group-arrow {
display: inline-flex;
align-items: center;
@@ -432,6 +543,9 @@
}
.app-sider.ant-layout-sider {
height: 100%;
min-height: 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.72);
border-right: 1px solid rgba(148, 163, 184, 0.14);
}
@@ -444,7 +558,7 @@
min-width: 100vw !important;
max-width: 100vw;
flex: 0 0 100vw !important;
height: calc(100vh - 72px);
height: calc(var(--app-viewport-height) - 72px);
border-right: 0;
background: rgba(255, 255, 255, 0.98);
transition: none !important;
@@ -479,36 +593,32 @@
.app-main-content.ant-layout-content {
position: relative;
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: calc(100dvh - 60px);
min-height: 0;
height: 100%;
max-height: 100%;
width: 100%;
padding: 0;
overflow-x: hidden;
}
.app-main-content.ant-layout-content:has(.app-chat-panel) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved) {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
.app-main-content--under-sidebar-overlay.ant-layout-content {
visibility: hidden;
pointer-events: none;
}
.app-main-content--expanded.ant-layout-content {
position: relative;
display: flex;
min-height: 100vh;
min-height: var(--app-viewport-height);
padding: 20px;
}
.app-main-panel {
display: flex;
min-width: 0;
min-height: 0;
width: 100%;
}
@@ -518,7 +628,7 @@
.app-main-panel--play-saved {
height: 100%;
min-height: calc(100dvh - 60px);
min-height: calc(var(--app-viewport-height) - 60px);
overflow: hidden;
}
@@ -535,18 +645,21 @@
}
.app-main-content--expanded.ant-layout-content:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 60px);
min-height: calc(var(--app-viewport-height) - 60px);
padding: 0;
overflow: hidden;
}
.app-main-panel:has(.app-chat-panel) {
flex: 1 1 auto;
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-layout:has(.app-chat-panel) {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
overflow: hidden;
@@ -557,10 +670,16 @@
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
gap: 16px;
flex: 1;
min-height: 100%;
min-height: 0;
min-width: 0;
height: 100%;
max-height: 100%;
padding: 16px;
width: 100%;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.app-main-layout:has(.chat-type-management-page) {
@@ -569,13 +688,124 @@
padding: 4px 12px 12px;
}
.app-main-panel:has(.board-page),
.app-main-panel:has(.history-page),
.app-main-panel:has(.chat-source-changes-page),
.app-main-panel:has(.docs-page) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.board-page),
.app-main-layout:has(.history-page),
.app-main-layout:has(.chat-source-changes-page),
.app-main-layout:has(.docs-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
height: 100%;
min-height: 0;
}
.app-main-layout:has(.docs-page) {
overflow-y: auto;
overflow-x: hidden;
}
.docs-page {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.docs-page__card,
.docs-page__card.ant-card,
.docs-page__card.ant-card .ant-card-body {
width: 100%;
min-width: 0;
min-height: 0;
}
.docs-page__card.ant-card {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
}
.docs-page__card.ant-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
.docs-page__scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 0 calc(16px + env(safe-area-inset-bottom, 0px));
}
.docs-page__stack {
min-width: 0;
}
.app-main-panel:has(.resource-management-page) {
height: 100%;
min-height: 100%;
overflow: hidden;
}
.app-main-layout:has(.resource-management-page) {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
padding: 4px 12px 12px;
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-panel:has(.plan-board-page),
.app-main-panel:has(.plan-schedule-page),
.app-main-panel:has(.release-review-page),
.app-main-panel:has(.server-command-page),
.app-main-panel:has(.test-play-app),
.app-main-panel:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-panel--play:has(.test-play-app),
.app-main-panel--play:has(.layout-playground__editor-card) {
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
@media (max-width: 720px) {
html,
body,
#root {
height: 100dvh;
height: var(--app-viewport-height);
overflow-x: hidden;
overflow-y: auto;
overflow-y: hidden;
}
html:has(.chat-type-management-page),
@@ -595,12 +825,16 @@
.app-shell,
.app-main-content.ant-layout-content {
overflow-x: hidden;
overflow-y: auto;
overflow-y: hidden;
}
.app-main-panel,
.app-main-layout {
overflow: visible;
overflow: hidden;
}
.app-main-layout {
overflow-y: auto;
}
.app-main-panel:has(.app-chat-panel),
@@ -609,12 +843,128 @@
}
.app-shell:has(.chat-type-management-page),
.app-shell:has(.chat-type-management-page) > .ant-layout {
width: 100%;
min-width: 100%;
max-width: 100%;
height: var(--app-viewport-height);
min-height: var(--app-viewport-height);
overflow: hidden;
}
.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),
.chat-type-management-page,
.chat-type-management-page__card {
width: 100%;
min-width: 100%;
max-width: 100%;
}
.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(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
width: 100%;
min-width: 100%;
max-width: 100%;
}
.app-shell:has(.resource-management-page),
.app-shell:has(.resource-management-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.resource-management-page),
.app-main-panel:has(.resource-management-page),
.app-main-layout:has(.resource-management-page) {
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
.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);
width: 100%;
}
.app-shell:has(.board-page),
.app-shell:has(.board-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.board-page),
.app-main-panel:has(.board-page),
.app-main-layout:has(.board-page),
.app-shell:has(.history-page),
.app-shell:has(.history-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.history-page),
.app-main-panel:has(.history-page),
.app-main-layout:has(.history-page),
.app-shell:has(.chat-source-changes-page),
.app-shell:has(.chat-source-changes-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.chat-source-changes-page),
.app-main-panel:has(.chat-source-changes-page),
.app-main-layout:has(.chat-source-changes-page),
.app-shell:has(.docs-page),
.app-shell:has(.docs-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.docs-page),
.app-main-panel:has(.docs-page),
.app-main-layout:has(.docs-page) {
height: 100%;
min-height: 0;
}
.app-shell:has(.docs-page),
.app-shell:has(.docs-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.docs-page),
.app-main-panel:has(.docs-page) {
overflow: hidden;
}
.app-main-layout:has(.docs-page) {
overflow-y: auto;
overflow-x: hidden;
}
.app-shell:has(.plan-board-page),
.app-shell:has(.plan-board-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.plan-board-page),
.app-main-panel:has(.plan-board-page),
.app-main-layout:has(.plan-board-page),
.app-shell:has(.plan-schedule-page),
.app-shell:has(.plan-schedule-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.plan-schedule-page),
.app-main-panel:has(.plan-schedule-page),
.app-main-layout:has(.plan-schedule-page),
.app-shell:has(.release-review-page),
.app-shell:has(.release-review-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.release-review-page),
.app-main-panel:has(.release-review-page),
.app-main-layout:has(.release-review-page),
.app-shell:has(.server-command-page),
.app-shell:has(.server-command-page) > .ant-layout,
.app-main-content.ant-layout-content:has(.server-command-page),
.app-main-panel:has(.server-command-page),
.app-main-layout:has(.server-command-page),
.app-shell:has(.test-play-app),
.app-shell:has(.test-play-app) > .ant-layout,
.app-main-content.ant-layout-content:has(.test-play-app),
.app-main-panel:has(.test-play-app),
.app-main-layout:has(.test-play-app),
.app-shell:has(.layout-playground__editor-card),
.app-shell:has(.layout-playground__editor-card) > .ant-layout,
.app-main-content.ant-layout-content:has(.layout-playground__editor-card),
.app-main-panel:has(.layout-playground__editor-card),
.app-main-layout:has(.layout-playground__editor-card) {
height: 100%;
min-height: 0;
overflow: hidden;
}
@@ -623,8 +973,8 @@
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
.app-main-layout:has(.app-main-panel--play-saved),
.app-main-panel--play-saved {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
height: calc(var(--app-viewport-height) - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
overflow: hidden;
}
@@ -718,7 +1068,7 @@
position: relative;
width: 100%;
height: 100%;
min-height: calc(100dvh - 92px);
min-height: calc(var(--app-viewport-height) - 92px);
overflow: hidden;
border-radius: 24px;
}
@@ -806,14 +1156,14 @@
@media (max-width: 1180px) {
.app-main-panel:has(.app-chat-panel) {
height: calc(100dvh - 60px);
min-height: calc(100dvh - 60px);
height: 100%;
min-height: 0;
overflow: hidden;
}
.app-main-layout:has(.app-chat-panel) {
height: calc(100dvh - 60px);
min-height: calc(100dvh - 60px);
height: 100%;
min-height: 0;
padding: 0;
gap: 0;
overflow: hidden;
@@ -821,10 +1171,6 @@
}
@media (max-width: 768px) {
.app-shell:has(.app-chat-panel) > .ant-layout {
height: calc(100dvh - 52px);
}
.app-header {
padding: 6px 10px;
height: 52px;
@@ -874,7 +1220,7 @@
.app-sider--mobile.ant-layout-sider {
position: fixed;
inset: 52px 0 0;
height: calc(100vh - 52px);
height: calc(var(--app-viewport-height) - 52px);
}
.app-sider--mobile-inline.ant-layout-sider {
@@ -884,21 +1230,33 @@
.app-main-content.ant-layout-content {
padding: 0;
min-height: calc(100dvh - 52px);
}
.app-main-layout {
min-height: calc(100dvh - 52px);
padding: 8px;
gap: 8px;
.app-main-layout,
.app-main-layout:has(.chat-type-management-page),
.app-main-layout:has(.docs-page),
.app-main-layout:has(.resource-management-page),
.app-main-layout:has(.board-page),
.app-main-layout:has(.history-page),
.app-main-layout:has(.chat-source-changes-page),
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card),
.app-main-layout:has(.app-main-panel--play-saved) {
width: 100%;
padding: 0;
gap: 0;
}
.app-main-panel--play-saved {
min-height: calc(100dvh - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
}
.app-main-layout:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 52px);
min-height: calc(var(--app-viewport-height) - 52px);
}
.app-main-layout:has(.chat-type-management-page) {
@@ -906,12 +1264,17 @@
gap: 0;
}
.app-main-layout:has(.docs-page) {
padding: 0;
gap: 0;
}
.app-main-window-layer {
inset: 8px;
}
.app-main-window-layer__stage {
min-height: calc(100dvh - 68px);
min-height: calc(var(--app-viewport-height) - 68px);
border-radius: 18px;
}
@@ -935,8 +1298,28 @@
padding: 10px 16px 16px;
}
.docs-page__scroll {
padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
}
.app-main-panel:has(.app-chat-panel) {
height: calc(100dvh - 76px);
min-height: calc(100dvh - 76px);
height: 100%;
min-height: 0;
}
.app-main-layout:has(.plan-board-page),
.app-main-layout:has(.plan-schedule-page),
.app-main-layout:has(.release-review-page),
.app-main-layout:has(.server-command-page),
.app-main-layout:has(.test-play-app),
.app-main-layout:has(.layout-playground__editor-card) {
overflow: hidden;
}
.app-main-panel:has(.test-play-app),
.app-main-panel:has(.layout-playground__editor-card) {
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -89,7 +89,7 @@ export function MainSidebar({
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
: effectiveTopMenu === 'play'
? [...(playMenuItems ?? [])]
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
: [...(chatMenuItems ?? []), ...(planMenuItems ?? [])];
const rootKeys = sidebarItems.flatMap((item) =>
item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [],
);

View File

@@ -0,0 +1,657 @@
.resource-management-page {
position: relative;
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(320px, 1fr) minmax(360px, 1.08fr);
gap: 16px;
height: 100%;
min-height: 0;
overflow: hidden;
-webkit-touch-callout: none;
}
.resource-management-page__mobile-nav,
.resource-management-page__mobile-card {
display: none;
}
.resource-management-page__sidebar,
.resource-management-page__content,
.resource-management-page__preview-card {
min-height: 0;
border-radius: 22px;
overflow: clip;
box-sizing: border-box;
}
.resource-management-page__sidebar.ant-card,
.resource-management-page__content.ant-card,
.resource-management-page__preview-card.ant-card {
display: flex;
flex-direction: column;
min-height: 0;
border: 0;
box-shadow:
inset 0 0 0 1px rgba(191, 204, 229, 0.92),
0 16px 40px rgba(15, 23, 42, 0.08);
}
.resource-management-page__sidebar .ant-card-head,
.resource-management-page__content .ant-card-head,
.resource-management-page__preview-card .ant-card-head {
min-height: 58px;
}
.resource-management-page__sidebar .ant-card-body,
.resource-management-page__content .ant-card-body,
.resource-management-page__preview-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
padding: 16px;
min-height: 0;
height: auto;
}
.resource-management-page__scope-copy {
display: block;
}
.resource-management-page__tree {
flex: 1;
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.resource-management-page__tree .ant-tree-node-content-wrapper {
width: 100%;
border-radius: 12px;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__tree-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__content {
min-height: 0;
}
.resource-management-page__preview-card {
min-height: 0;
}
.resource-management-page__workspace {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.resource-management-page__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.resource-management-page__toolbar-main,
.resource-management-page__toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.resource-management-page__guide {
margin: 0;
}
.resource-management-page__list-shell {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
border: 0;
border-radius: 18px;
background:
linear-gradient(180deg, rgba(249, 251, 255, 0.96), rgba(241, 246, 255, 0.92)),
#fff;
box-shadow: inset 0 0 0 1px #dfe6f4;
overflow: clip;
box-sizing: border-box;
-webkit-touch-callout: none;
}
.resource-management-page__list-header,
.resource-management-page__list-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 160px 88px 76px;
gap: 12px;
align-items: center;
}
.resource-management-page__list-header {
padding: 12px 16px;
border-bottom: 1px solid #e9eef8;
color: #6b7280;
font-size: 12px;
font-weight: 600;
}
.resource-management-page__list-body {
flex: 1;
min-height: 0;
overflow: auto;
}
.resource-management-page__list-row {
padding: 12px 16px;
border-bottom: 1px solid #eef2fa;
cursor: pointer;
transition: background-color 0.18s ease, transform 0.18s ease;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.resource-management-page__list-row:hover {
background: rgba(222, 234, 255, 0.42);
}
.resource-management-page__list-row--selected {
background: rgba(186, 209, 255, 0.34);
}
.resource-management-page__list-row--parent {
background: rgba(241, 245, 255, 0.92);
}
.resource-management-page__list-name,
.resource-management-page__entry-name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.resource-management-page__list-meta {
display: contents;
}
.resource-management-page__entry-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-management-page__preview-card .ant-card-body {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
overflow: hidden;
padding-bottom: 17px;
}
.resource-management-page__preview-frame {
width: 100%;
min-height: 0;
flex: 1;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
}
.resource-management-page__text-preview {
flex: 1;
min-height: 0;
margin: 0;
padding: 16px;
overflow: auto;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
color: #111827;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.resource-management-page__image-preview {
width: 100%;
max-height: 100%;
object-fit: contain;
border: 0;
border-radius: 16px;
background: #fff;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
}
.resource-management-page__zoom-shell,
.resource-management-page__preview-modal-body {
display: flex;
flex: 1;
min-height: 0;
}
.resource-management-page__zoom-shell {
align-items: center;
justify-content: center;
overflow: clip;
border: 0;
border-radius: 16px;
background: #0b1220;
box-shadow: inset 0 0 0 1px #d9e1f2;
box-sizing: border-box;
scrollbar-gutter: stable both-edges;
}
.resource-management-page__zoom-shell--touch-zoom {
touch-action: none;
overscroll-behavior: contain;
}
.resource-management-page__zoom-shell--image {
padding: 16px;
}
.resource-management-page__image-preview--zoomable {
width: 100%;
height: 100%;
object-fit: contain;
border: 0;
border-radius: 0;
transform-origin: center center;
will-change: transform;
}
.resource-management-page__frame-zoom-shell {
width: 100%;
min-width: 100%;
height: 100%;
min-height: 100%;
overflow: hidden;
transform-origin: center center;
will-change: transform;
}
.resource-management-page__preview-frame--zoomable {
min-width: 100%;
min-height: 100%;
border: 0;
border-radius: 0;
transform-origin: center center;
pointer-events: none;
}
.resource-management-page__preview-modal-toolbar {
display: flex;
align-items: center;
justify-content: stretch;
gap: 12px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid rgba(217, 225, 242, 0.9);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(14px);
flex: 0 0 auto;
min-height: 72px;
box-sizing: border-box;
}
.resource-management-page__preview-modal-toolbar-slider {
flex: 1 1 auto;
min-width: 0;
}
.resource-management-page__preview-modal-toolbar-slider .ant-slider {
margin: 0;
}
.resource-management-page__preview-modal-toolbar-button {
width: 44px;
min-width: 44px;
height: 44px;
padding-inline: 0;
flex: 0 0 44px;
}
.resource-management-page__preview-modal-toolbar-value {
flex: 0 0 56px;
width: 56px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.resource-management-page__preview-modal-body {
overflow: hidden;
background: #0b1220;
}
.resource-management-page__preview-modal-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.resource-management-page__editor {
min-height: 260px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}
.resource-management-page__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
padding: 24px 16px;
}
.resource-management-page__context-menu {
position: fixed;
z-index: 1200;
display: flex;
flex-direction: column;
min-width: 208px;
padding: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(18px);
}
.resource-management-page__context-menu-backdrop {
position: fixed;
inset: 0;
z-index: 1199;
background: transparent;
}
.resource-management-page__context-menu button {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: #111827;
font: inherit;
text-align: left;
}
.resource-management-page__context-menu button:hover {
background: rgba(222, 234, 255, 0.42);
}
.resource-management-page__context-menu button.danger {
color: #b42318;
}
.resource-management-page__context-menu button.danger:hover {
background: rgba(254, 226, 226, 0.72);
}
.resource-management-page__file-input {
position: fixed;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
inset: auto auto -100vh -100vw;
}
@media (max-width: 1180px) {
.resource-management-page {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
}
.resource-management-page__preview-card {
grid-column: 1 / -1;
}
}
@media (max-width: 768px) {
.resource-management-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-management-page--mobile {
min-height: 0;
padding-inline: 1px;
padding-bottom: max(10px, calc(env(safe-area-inset-bottom, 0px) + 6px));
box-sizing: border-box;
}
.resource-management-page--mobile > .resource-management-page__sidebar,
.resource-management-page--mobile > .resource-management-page__content,
.resource-management-page--mobile > .resource-management-page__preview-card {
display: none;
}
.resource-management-page__mobile-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
flex: 0 0 auto;
}
.resource-management-page__mobile-card {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.resource-management-page__mobile-card > .ant-card {
flex: 1 1 auto;
min-height: 0;
height: auto;
}
.resource-management-page__sidebar .ant-card-body,
.resource-management-page__content .ant-card-body,
.resource-management-page__preview-card .ant-card-body {
padding: 14px;
padding-bottom: 15px;
}
.resource-management-page__list-header {
display: none;
}
.resource-management-page__list-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'name actions'
'meta meta';
row-gap: 6px;
}
.resource-management-page__list-name {
grid-area: name;
}
.resource-management-page__list-meta {
grid-area: meta;
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: #6b7280;
}
.resource-management-page__list-meta span {
font-size: 12px;
}
.resource-management-page__list-row > .ant-space {
grid-area: actions;
justify-self: end;
}
.resource-management-page__context-menu {
min-width: min(208px, calc(100vw - 24px));
}
.resource-management-page__toolbar {
align-items: stretch;
}
.resource-management-page__toolbar-main,
.resource-management-page__toolbar-actions {
width: 100%;
}
.resource-management-page__toolbar-actions .ant-btn {
flex: 1 1 0;
}
.resource-management-page__preview-frame,
.resource-management-page__image-preview,
.resource-management-page__text-preview {
min-height: 220px;
}
.resource-management-page__editor {
min-height: 0;
height: 100%;
}
.resource-management-page__preview-card .ant-tabs,
.resource-management-page__preview-card .ant-tabs-content-holder,
.resource-management-page__preview-card .ant-tabs-content,
.resource-management-page__preview-card .ant-tabs-tabpane {
min-height: 0;
height: 100%;
}
.resource-management-page__preview-card .ant-tabs-content-holder {
overflow: hidden;
padding-bottom: 1px;
box-sizing: border-box;
}
.resource-management-page__preview-card .ant-tabs-tabpane-active {
display: flex;
flex-direction: column;
}
.resource-management-page--panel-preview .resource-management-page__preview-card .ant-card-body {
gap: 8px;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal {
width: 100vw !important;
max-width: 100vw;
margin: 0;
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-content {
border-radius: 0;
min-height: 100dvh;
background: #0b1220;
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-header {
padding: calc(12px + env(safe-area-inset-top, 0px)) 64px 12px 16px;
margin-bottom: 0;
background: #0b1220;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-title {
color: #f8fafc;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close {
top: calc(8px + env(safe-area-inset-top, 0px));
right: 8px;
width: 44px;
height: 44px;
color: #f8fafc;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(226, 232, 240, 0.22);
border-radius: 999px;
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close:hover,
.resource-management-page__preview-modal-wrap--mobile .ant-modal-close:focus-visible {
color: #ffffff;
background: rgba(30, 41, 59, 0.98);
}
.resource-management-page__preview-modal-wrap--mobile .ant-modal-body {
height: calc(100dvh - 56px) !important;
background: #0b1220;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-shell .resource-management-page__preview-modal-body {
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__zoom-shell {
border: 0;
border-radius: 0;
min-height: 100%;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__zoom-shell--image {
padding: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-frame,
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__image-preview {
border: 0;
border-radius: 0;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar {
gap: 10px;
padding-inline: 12px;
min-height: 76px;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar .ant-btn {
flex: 0 0 auto;
}
.resource-management-page__preview-modal-wrap--mobile .resource-management-page__preview-modal-toolbar-value {
flex-basis: 52px;
width: 52px;
}
}

File diff suppressed because it is too large Load Diff

641
src/app/main/appConfig.js Normal file
View File

@@ -0,0 +1,641 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_APP_CONFIG = exports.APP_CONFIG_STORAGE_KEY = void 0;
exports.fetchAppConfigFromServer = fetchAppConfigFromServer;
exports.saveAppConfigToServer = saveAppConfigToServer;
exports.saveAutomationNotificationPreferenceToServer = saveAutomationNotificationPreferenceToServer;
exports.syncAppConfigFromServer = syncAppConfigFromServer;
exports.getStoredAppConfig = getStoredAppConfig;
exports.setStoredAppConfig = setStoredAppConfig;
exports.updateStoredAppConfig = updateStoredAppConfig;
exports.useAppConfig = useAppConfig;
exports.describeAutoReceiveSchedule = describeAutoReceiveSchedule;
exports.getWeeklyScheduleOptions = getWeeklyScheduleOptions;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
var notificationIdentity_1 = require("./notificationIdentity");
exports.APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
var APP_CONFIG_EVENT = 'work-server:app-config';
var APP_CONFIG_API_PATH = '/app-config';
var AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';
var APP_CONFIG_REQUEST_TIMEOUT_MS = 8000;
var cachedConfig = null;
var cachedRawConfig = null;
exports.DEFAULT_APP_CONFIG = {
chat: {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
},
automation: {
autoRefreshEnabled: true,
autoRefreshIntervalSeconds: 5,
autoReceiveScheduleType: 'interval',
autoReceiveIntervalSeconds: 30,
autoReceiveDailyTime: '09:00',
autoReceiveWeeklyDay: 'mon',
autoReceiveWeeklyTime: '09:00',
notifyOnAutomationStart: true,
notifyOnAutomationProgress: true,
notifyOnAutomationCompletion: true,
notifyOnAutomationRelease: true,
notifyOnAutomationMain: true,
notifyOnAutomationFailure: true,
notifyOnAutomationRestart: true,
notifyOnAutomationIssueResolved: true,
},
worklogAutomation: {
autoCreateDailyWorklog: false,
dailyCreateTime: '18:00',
repeatRequestEnabled: false,
repeatIntervalMinutes: 60,
includeScreenshots: true,
includeChangedFiles: true,
includeCommandLogs: true,
template: 'detailed',
},
planDefaults: {
jangsingProcessingRequired: true,
autoDeployToMain: true,
openEditorAfterCreate: true,
},
planCost: {
baseCostPerMillionTokens: 10000,
retryCostMultiplierPercent: 15,
hourlyCostMultiplierPercent: 0,
timeCostUnit: 'hour',
attentionCostThresholdMultiplier: 1,
warningCostThresholdMultiplier: 2,
highCostThresholdMultiplier: 4,
},
gestureShortcuts: {
openSearch: 'Mod+K',
openWindowSearch: 'Mod+Shift+K',
},
};
var WEEKLY_DAY_LABELS = {
mon: '월요일',
tue: '화요일',
wed: '수요일',
thu: '목요일',
fri: '금요일',
sat: '토요일',
sun: '일요일',
};
var AUTOMATION_NOTIFICATION_KEYS = [
'notifyOnAutomationStart',
'notifyOnAutomationProgress',
'notifyOnAutomationCompletion',
'notifyOnAutomationRelease',
'notifyOnAutomationMain',
'notifyOnAutomationFailure',
'notifyOnAutomationRestart',
'notifyOnAutomationIssueResolved',
];
function clampIntervalSeconds(value, fallback) {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.min(3600, Math.max(1, Math.round(value)));
}
function normalizeTimeValue(value, fallback) {
if (/^\d{2}:\d{2}$/.test(value)) {
return value;
}
return fallback;
}
function normalizeScheduleType(value) {
if (value === 'daily' || value === 'weekly') {
return value;
}
return 'interval';
}
function normalizeWeeklyDay(value) {
if (value && value in WEEKLY_DAY_LABELS) {
return value;
}
return exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyDay;
}
function normalizeWorklogTemplate(value) {
if (value === 'simple') {
return 'simple';
}
return 'detailed';
}
function normalizeShortcutValue(value, fallback) {
var trimmed = value === null || value === void 0 ? void 0 : value.trim();
if (!trimmed) {
return fallback;
}
return trimmed
.split('+')
.map(function (token) { return token.trim(); })
.filter(Boolean)
.join('+');
}
function normalizePlanCostValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(1000000, Math.max(100, Math.round(value)));
}
function normalizePlanCostMultiplierValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(100, Math.max(0.1, Math.round(value * 10) / 10));
}
function normalizePlanCostPercentValue(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(500, Math.max(0, Math.round(value)));
}
function normalizePlanCostTimeUnit(value) {
if (value === 'minute' || value === 'second') {
return value;
}
return 'hour';
}
function normalizeChatContextMessageLimit(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(50, Math.max(1, Math.round(value)));
}
function normalizeChatContextCharLimit(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(20000, Math.max(500, Math.round(value)));
}
function normalizeCodexLiveMaxExecutionSeconds(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(7200, Math.max(60, Math.round(value)));
}
function normalizeCodexLiveIdleTimeoutSeconds(value, fallback) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(3600, Math.max(30, Math.round(value)));
}
function normalizeBooleanValue(value, fallback) {
if (typeof value !== 'boolean') {
return fallback;
}
return value;
}
function normalizeConfig(raw) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w;
var chat = raw === null || raw === void 0 ? void 0 : raw.chat;
var automation = raw === null || raw === void 0 ? void 0 : raw.automation;
var worklogAutomation = raw === null || raw === void 0 ? void 0 : raw.worklogAutomation;
var planDefaults = raw === null || raw === void 0 ? void 0 : raw.planDefaults;
var planCost = raw === null || raw === void 0 ? void 0 : raw.planCost;
var gestureShortcuts = raw === null || raw === void 0 ? void 0 : raw.gestureShortcuts;
return {
chat: {
maxContextMessages: normalizeChatContextMessageLimit(chat === null || chat === void 0 ? void 0 : chat.maxContextMessages, exports.DEFAULT_APP_CONFIG.chat.maxContextMessages),
maxContextChars: normalizeChatContextCharLimit(chat === null || chat === void 0 ? void 0 : chat.maxContextChars, exports.DEFAULT_APP_CONFIG.chat.maxContextChars),
codexLiveMaxExecutionSeconds: normalizeCodexLiveMaxExecutionSeconds(chat === null || chat === void 0 ? void 0 : chat.codexLiveMaxExecutionSeconds, exports.DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds),
codexLiveIdleTimeoutSeconds: normalizeCodexLiveIdleTimeoutSeconds(chat === null || chat === void 0 ? void 0 : chat.codexLiveIdleTimeoutSeconds, exports.DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds),
receiveRoomNotifications: normalizeBooleanValue(chat === null || chat === void 0 ? void 0 : chat.receiveRoomNotifications, exports.DEFAULT_APP_CONFIG.chat.receiveRoomNotifications),
},
automation: {
autoRefreshEnabled: (_a = automation === null || automation === void 0 ? void 0 : automation.autoRefreshEnabled) !== null && _a !== void 0 ? _a : exports.DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
autoRefreshIntervalSeconds: clampIntervalSeconds((_b = automation === null || automation === void 0 ? void 0 : automation.autoRefreshIntervalSeconds) !== null && _b !== void 0 ? _b : exports.DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds, exports.DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds),
autoReceiveScheduleType: normalizeScheduleType(automation === null || automation === void 0 ? void 0 : automation.autoReceiveScheduleType),
autoReceiveIntervalSeconds: clampIntervalSeconds((_c = automation === null || automation === void 0 ? void 0 : automation.autoReceiveIntervalSeconds) !== null && _c !== void 0 ? _c : exports.DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds, exports.DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds),
autoReceiveDailyTime: normalizeTimeValue((_d = automation === null || automation === void 0 ? void 0 : automation.autoReceiveDailyTime) !== null && _d !== void 0 ? _d : exports.DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime, exports.DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime),
autoReceiveWeeklyDay: normalizeWeeklyDay(automation === null || automation === void 0 ? void 0 : automation.autoReceiveWeeklyDay),
autoReceiveWeeklyTime: normalizeTimeValue((_e = automation === null || automation === void 0 ? void 0 : automation.autoReceiveWeeklyTime) !== null && _e !== void 0 ? _e : exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime, exports.DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime),
notifyOnAutomationStart: (_f = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationStart) !== null && _f !== void 0 ? _f : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationStart,
notifyOnAutomationProgress: (_g = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationProgress) !== null && _g !== void 0 ? _g : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationProgress,
notifyOnAutomationCompletion: (_h = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationCompletion) !== null && _h !== void 0 ? _h : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationCompletion,
notifyOnAutomationRelease: (_j = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRelease) !== null && _j !== void 0 ? _j : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationRelease,
notifyOnAutomationMain: (_k = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationMain) !== null && _k !== void 0 ? _k : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationMain,
notifyOnAutomationFailure: (_l = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationFailure) !== null && _l !== void 0 ? _l : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationFailure,
notifyOnAutomationRestart: (_m = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationRestart) !== null && _m !== void 0 ? _m : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationRestart,
notifyOnAutomationIssueResolved: (_o = automation === null || automation === void 0 ? void 0 : automation.notifyOnAutomationIssueResolved) !== null && _o !== void 0 ? _o : exports.DEFAULT_APP_CONFIG.automation.notifyOnAutomationIssueResolved,
},
worklogAutomation: {
autoCreateDailyWorklog: (_p = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.autoCreateDailyWorklog) !== null && _p !== void 0 ? _p : exports.DEFAULT_APP_CONFIG.worklogAutomation.autoCreateDailyWorklog,
dailyCreateTime: normalizeTimeValue((_q = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.dailyCreateTime) !== null && _q !== void 0 ? _q : exports.DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime, exports.DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime),
repeatRequestEnabled: false,
repeatIntervalMinutes: exports.DEFAULT_APP_CONFIG.worklogAutomation.repeatIntervalMinutes,
includeScreenshots: (_r = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeScreenshots) !== null && _r !== void 0 ? _r : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeScreenshots,
includeChangedFiles: (_s = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeChangedFiles) !== null && _s !== void 0 ? _s : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeChangedFiles,
includeCommandLogs: (_t = worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.includeCommandLogs) !== null && _t !== void 0 ? _t : exports.DEFAULT_APP_CONFIG.worklogAutomation.includeCommandLogs,
template: normalizeWorklogTemplate(worklogAutomation === null || worklogAutomation === void 0 ? void 0 : worklogAutomation.template),
},
planDefaults: {
jangsingProcessingRequired: (_u = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.jangsingProcessingRequired) !== null && _u !== void 0 ? _u : exports.DEFAULT_APP_CONFIG.planDefaults.jangsingProcessingRequired,
autoDeployToMain: (_v = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.autoDeployToMain) !== null && _v !== void 0 ? _v : exports.DEFAULT_APP_CONFIG.planDefaults.autoDeployToMain,
openEditorAfterCreate: (_w = planDefaults === null || planDefaults === void 0 ? void 0 : planDefaults.openEditorAfterCreate) !== null && _w !== void 0 ? _w : exports.DEFAULT_APP_CONFIG.planDefaults.openEditorAfterCreate,
},
planCost: {
baseCostPerMillionTokens: normalizePlanCostValue(planCost === null || planCost === void 0 ? void 0 : planCost.baseCostPerMillionTokens, exports.DEFAULT_APP_CONFIG.planCost.baseCostPerMillionTokens),
retryCostMultiplierPercent: normalizePlanCostPercentValue(planCost === null || planCost === void 0 ? void 0 : planCost.retryCostMultiplierPercent, exports.DEFAULT_APP_CONFIG.planCost.retryCostMultiplierPercent),
hourlyCostMultiplierPercent: normalizePlanCostPercentValue(planCost === null || planCost === void 0 ? void 0 : planCost.hourlyCostMultiplierPercent, exports.DEFAULT_APP_CONFIG.planCost.hourlyCostMultiplierPercent),
timeCostUnit: normalizePlanCostTimeUnit(planCost === null || planCost === void 0 ? void 0 : planCost.timeCostUnit),
attentionCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.attentionCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.attentionCostThresholdMultiplier),
warningCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.warningCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.warningCostThresholdMultiplier),
highCostThresholdMultiplier: normalizePlanCostMultiplierValue(planCost === null || planCost === void 0 ? void 0 : planCost.highCostThresholdMultiplier, exports.DEFAULT_APP_CONFIG.planCost.highCostThresholdMultiplier),
},
gestureShortcuts: {
openSearch: normalizeShortcutValue(gestureShortcuts === null || gestureShortcuts === void 0 ? void 0 : gestureShortcuts.openSearch, exports.DEFAULT_APP_CONFIG.gestureShortcuts.openSearch),
openWindowSearch: normalizeShortcutValue(gestureShortcuts === null || gestureShortcuts === void 0 ? void 0 : gestureShortcuts.openWindowSearch, exports.DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch),
},
};
}
function pickAutomationNotificationSettings(automation) {
return AUTOMATION_NOTIFICATION_KEYS.reduce(function (picked, key) {
picked[key] = automation[key];
return picked;
}, {});
}
function mergeAutomationNotificationSettings(config, automation) {
if (!automation) {
return config;
}
return normalizeConfig(__assign(__assign({}, config), { automation: __assign(__assign({}, config.automation), automation) }));
}
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
function emitConfigChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(APP_CONFIG_EVENT));
}
function resolveAppConfigApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveAppConfigFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
var APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
var AppConfigApiError = /** @class */ (function (_super) {
__extends(AppConfigApiError, _super);
function AppConfigApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'AppConfigApiError';
_this.status = status;
return _this;
}
return AppConfigApiError;
}(Error));
function requestAppConfigOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, controller, timeoutId, appOrigin, appDomain, response, error_1, text, payload, contentType, text;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
controller = new AbortController();
timeoutId = setTimeout(function () { return controller.abort(); }, APP_CONFIG_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
appOrigin = getCurrentAppOrigin();
appDomain = getCurrentAppDomain();
if (appOrigin && !headers.has('X-App-Origin')) {
headers.set('X-App-Origin', appOrigin);
}
if (appDomain && !headers.has('X-App-Domain')) {
headers.set('X-App-Domain', appDomain);
}
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_1 = _e.sent();
clearTimeout(timeoutId);
if (error_1 instanceof DOMException && error_1.name === 'AbortError') {
throw new AppConfigApiError('서버 응답이 지연됩니다.', 408);
}
throw error_1;
case 4:
clearTimeout(timeoutId);
if (!!response.ok) return [3 /*break*/, 6];
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
try {
payload = JSON.parse(text);
throw new AppConfigApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
}
catch (_f) {
throw new AppConfigApiError(text || '요청 처리에 실패했습니다.', response.status);
}
_e.label = 6;
case 6:
contentType = (_d = response.headers.get('content-type')) !== null && _d !== void 0 ? _d : '';
if (!!contentType.toLowerCase().includes('application/json')) return [3 /*break*/, 8];
return [4 /*yield*/, response.text()];
case 7:
text = _e.sent();
throw new AppConfigApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
case 8: return [2 /*return*/, response.json()];
}
});
});
}
function requestAppConfig(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_2, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestAppConfigOnce(APP_CONFIG_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_2 = _a.sent();
shouldRetryWithFallback = APP_CONFIG_FALLBACK_BASE_URL &&
APP_CONFIG_FALLBACK_BASE_URL !== APP_CONFIG_API_BASE_URL &&
(error_2 instanceof AppConfigApiError
? error_2.status === 404 || error_2.status === 408 || error_2.status === 502
: error_2 instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error_2.message));
if (!shouldRetryWithFallback) {
throw error_2;
}
return [2 /*return*/, requestAppConfigOnce(APP_CONFIG_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function fetchAppConfigFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response, config, preference, _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_b.trys.push([0, 3, , 4]);
return [4 /*yield*/, requestAppConfig(APP_CONFIG_API_PATH)];
case 1:
response = _b.sent();
config = normalizeConfig(response.config);
return [4 /*yield*/, fetchAutomationNotificationPreferenceFromServer()];
case 2:
preference = _b.sent();
return [2 /*return*/, mergeAutomationNotificationSettings(config, preference)];
case 3:
_a = _b.sent();
return [2 /*return*/, null];
case 4: return [2 /*return*/];
}
});
});
}
function fetchAutomationNotificationPreferenceFromServer() {
return __awaiter(this, void 0, void 0, function () {
var target, query, response, _a;
var _b;
return __generator(this, function (_c) {
switch (_c.label) {
case 0:
_c.trys.push([0, 2, , 3]);
target = (0, notificationIdentity_1.getAutomationNotificationPreferenceTarget)();
query = target ? "?".concat(new URLSearchParams(target).toString()) : '';
return [4 /*yield*/, requestAppConfig("".concat(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH).concat(query))];
case 1:
response = _c.sent();
return [2 /*return*/, (_b = response.automation) !== null && _b !== void 0 ? _b : null];
case 2:
_a = _c.sent();
return [2 /*return*/, null];
case 3: return [2 /*return*/];
}
});
});
}
function saveAppConfigToServer(config) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAppConfig(APP_CONFIG_API_PATH, {
method: 'PUT',
body: JSON.stringify({ config: config }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizeConfig(response.config)];
}
});
});
}
function saveAutomationNotificationPreferenceToServer(config) {
return __awaiter(this, void 0, void 0, function () {
var target, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
target = (0, notificationIdentity_1.getAutomationNotificationPreferenceTarget)();
return [4 /*yield*/, requestAppConfig(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH, {
method: 'PUT',
body: JSON.stringify(__assign(__assign({}, (target !== null && target !== void 0 ? target : {})), { automation: pickAutomationNotificationSettings(config.automation) })),
})];
case 1:
response = _a.sent();
return [2 /*return*/, mergeAutomationNotificationSettings(config, response.automation)];
}
});
});
}
function syncAppConfigFromServer() {
return __awaiter(this, void 0, void 0, function () {
var config;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, fetchAppConfigFromServer()];
case 1:
config = _a.sent();
if (!config) {
return [2 /*return*/, false];
}
setStoredAppConfig(config);
return [2 /*return*/, true];
}
});
});
}
function getStoredAppConfig() {
if (typeof window === 'undefined') {
return exports.DEFAULT_APP_CONFIG;
}
try {
var raw = window.localStorage.getItem(exports.APP_CONFIG_STORAGE_KEY);
if (!raw) {
cachedConfig = exports.DEFAULT_APP_CONFIG;
cachedRawConfig = null;
return exports.DEFAULT_APP_CONFIG;
}
if (raw === cachedRawConfig && cachedConfig) {
return cachedConfig;
}
var normalized = normalizeConfig(JSON.parse(raw));
cachedConfig = normalized;
cachedRawConfig = raw;
return normalized;
}
catch (_a) {
cachedConfig = exports.DEFAULT_APP_CONFIG;
cachedRawConfig = null;
return exports.DEFAULT_APP_CONFIG;
}
}
function setStoredAppConfig(config) {
if (typeof window === 'undefined') {
return;
}
var normalized = normalizeConfig(config);
var raw = JSON.stringify(normalized);
cachedConfig = normalized;
cachedRawConfig = raw;
window.localStorage.setItem(exports.APP_CONFIG_STORAGE_KEY, raw);
emitConfigChange();
}
function updateStoredAppConfig(updater) {
var current = getStoredAppConfig();
var next = updater(current);
setStoredAppConfig(next);
}
function subscribeToAppConfig(callback) {
if (typeof window === 'undefined') {
return function () { return undefined; };
}
var handleChange = function () {
callback();
};
window.addEventListener(APP_CONFIG_EVENT, handleChange);
window.addEventListener('storage', handleChange);
return function () {
window.removeEventListener(APP_CONFIG_EVENT, handleChange);
window.removeEventListener('storage', handleChange);
};
}
function useAppConfig() {
return (0, react_1.useSyncExternalStore)(subscribeToAppConfig, getStoredAppConfig, function () { return exports.DEFAULT_APP_CONFIG; });
}
function describeAutoReceiveSchedule(config) {
var automation = config.automation;
if (automation.autoReceiveScheduleType === 'daily') {
return "\uB9E4\uC77C ".concat(automation.autoReceiveDailyTime);
}
if (automation.autoReceiveScheduleType === 'weekly') {
return "\uB9E4\uC8FC ".concat(WEEKLY_DAY_LABELS[automation.autoReceiveWeeklyDay], " ").concat(automation.autoReceiveWeeklyTime);
}
return "".concat(automation.autoReceiveIntervalSeconds, "\uCD08\uB9C8\uB2E4");
}
function getWeeklyScheduleOptions() {
return Object.entries(WEEKLY_DAY_LABELS).map(function (_a) {
var value = _a[0], label = _a[1];
return ({
value: value,
label: label,
});
});
}

View File

@@ -21,6 +21,7 @@ export type AppConfig = {
codexLiveMaxExecutionSeconds: number;
codexLiveIdleTimeoutSeconds: number;
receiveRoomNotifications: boolean;
restartReservationCompletionDelaySeconds: number;
};
automation: {
autoRefreshEnabled: boolean;
@@ -76,6 +77,7 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
restartReservationCompletionDelaySeconds: 10,
},
automation: {
autoRefreshEnabled: true,
@@ -265,6 +267,14 @@ function normalizeCodexLiveIdleTimeoutSeconds(value: number | undefined, fallbac
return Math.min(3600, Math.max(30, Math.round(value)));
}
function normalizeRestartReservationCompletionDelaySeconds(value: number | undefined, fallback: number) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(300, Math.max(1, Math.round(value)));
}
function normalizeBooleanValue(value: boolean | undefined, fallback: boolean) {
if (typeof value !== 'boolean') {
return fallback;
@@ -300,6 +310,10 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
chat?.receiveRoomNotifications,
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
),
restartReservationCompletionDelaySeconds: normalizeRestartReservationCompletionDelaySeconds(
chat?.restartReservationCompletionDelaySeconds,
DEFAULT_APP_CONFIG.chat.restartReservationCompletionDelaySeconds,
),
},
automation: {
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
@@ -423,6 +437,22 @@ function mergeAutomationNotificationSettings(
});
}
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
function emitConfigChange() {
if (typeof window === 'undefined') {
return;
@@ -485,6 +515,17 @@ async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: Req
headers.set('Content-Type', 'application/json');
}
const appOrigin = getCurrentAppOrigin();
const appDomain = getCurrentAppDomain();
if (appOrigin && !headers.has('X-App-Origin')) {
headers.set('X-App-Origin', appOrigin);
}
if (appDomain && !headers.has('X-App-Domain')) {
headers.set('X-App-Domain', appDomain);
}
let response: Response;
try {

View File

@@ -0,0 +1,398 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_AUTOMATION_CONTEXTS = void 0;
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
exports.upsertAutomationContext = upsertAutomationContext;
exports.deleteAutomationContext = deleteAutomationContext;
exports.buildAutomationContextOptions = buildAutomationContextOptions;
exports.resolveDefaultAutomationContextIds = resolveDefaultAutomationContextIds;
exports.useAutomationContextRegistry = useAutomationContextRegistry;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
var AUTOMATION_CONTEXTS_API_PATH = '/automation-contexts';
var AUTOMATION_CONTEXT_SYNC_EVENT = 'work-app:automation-contexts-changed';
var AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS = 8000;
exports.DEFAULT_AUTOMATION_CONTEXTS = [
{
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) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function compareContextUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record) {
var title = normalizeText(record.title);
var content = normalizeText(record.content);
if (!title && !content) {
return null;
}
var id = normalizeText(record.id) ||
"automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
title: title || 'Context',
content: content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationContexts(items) {
var byId = new Map();
var bySemanticKey = new Map();
(items !== null && items !== void 0 ? items : [])
.map(function (item) { return normalizeAutomationContext(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
var current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
return values.length > 0 ? values : exports.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;
}
var hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var API_BASE_URL = resolveApiBaseUrl();
var FALLBACK_BASE_URL = resolveFallbackBaseUrl();
function requestOnce(baseUrl, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, controller, timeoutId, response, _a;
var _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && (init === null || init === void 0 ? void 0 : init.body) !== null;
controller = new AbortController();
timeoutId = window.setTimeout(function () { return controller.abort(); }, AUTOMATION_CONTEXT_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
_d.label = 1;
case 1:
_d.trys.push([1, , 5, 6]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(AUTOMATION_CONTEXTS_API_PATH), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_b = init === null || init === void 0 ? void 0 : init.cache) !== null && _b !== void 0 ? _b : (((_c = init === null || init === void 0 ? void 0 : init.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()) === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 4];
_a = Error.bind;
return [4 /*yield*/, response.text()];
case 3: throw new (_a.apply(Error, [void 0, (_d.sent()) || '자동화 Context 요청에 실패했습니다.']))();
case 4: return [2 /*return*/, response.json()];
case 5:
window.clearTimeout(timeoutId);
return [7 /*endfinally*/];
case 6: return [2 /*return*/];
}
});
});
}
function requestAutomationContexts(init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(API_BASE_URL, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error_1 instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error_1.message);
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(FALLBACK_BASE_URL, init)];
case 3: return [2 /*return*/];
}
});
});
}
function loadAutomationContextsFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAutomationContexts({
method: 'GET',
})];
case 1:
response = _a.sent();
if (response.automationContexts == null) {
return [2 /*return*/, exports.DEFAULT_AUTOMATION_CONTEXTS];
}
return [2 /*return*/, sanitizeAutomationContexts(response.automationContexts)];
}
});
});
}
function saveAutomationContextsToServer(items) {
return __awaiter(this, void 0, void 0, function () {
var resolved, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
resolved = sanitizeAutomationContexts(items);
return [4 /*yield*/, requestAutomationContexts({
method: 'PUT',
body: JSON.stringify({ automationContexts: resolved }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, sanitizeAutomationContexts(response.automationContexts)];
}
});
});
}
function upsertAutomationContext(items, input) {
var nextItem = normalizeAutomationContext(input);
if (!nextItem) {
return sanitizeAutomationContexts(items);
}
var nextItems = items.filter(function (item) { return item.id !== nextItem.id; });
nextItems.push(nextItem);
return sanitizeAutomationContexts(nextItems);
}
function deleteAutomationContext(items, automationContextId) {
var normalizedId = normalizeText(automationContextId);
if (!normalizedId) {
return sanitizeAutomationContexts(items);
}
return sanitizeAutomationContexts(items.filter(function (item) { return item.id !== normalizedId; }));
}
function buildAutomationContextOptions(items, selectedContextIds) {
if (selectedContextIds === void 0) { selectedContextIds = []; }
var contexts = sanitizeAutomationContexts(items);
var selectedSet = new Set(selectedContextIds);
var enabledIds = new Set(contexts.filter(function (item) { return item.enabled; }).map(function (item) { return item.id; }));
return contexts
.filter(function (item) { return enabledIds.has(item.id) || selectedSet.has(item.id); })
.map(function (item) { return ({
label: item.title,
value: item.id,
}); });
}
function resolveDefaultAutomationContextIds(items) {
return sanitizeAutomationContexts(items)
.filter(function (item) { return item.enabled && item.defaultSelected; })
.map(function (item) { return item.id; });
}
function useAutomationContextRegistry() {
var _this = this;
var _a = (0, react_1.useState)(exports.DEFAULT_AUTOMATION_CONTEXTS), automationContexts = _a[0], setAutomationContextsState = _a[1];
var _b = (0, react_1.useState)(true), isLoading = _b[0], setIsLoading = _b[1];
var _c = (0, react_1.useState)(''), errorMessage = _c[0], setErrorMessage = _c[1];
var mountedRef = (0, react_1.useRef)(true);
(0, react_1.useEffect)(function () {
mountedRef.current = true;
return function () {
mountedRef.current = false;
};
}, []);
(0, react_1.useEffect)(function () {
var cancelled = false;
var load = function () { return __awaiter(_this, void 0, void 0, function () {
var nextAutomationContexts, error_2;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
setIsLoading(true);
setErrorMessage('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, loadAutomationContextsFromServer()];
case 2:
nextAutomationContexts = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(nextAutomationContexts);
}
return [3 /*break*/, 5];
case 3:
error_2 = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationContextsState(exports.DEFAULT_AUTOMATION_CONTEXTS);
setErrorMessage(error_2 instanceof Error ? error_2.message : '자동화 Context를 불러오지 못했습니다.');
}
return [3 /*break*/, 5];
case 4:
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
void load();
var handleSync = function () {
void load();
};
window.addEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
return function () {
cancelled = true;
window.removeEventListener(AUTOMATION_CONTEXT_SYNC_EVENT, handleSync);
};
}, []);
var setAutomationContexts = function (nextItems) { return __awaiter(_this, void 0, void 0, function () {
var saved;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, saveAutomationContextsToServer(nextItems)];
case 1:
saved = _a.sent();
if (mountedRef.current) {
setAutomationContextsState(saved);
setErrorMessage('');
}
emitAutomationContextsChange();
return [2 /*return*/, saved];
}
});
}); };
return {
automationContexts: automationContexts,
setAutomationContexts: setAutomationContexts,
isLoading: isLoading,
errorMessage: errorMessage,
};
}

View File

@@ -0,0 +1,577 @@
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {
if (ar || !(i in from)) {
if (!ar) ar = Array.prototype.slice.call(from, 0, i);
ar[i] = from[i];
}
}
return to.concat(ar || Array.prototype.slice.call(from));
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AUTOMATION_BEHAVIOR_LABELS = exports.AUTOMATION_BEHAVIOR_TYPES = void 0;
exports.normalizeAutomationTypeId = normalizeAutomationTypeId;
exports.sanitizeAutomationContexts = sanitizeAutomationContexts;
exports.upsertAutomationType = upsertAutomationType;
exports.deleteAutomationType = deleteAutomationType;
exports.resolveAutomationTypeLabel = resolveAutomationTypeLabel;
exports.buildAutomationTypeOptions = buildAutomationTypeOptions;
exports.buildAutomationContextOptions = buildAutomationContextOptions;
exports.resolveDefaultAutomationContextIds = resolveDefaultAutomationContextIds;
exports.useAutomationTypeRegistry = useAutomationTypeRegistry;
var react_1 = require("react");
var clientIdentity_1 = require("./clientIdentity");
exports.AUTOMATION_BEHAVIOR_TYPES = [
'none',
'plan',
'command_execution',
'non_source_work',
'auto_worker',
];
var AUTOMATION_TYPES_API_PATH = '/automation-types';
var AUTOMATION_TYPE_SYNC_EVENT = 'work-app:automation-types-changed';
var AUTOMATION_TYPE_REQUEST_TIMEOUT_MS = 8000;
exports.AUTOMATION_BEHAVIOR_LABELS = {
none: '기본유형',
plan: '작업 요청 등록',
command_execution: 'Command 실행',
non_source_work: '비 소스작업',
auto_worker: 'autoWorker',
};
var DEFAULT_AUTOMATION_TYPES = [
{
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: '기본 자동화 처리용 유형입니다.',
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',
},
{
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',
},
{
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',
},
{
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',
},
{
id: 'auto_worker',
name: 'autoWorker',
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',
},
];
function normalizeText(value) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function normalizeAutomationTypeId(value) {
var normalized = normalizeText(typeof value === 'string' ? value : '');
if (normalized === 'stock-alert') {
return 'general-inquiry';
}
if (normalized === 'plan_registration') {
return 'plan';
}
if (normalized === 'general_development') {
return 'auto_worker';
}
return normalized || 'none';
}
function normalizeBehaviorType(value) {
var normalized = normalizeAutomationTypeId(value);
return exports.AUTOMATION_BEHAVIOR_TYPES.includes(normalized)
? normalized
: 'none';
}
function getSemanticKey(record) {
return "".concat(record.behaviorType, ":").concat(normalizeText(record.name).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'));
}
function compareUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function compareContextUpdatedAt(left, right) {
var leftTime = Date.parse(left.updatedAt);
var rightTime = Date.parse(right.updatedAt);
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
return leftTime - rightTime;
}
return 0;
}
function normalizeAutomationContext(record) {
var title = normalizeText(record.title);
var content = normalizeText(record.content);
if (!title && !content) {
return null;
}
var id = normalizeText(record.id) ||
"automation-context-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
title: title || 'Context',
content: content,
enabled: record.enabled !== false,
defaultSelected: record.defaultSelected !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationContexts(items) {
var byId = new Map();
var bySemanticKey = new Map();
(items !== null && items !== void 0 ? items : [])
.map(function (item) { return normalizeAutomationContext(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareContextUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = normalizeText(item.title).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
var current = bySemanticKey.get(semanticKey);
if (!current || compareContextUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.title.localeCompare(right.title, 'ko-KR'); });
}
function normalizeAutomationType(record) {
var name = normalizeText(record.name);
if (!name) {
return null;
}
var id = normalizeText(record.id) ||
"automation-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8));
return {
id: id,
name: name,
description: normalizeText(record.description),
contexts: sanitizeAutomationContexts(record.contexts),
behaviorType: normalizeBehaviorType(record.behaviorType),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function sanitizeAutomationTypes(items) {
var byId = new Map();
var bySemanticKey = new Map();
items
.map(function (item) { return normalizeAutomationType(item); })
.filter(function (item) { return Boolean(item); })
.forEach(function (item) {
var currentById = byId.get(item.id);
if (!currentById || compareUpdatedAt(currentById, item) <= 0) {
byId.set(item.id, item);
}
});
for (var _i = 0, _a = byId.values(); _i < _a.length; _i++) {
var item = _a[_i];
var semanticKey = getSemanticKey(item);
var current = bySemanticKey.get(semanticKey);
if (!current || compareUpdatedAt(current, item) <= 0) {
bySemanticKey.set(semanticKey, item);
}
}
var values = Array.from(bySemanticKey.values()).sort(function (left, right) { return 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;
}
var hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var API_BASE_URL = resolveApiBaseUrl();
var FALLBACK_BASE_URL = resolveFallbackBaseUrl();
function requestOnce(baseUrl, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, controller, timeoutId, response, _a;
var _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && (init === null || init === void 0 ? void 0 : init.body) !== null;
controller = new AbortController();
timeoutId = window.setTimeout(function () { return controller.abort(); }, AUTOMATION_TYPE_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
_d.label = 1;
case 1:
_d.trys.push([1, , 5, 6]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(AUTOMATION_TYPES_API_PATH), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_b = init === null || init === void 0 ? void 0 : init.cache) !== null && _b !== void 0 ? _b : (((_c = init === null || init === void 0 ? void 0 : init.method) === null || _c === void 0 ? void 0 : _c.toUpperCase()) === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 4];
_a = Error.bind;
return [4 /*yield*/, response.text()];
case 3: throw new (_a.apply(Error, [void 0, (_d.sent()) || '자동화 처리 유형 요청에 실패했습니다.']))();
case 4: return [2 /*return*/, response.json()];
case 5:
window.clearTimeout(timeoutId);
return [7 /*endfinally*/];
case 6: return [2 /*return*/];
}
});
});
}
function requestAutomationTypes(init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(API_BASE_URL, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
error_1 instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error_1.message);
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(FALLBACK_BASE_URL, init)];
case 3: return [2 /*return*/];
}
});
});
}
function loadAutomationTypesFromServer() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestAutomationTypes({
method: 'GET',
})];
case 1:
response = _a.sent();
if (response.automationTypes == null) {
return [2 /*return*/, DEFAULT_AUTOMATION_TYPES];
}
return [2 /*return*/, sanitizeAutomationTypes(response.automationTypes)];
}
});
});
}
function saveAutomationTypesToServer(items) {
return __awaiter(this, void 0, void 0, function () {
var resolved, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
resolved = sanitizeAutomationTypes(items);
return [4 /*yield*/, requestAutomationTypes({
method: 'PUT',
body: JSON.stringify({ automationTypes: resolved }),
})];
case 1:
response = _a.sent();
return [2 /*return*/, sanitizeAutomationTypes(response.automationTypes)];
}
});
});
}
function upsertAutomationType(items, input) {
var nextItem = normalizeAutomationType(input);
if (!nextItem) {
return sanitizeAutomationTypes(items);
}
var nextItems = items.filter(function (item) { return item.id !== nextItem.id; });
nextItems.push(nextItem);
return sanitizeAutomationTypes(nextItems);
}
function deleteAutomationType(items, automationTypeId) {
var normalizedId = normalizeText(automationTypeId);
if (!normalizedId) {
return sanitizeAutomationTypes(items);
}
return sanitizeAutomationTypes(items.filter(function (item) { return item.id !== normalizedId; }));
}
function resolveAutomationTypeLabel(items, automationTypeId) {
var _a, _b;
var normalizedId = normalizeAutomationTypeId(automationTypeId);
return (_b = (_a = items.find(function (item) { return item.id === normalizedId; })) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : normalizedId;
}
function buildAutomationTypeOptions(items, value) {
var normalizedValue = normalizeAutomationTypeId(value);
var enabledItems = items.filter(function (item) { return item.enabled; });
var currentItem = items.find(function (item) { return item.id === normalizedValue; });
var source = currentItem && !enabledItems.some(function (item) { return item.id === currentItem.id; }) ? __spreadArray(__spreadArray([], enabledItems, true), [currentItem], false) : enabledItems;
if (source.length === 0) {
return [{ label: '선택 안함', value: 'none' }];
}
return source.map(function (item) { return ({
label: item.name,
value: item.id,
}); });
}
function buildAutomationContextOptions(items, automationTypeId, selectedContextIds) {
var _a;
if (selectedContextIds === void 0) { selectedContextIds = []; }
var normalizedId = normalizeAutomationTypeId(automationTypeId);
var automationType = (_a = items.find(function (item) { return item.id === normalizedId; })) !== null && _a !== void 0 ? _a : null;
var contexts = sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts);
var selectedSet = new Set(selectedContextIds);
var enabledIds = new Set(contexts.filter(function (item) { return item.enabled; }).map(function (item) { return item.id; }));
return contexts
.filter(function (item) { return enabledIds.has(item.id) || selectedSet.has(item.id); })
.map(function (item) { return ({
label: item.title,
value: item.id,
}); });
}
function resolveDefaultAutomationContextIds(items, automationTypeId) {
var _a;
var normalizedId = normalizeAutomationTypeId(automationTypeId);
var automationType = (_a = items.find(function (item) { return item.id === normalizedId; })) !== null && _a !== void 0 ? _a : null;
return sanitizeAutomationContexts(automationType === null || automationType === void 0 ? void 0 : automationType.contexts)
.filter(function (item) { return item.enabled && item.defaultSelected; })
.map(function (item) { return item.id; });
}
function useAutomationTypeRegistry() {
var _this = this;
var _a = (0, react_1.useState)(DEFAULT_AUTOMATION_TYPES), automationTypes = _a[0], setAutomationTypesState = _a[1];
var _b = (0, react_1.useState)(true), isLoading = _b[0], setIsLoading = _b[1];
var _c = (0, react_1.useState)(''), errorMessage = _c[0], setErrorMessage = _c[1];
var mountedRef = (0, react_1.useRef)(true);
(0, react_1.useEffect)(function () {
mountedRef.current = true;
return function () {
mountedRef.current = false;
};
}, []);
(0, react_1.useEffect)(function () {
var cancelled = false;
var load = function () { return __awaiter(_this, void 0, void 0, function () {
var nextAutomationTypes, error_2;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
setIsLoading(true);
setErrorMessage('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, loadAutomationTypesFromServer()];
case 2:
nextAutomationTypes = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(nextAutomationTypes);
}
return [3 /*break*/, 5];
case 3:
error_2 = _a.sent();
if (!cancelled && mountedRef.current) {
setAutomationTypesState(DEFAULT_AUTOMATION_TYPES);
setErrorMessage(error_2 instanceof Error ? error_2.message : '자동화 처리 유형을 불러오지 못했습니다.');
}
return [3 /*break*/, 5];
case 4:
if (!cancelled && mountedRef.current) {
setIsLoading(false);
}
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
void load();
var handleSync = function () {
void load();
};
window.addEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
return function () {
cancelled = true;
window.removeEventListener(AUTOMATION_TYPE_SYNC_EVENT, handleSync);
};
}, []);
var setAutomationTypes = function (nextItems) { return __awaiter(_this, void 0, void 0, function () {
var saved;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, saveAutomationTypesToServer(nextItems)];
case 1:
saved = _a.sent();
if (mountedRef.current) {
setAutomationTypesState(saved);
setErrorMessage('');
}
emitAutomationTypesChange();
return [2 /*return*/, saved];
}
});
}); };
return {
automationTypes: automationTypes,
setAutomationTypes: setAutomationTypes,
isLoading: isLoading,
errorMessage: errorMessage,
};
}

View File

@@ -0,0 +1,521 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
export type ChatDefaultContextRecord = {
id: string;
title: string;
content: string;
enabled: boolean;
updatedAt: string;
};
export type ChatTypeDefaultContextSelection = {
chatTypeId: string;
defaultContextIds: string[];
updatedAt: string;
};
export type ChatRoomContextSettings = {
sessionId: string;
defaultContextIds: string[];
customContextTitle: string;
customContextContent: string;
updatedAt: string;
};
type ChatContextSettingsStore = {
defaultContexts: ChatDefaultContextRecord[];
chatTypeDefaults: ChatTypeDefaultContextSelection[];
roomContexts: ChatRoomContextSettings[];
};
const CHAT_CONTEXT_SETTINGS_STORAGE_KEY = 'work-app:chat-context-settings';
const CHAT_CONTEXT_SETTINGS_SYNC_EVENT = 'work-app:chat-context-settings-changed';
const CHAT_CONTEXT_SETTINGS_API_PATH = '/chat-context-settings';
const CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
const DEFAULT_CHAT_DEFAULT_CONTEXTS: ChatDefaultContextRecord[] = [
{
id: 'chat-default-mobile-verification',
title: '모바일 검증',
content:
'## 검증\n- UI 변경은 모바일 브라우저 환경에서 먼저 확인합니다.\n- 토큰 등록이 필요한 화면은 등록 토큰이 주입된 상태에서 검증합니다.',
enabled: true,
updatedAt: '2026-05-03T00:00:00.000Z',
},
{
id: 'chat-default-resource-output',
title: '리소스 출력',
content:
'## 산출물\n- 문서, 이미지, 코드 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 경로 기준으로 제공합니다.\n- 최종 검증 이미지는 `[[preview:URL]]` 형식으로 남깁니다.',
enabled: true,
updatedAt: '2026-05-03T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function compareUpdatedAt(left: { updatedAt: string }, right: { updatedAt: string }) {
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 normalizeDefaultContext(record: Partial<ChatDefaultContextRecord>): ChatDefaultContextRecord | null {
const title = normalizeText(record.title);
const content = normalizeText(record.content);
if (!title && !content) {
return null;
}
return {
id:
normalizeText(record.id) ||
`chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
title: title || '기본 유형',
content,
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefined) {
return Array.from(new Set((defaultContextIds ?? []).map((item) => normalizeText(item)).filter(Boolean)));
}
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
const byId = new Map<string, ChatDefaultContextRecord>();
[...(items ?? []), ...DEFAULT_CHAT_DEFAULT_CONTEXTS]
.map((item) => normalizeDefaultContext(item))
.filter((item): item is ChatDefaultContextRecord => Boolean(item))
.forEach((item) => {
const current = byId.get(item.id);
if (!current || compareUpdatedAt(current, item) <= 0) {
byId.set(item.id, item);
}
});
return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR'));
}
function sanitizeChatTypeDefaultSelections(items: Partial<ChatTypeDefaultContextSelection>[] | null | undefined) {
const byChatTypeId = new Map<string, ChatTypeDefaultContextSelection>();
(items ?? []).forEach((item) => {
const chatTypeId = normalizeText(item.chatTypeId);
if (!chatTypeId) {
return;
}
const nextRecord: ChatTypeDefaultContextSelection = {
chatTypeId,
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
};
const current = byChatTypeId.get(chatTypeId);
if (!current || compareUpdatedAt(current, nextRecord) <= 0) {
byChatTypeId.set(chatTypeId, nextRecord);
}
});
return Array.from(byChatTypeId.values()).sort((left, right) => left.chatTypeId.localeCompare(right.chatTypeId, 'ko-KR'));
}
function sanitizeRoomContexts(items: Partial<ChatRoomContextSettings>[] | null | undefined) {
const bySessionId = new Map<string, ChatRoomContextSettings>();
(items ?? []).forEach((item) => {
const sessionId = normalizeText(item.sessionId);
if (!sessionId) {
return;
}
const nextRecord: ChatRoomContextSettings = {
sessionId,
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
customContextTitle: normalizeText(item.customContextTitle),
customContextContent: normalizeText(item.customContextContent),
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
};
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
if (!hasCustomContext && !hasDefaultOverrides) {
return;
}
const current = bySessionId.get(sessionId);
if (!current || compareUpdatedAt(current, nextRecord) <= 0) {
bySessionId.set(sessionId, nextRecord);
}
});
return Array.from(bySessionId.values()).sort((left, right) => left.sessionId.localeCompare(right.sessionId, 'ko-KR'));
}
function sanitizeStore(input: Partial<ChatContextSettingsStore> | null | undefined): ChatContextSettingsStore {
return {
defaultContexts: sanitizeDefaultContexts(input?.defaultContexts),
chatTypeDefaults: sanitizeChatTypeDefaultSelections(input?.chatTypeDefaults),
roomContexts: sanitizeRoomContexts(input?.roomContexts),
};
}
function loadStore() {
if (typeof window === 'undefined') {
return sanitizeStore(null);
}
try {
const raw = window.localStorage.getItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY);
if (!raw) {
return sanitizeStore(null);
}
return sanitizeStore(JSON.parse(raw) as Partial<ChatContextSettingsStore>);
} catch {
return sanitizeStore(null);
}
}
function emitStoreChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(CHAT_CONTEXT_SETTINGS_SYNC_EVENT));
}
function saveStore(store: ChatContextSettingsStore) {
if (typeof window === 'undefined') {
return store;
}
const sanitized = sanitizeStore(store);
window.localStorage.setItem(CHAT_CONTEXT_SETTINGS_STORAGE_KEY, JSON.stringify(sanitized));
emitStoreChange();
return sanitized;
}
function resolveChatContextSettingsApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveChatContextSettingsFallbackBaseUrl() {
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 CHAT_CONTEXT_SETTINGS_API_BASE_URL = resolveChatContextSettingsApiBaseUrl();
const CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL = resolveChatContextSettingsFallbackBaseUrl();
async function requestChatContextSettingsOnce<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(), CHAT_CONTEXT_SETTINGS_REQUEST_TIMEOUT_MS);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
try {
const response = await fetch(`${baseUrl}${CHAT_CONTEXT_SETTINGS_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 requestChatContextSettings<T>(init?: RequestInit) {
try {
return await requestChatContextSettingsOnce<T>(CHAT_CONTEXT_SETTINGS_API_BASE_URL, init);
} catch (error) {
const shouldRetryWithFallback =
CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL &&
CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL !== CHAT_CONTEXT_SETTINGS_API_BASE_URL &&
error instanceof Error &&
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
if (!shouldRetryWithFallback) {
throw error;
}
return requestChatContextSettingsOnce<T>(CHAT_CONTEXT_SETTINGS_FALLBACK_BASE_URL, init);
}
}
async function fetchStoreFromServer() {
const response = await requestChatContextSettings<{
ok: boolean;
settings?: Partial<ChatContextSettingsStore> | null;
}>({
method: 'GET',
});
return sanitizeStore(response.settings);
}
async function saveStoreToServer(store: ChatContextSettingsStore) {
const sanitized = sanitizeStore(store);
const response = await requestChatContextSettings<{
ok: boolean;
settings?: Partial<ChatContextSettingsStore> | null;
}>({
method: 'PUT',
body: JSON.stringify({ settings: sanitized }),
});
emitStoreChange();
return sanitizeStore(response.settings ?? sanitized);
}
export function resolveChatTypeDefaultContextIds(
selections: ChatTypeDefaultContextSelection[],
chatTypeId: string | null | undefined,
) {
const normalizedChatTypeId = normalizeText(chatTypeId);
if (!normalizedChatTypeId) {
return [];
}
return (
selections.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds.map((item) => item.trim()).filter(Boolean) ??
[]
);
}
export function resolveChatRoomContextSettings(
roomContexts: ChatRoomContextSettings[],
sessionId: string | null | undefined,
) {
const normalizedSessionId = normalizeText(sessionId);
if (!normalizedSessionId) {
return null;
}
return roomContexts.find((item) => item.sessionId === normalizedSessionId) ?? null;
}
export function upsertChatDefaultContext(
defaultContexts: ChatDefaultContextRecord[],
input: Partial<ChatDefaultContextRecord>,
) {
const nextRecord = normalizeDefaultContext({
...input,
updatedAt: new Date().toISOString(),
});
if (!nextRecord) {
return sanitizeDefaultContexts(defaultContexts);
}
return sanitizeDefaultContexts([
...defaultContexts.filter((item) => item.id !== nextRecord.id),
nextRecord,
]);
}
export function deleteChatDefaultContext(defaultContexts: ChatDefaultContextRecord[], contextId: string) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeDefaultContexts(defaultContexts);
}
return sanitizeDefaultContexts(defaultContexts.filter((item) => item.id !== normalizedContextId));
}
export function upsertChatTypeDefaultContextSelection(
selections: ChatTypeDefaultContextSelection[],
chatTypeId: string,
defaultContextIds: string[],
) {
const normalizedChatTypeId = normalizeText(chatTypeId);
if (!normalizedChatTypeId) {
return sanitizeChatTypeDefaultSelections(selections);
}
const nextRecord: ChatTypeDefaultContextSelection = {
chatTypeId: normalizedChatTypeId,
defaultContextIds: normalizeDefaultContextIds(defaultContextIds),
updatedAt: new Date().toISOString(),
};
return sanitizeChatTypeDefaultSelections([
...selections.filter((item) => item.chatTypeId !== normalizedChatTypeId),
nextRecord,
]);
}
export function pruneChatTypeDefaultSelections(
selections: ChatTypeDefaultContextSelection[],
contextId: string,
) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeChatTypeDefaultSelections(selections);
}
return sanitizeChatTypeDefaultSelections(
selections.map((item) => ({
...item,
defaultContextIds: item.defaultContextIds.filter((candidate) => candidate !== normalizedContextId),
})),
);
}
export function upsertChatRoomContextSettings(
roomContexts: ChatRoomContextSettings[],
input: Partial<ChatRoomContextSettings> & { sessionId: string },
) {
const nextRecord: ChatRoomContextSettings = {
sessionId: normalizeText(input.sessionId),
defaultContextIds: normalizeDefaultContextIds(input.defaultContextIds),
customContextTitle: normalizeText(input.customContextTitle),
customContextContent: normalizeText(input.customContextContent),
updatedAt: new Date().toISOString(),
};
if (!nextRecord.sessionId) {
return sanitizeRoomContexts(roomContexts);
}
return sanitizeRoomContexts([
...roomContexts.filter((item) => item.sessionId !== nextRecord.sessionId),
nextRecord,
]);
}
export function pruneChatRoomContextSettings(roomContexts: ChatRoomContextSettings[], contextId: string) {
const normalizedContextId = normalizeText(contextId);
if (!normalizedContextId) {
return sanitizeRoomContexts(roomContexts);
}
return sanitizeRoomContexts(
roomContexts.map((item) => ({
...item,
defaultContextIds: item.defaultContextIds.filter((candidate) => candidate !== normalizedContextId),
})),
);
}
export function useChatContextSettingsRegistry() {
const [store, setStoreState] = useState<ChatContextSettingsStore>(() => loadStore());
const [errorMessage, setErrorMessage] = useState('');
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
const syncStore = async () => {
try {
const serverStore = await fetchStoreFromServer();
const saved = saveStore(serverStore);
if (isMountedRef.current) {
setStoreState(saved);
setErrorMessage('');
}
} catch (error) {
const localStore = loadStore();
if (isMountedRef.current) {
setStoreState(localStore);
setErrorMessage(error instanceof Error ? error.message : '채팅 Context 설정을 불러오지 못했습니다.');
}
}
};
void syncStore();
const handleSync = () => {
void syncStore();
};
window.addEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
window.addEventListener('storage', syncStore);
return () => {
isMountedRef.current = false;
window.removeEventListener(CHAT_CONTEXT_SETTINGS_SYNC_EVENT, handleSync);
window.removeEventListener('storage', syncStore);
};
}, []);
return {
...store,
errorMessage,
setStore: (
updater:
| ChatContextSettingsStore
| ((current: ChatContextSettingsStore) => ChatContextSettingsStore),
) => {
const nextStore = typeof updater === 'function' ? updater(loadStore()) : updater;
const saved = saveStore(nextStore);
if (isMountedRef.current) {
setStoreState(saved);
setErrorMessage('');
}
return saveStoreToServer(saved).then((serverSaved) => {
if (isMountedRef.current) {
setStoreState(serverSaved);
}
return serverSaved;
});
},
};
}

View File

@@ -1,10 +1,6 @@
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';
import { DEFAULT_CHAT_TYPES } from './chatTypeDefaults';
export type ChatPermissionRole = 'guest' | 'token-user';
@@ -33,43 +29,6 @@ export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
'token-user': '토큰 사용자',
};
const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
{
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
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요청',
description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: 'general-inquiry',
name: '일반 문의',
description:
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
@@ -151,6 +110,10 @@ function sanitizeChatTypes(chatTypes: Partial<ChatTypeRecord>[]) {
);
}
function mergeWithDefaultChatTypes(chatTypes: Partial<ChatTypeRecord>[] | null | undefined) {
return sanitizeChatTypes([...(chatTypes ?? []), ...DEFAULT_CHAT_TYPES]);
}
function emitChatTypesChange() {
if (typeof window === 'undefined') {
return;
@@ -242,21 +205,21 @@ async function fetchChatTypesFromServer() {
});
if (response.chatTypes == null) {
return null;
return mergeWithDefaultChatTypes(DEFAULT_CHAT_TYPES);
}
return sanitizeChatTypes(response.chatTypes);
return mergeWithDefaultChatTypes(response.chatTypes);
}
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
const resolved = sanitizeChatTypes(chatTypes);
const resolved = mergeWithDefaultChatTypes(chatTypes);
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ chatTypes: resolved }),
});
emitChatTypesChange();
return sanitizeChatTypes(response.chatTypes);
return mergeWithDefaultChatTypes(response.chatTypes);
}
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {

View File

@@ -1,6 +1,76 @@
export type DefaultChatTypeRecord = {
id: string;
name: string;
description: string;
permissions: Array<'guest' | 'token-user'>;
enabled: boolean;
updatedAt: string;
};
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
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- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
'## 처리 범위\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:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID = 'layout-editor-guided-execution';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME = 'Layout editor 단계별 실행';
export const LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION =
'## 기본 처리\n- Layout editor 요청을 단계별로 나눠 진행합니다.\n- 현재 단계 결과를 확인하면서 다음 단계로 이어가는 실행 흐름에 사용합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 이 채팅유형은 특정 메뉴 하나의 전용 context를 기본값으로 두지 않고, Layout editor 화면 구성 전반에서 공통으로 사용합니다.\n\n## 레이아웃 패키지 기준\n- 특정 레이아웃에서만 쓰는 전용 기능은 공용 app 계층으로 퍼뜨리지 말고 해당 layout feature package 안에서만 구조화합니다.\n- 패키지 개선이 필요하면 전용 화면을 해당 package 내부의 page, utils, types, chat helper 등으로 먼저 분리합니다.\n- 공통 컴포넌트 승격은 다른 화면 재사용 근거가 확인될 때만 진행합니다.\n\n## 화면 정리 기준\n- 현재 단계에서 필요한 입력 영역과 참고 영역은 한 화면에 섞어 두지 말고 분리합니다.\n- 상시 노출 액션은 화면 맥락에 맞게 간결하게 유지하고, 필요하면 tooltip과 aria-label로 의미를 보완합니다.\n- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선합니다.\n\n## 산출물 기준\n- 단계별 산출물은 현재 요청 범위에 맞춰 유지합니다.\n- 특정 메뉴 전용 문서 저장 위치나 세부 규칙은 해당 메뉴 요청이 있을 때만 그 패키지 기준으로 추가 적용합니다.';
export const API_REQUEST_CHAT_TYPE_ID = 'api-request-template';
export const API_REQUEST_CHAT_TYPE_NAME = 'API요청';
export const API_REQUEST_CHAT_TYPE_DESCRIPTION =
'## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.';
export const GENERAL_INQUIRY_CHAT_TYPE_ID = 'general-inquiry';
export const GENERAL_INQUIRY_CHAT_TYPE_NAME = '일반 문의';
export const GENERAL_INQUIRY_CHAT_TYPE_DESCRIPTION =
'## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat/<chat-session-id>/resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.';
export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
{
id: GENERAL_REQUEST_CHAT_TYPE_ID,
name: GENERAL_REQUEST_CHAT_TYPE_NAME,
description: GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
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: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-02T00:00:00.000Z',
},
{
id: API_REQUEST_CHAT_TYPE_ID,
name: API_REQUEST_CHAT_TYPE_NAME,
description: API_REQUEST_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-16T00:00:00.000Z',
},
{
id: GENERAL_INQUIRY_CHAT_TYPE_ID,
name: GENERAL_INQUIRY_CHAT_TYPE_NAME,
description: GENERAL_INQUIRY_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-24T00:00:00.000Z',
},
];

View File

@@ -47,7 +47,13 @@ export type ChatGateway = {
payload: Partial<
Pick<
ChatConversationSummary,
'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'
| 'title'
| 'chatTypeId'
| 'lastChatTypeId'
| 'generalSectionName'
| 'contextLabel'
| 'contextDescription'
| 'notifyOffline'
>
>,
) => Promise<ChatConversationSummary>;

View File

@@ -20,6 +20,7 @@ type PendingChatRequest = {
requestId: string;
text: string;
mode: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
@@ -35,6 +36,7 @@ type PendingContextConfirm = {
chatTypeDescription: string;
includedContextCount: number;
omittedContextCount: number;
omitPromptHistory?: boolean;
};
type SelectedChatType = {
@@ -87,6 +89,11 @@ type UseConversationComposerControllerOptions = {
scrollViewportToBottom: () => void;
};
type SendMessageOptions = {
mode: 'queue' | 'direct';
draftText?: string;
};
export function useConversationComposerController({
activeSessionId,
appConfigChat,
@@ -219,13 +226,14 @@ export function useConversationComposerController({
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription } = request;
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, omitPromptHistory } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
omitPromptHistory: omitPromptHistory === true,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
@@ -361,12 +369,12 @@ export function useConversationComposerController({
);
const sendMessage = useCallback(
(mode: 'queue' | 'direct') => {
({ mode, draftText }: SendMessageOptions) => {
if (isComposerAttachmentUploading) {
return;
}
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
const trimmed = buildOutgoingMessageText(draftText ?? draft, composerAttachments).trim();
if (!trimmed) {
return;
@@ -427,11 +435,11 @@ export function useConversationComposerController({
);
const handleSend = useCallback(() => {
sendMessage('queue');
sendMessage({ mode: 'queue' });
}, [sendMessage]);
const handleSendImmediate = useCallback(() => {
sendMessage('direct');
sendMessage({ mode: 'direct' });
}, [sendMessage]);
return {

View File

@@ -6,6 +6,7 @@ import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
requestedSessionId: string;
enabled?: boolean;
};
type UseConversationListDataResult = {
@@ -17,37 +18,71 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = nextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries(nextItems);
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...nextItems]);
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}
export function useConversationListData({
requestedSessionId,
enabled = true,
}: UseConversationListDataOptions): UseConversationListDataResult {
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
@@ -104,65 +139,17 @@ export function useConversationListData({
useEffect(() => {
isMountedRef.current = true;
setIsConversationListLoading(true);
void loadConversationItems();
if (enabled) {
setIsConversationListLoading(true);
void loadConversationItems();
} else {
setIsConversationListLoading(false);
}
return () => {
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]);
}, [enabled, loadConversationItems]);
return {
conversationItems,

View File

@@ -14,6 +14,7 @@ type PendingChatRequest = {
requestId: string;
text: string;
mode: 'queue' | 'direct';
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;

View File

@@ -18,14 +18,40 @@ function mergeConversationRequests(
sessionId: string,
) {
const previousSessionItems = previous.filter((item) => item.sessionId === sessionId);
const previousByRequestId = new Map(previousSessionItems.map((item) => [item.requestId, item] as const));
const incomingRequestIds = new Set(incoming.map((item) => item.requestId));
const mergedIncoming = incoming.map((item) => {
const previousItem = previousByRequestId.get(item.requestId);
if (!previousItem) {
return item;
}
const nextUserText = item.userText.trim() || previousItem.userText.trim();
const nextResponseText = item.responseText.trim() || previousItem.responseText.trim();
const nextStatusMessage = item.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;
return {
...item,
statusMessage: nextStatusMessage,
userMessageId: item.userMessageId ?? previousItem.userMessageId,
userText: nextUserText,
responseMessageId: item.responseMessageId ?? previousItem.responseMessageId,
responseText: nextResponseText,
hasResponse: item.hasResponse || previousItem.hasResponse || nextResponseText.length > 0,
answeredAt: item.answeredAt ?? previousItem.answeredAt,
terminalAt: item.terminalAt ?? previousItem.terminalAt,
};
});
const preservedLocalItems = previousSessionItems.filter((item) => !incomingRequestIds.has(item.requestId));
return [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
return [...mergedIncoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
type UseConversationRoomDataOptions = {
activeSessionId: string;
activeConversationIsDraftOnly?: boolean;
activeConversationHasLocalActivity?: boolean;
oldestLoadedMessageId: number | null;
reloadKey: number;
shouldForceStickToBottomOnNextLoadRef: MutableRefObject<boolean>;
@@ -50,6 +76,8 @@ type UseConversationRoomDataOptions = {
export function useConversationRoomData({
activeSessionId,
activeConversationIsDraftOnly = false,
activeConversationHasLocalActivity = false,
oldestLoadedMessageId,
reloadKey,
shouldForceStickToBottomOnNextLoadRef,
@@ -85,6 +113,18 @@ export function useConversationRoomData({
return;
}
if (activeConversationIsDraftOnly && !activeConversationHasLocalActivity) {
previousSessionIdRef.current = activeSessionId;
setMessages([]);
setRequestItems((previous) => previous.filter((item) => item.sessionId !== activeSessionId));
setConversationLoadingLabel('첫 요청을 보내면 대화가 저장됩니다.');
setIsConversationContentLoading(false);
setIsLoadingOlderMessages(false);
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
return;
}
let isCancelled = false;
const requestedSessionId = activeSessionId;
@@ -195,6 +235,8 @@ export function useConversationRoomData({
};
}, [
activeSessionId,
activeConversationHasLocalActivity,
activeConversationIsDraftOnly,
captureViewportRestoreSnapshot,
messagesRef,
pendingViewportRestoreRef,

View File

@@ -0,0 +1,92 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CLIENT_ID_STORAGE_KEY = void 0;
exports.getClientId = getClientId;
exports.clearClientId = clearClientId;
exports.getOrCreateClientId = getOrCreateClientId;
exports.appendClientIdHeader = appendClientIdHeader;
exports.buildTrackedPageUrl = buildTrackedPageUrl;
exports.CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id';
function generateFallbackClientId() {
return "client-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 10));
}
function generateClientId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return generateFallbackClientId();
}
function getClientId() {
var _a, _b;
if (typeof window === 'undefined') {
return '';
}
return (_b = (_a = window.localStorage.getItem(exports.CLIENT_ID_STORAGE_KEY)) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
}
function clearClientId() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(exports.CLIENT_ID_STORAGE_KEY);
}
function getOrCreateClientId() {
var existingClientId = getClientId();
if (existingClientId) {
return existingClientId;
}
if (typeof window === 'undefined') {
return '';
}
var nextClientId = generateClientId();
window.localStorage.setItem(exports.CLIENT_ID_STORAGE_KEY, nextClientId);
return nextClientId;
}
function appendClientIdHeader(headersInit) {
var headers = new Headers(headersInit);
var clientId = getOrCreateClientId();
if (clientId && !headers.has('X-Client-Id')) {
headers.set('X-Client-Id', clientId);
}
return headers;
}
function buildTrackedPageUrl(page) {
if (typeof window === 'undefined') {
return '';
}
var url = new URL(window.location.href);
if (page.topMenu === 'plans') {
url.searchParams.set('topMenu', 'plans');
url.searchParams.set('planFilter', page.section);
url.searchParams.delete('planSection');
}
else {
url.searchParams.set('topMenu', page.topMenu);
url.searchParams.delete('planSection');
url.searchParams.delete('planFilter');
}
if (page.topMenu === 'docs') {
url.searchParams.set('docsSection', page.section);
}
else {
url.searchParams.delete('docsSection');
}
if (page.topMenu === 'apis') {
url.searchParams.set('apiSection', page.section);
}
else {
url.searchParams.delete('apiSection');
}
if (page.topMenu === 'chat') {
url.searchParams.set('chatSection', page.section);
}
else {
url.searchParams.delete('chatSection');
}
if (page.topMenu === 'play') {
url.searchParams.set('playSection', page.section);
}
else {
url.searchParams.delete('playSection');
}
return url.toString();
}

217
src/app/main/errorLogApi.js Normal file
View File

@@ -0,0 +1,217 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.fetchErrorLogs = fetchErrorLogs;
exports.reportClientError = reportClientError;
var clientIdentity_1 = require("./clientIdentity");
var tokenAccess_1 = require("./tokenAccess");
var ErrorLogApiError = /** @class */ (function (_super) {
__extends(ErrorLogApiError, _super);
function ErrorLogApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'ErrorLogApiError';
_this.status = status;
return _this;
}
return ErrorLogApiError;
}(Error));
function resolveErrorLogApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var ERROR_LOG_API_BASE_URL = resolveErrorLogApiBaseUrl();
var ERROR_LOG_API_FALLBACK_BASE_URL = !import.meta.env.VITE_WORK_SERVER_URL && ERROR_LOG_API_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
function requestOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, response, text, payload;
var _a, _b, _c;
return __generator(this, function (_d) {
switch (_d.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 1:
response = _d.sent();
if (!!response.ok) return [3 /*break*/, 3];
return [4 /*yield*/, response.text()];
case 2:
text = _d.sent();
try {
payload = JSON.parse(text);
throw new ErrorLogApiError(payload.message || '에러 로그 요청에 실패했습니다.', response.status);
}
catch (_e) {
throw new ErrorLogApiError(text || '에러 로그 요청에 실패했습니다.', response.status);
}
_d.label = 3;
case 3: return [2 /*return*/, response.json()];
}
});
});
}
function request(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_1, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(ERROR_LOG_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_1 = _a.sent();
shouldRetryWithFallback = ERROR_LOG_API_FALLBACK_BASE_URL &&
ERROR_LOG_API_FALLBACK_BASE_URL !== ERROR_LOG_API_BASE_URL &&
(error_1 instanceof ErrorLogApiError
? error_1.status === 404 || error_1.status === 408 || error_1.status === 502
: error_1 instanceof Error && /404|Failed to fetch|NetworkError/i.test(error_1.message));
if (!shouldRetryWithFallback) {
throw error_1;
}
return [2 /*return*/, requestOnce(ERROR_LOG_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function fetchErrorLogs() {
return __awaiter(this, arguments, void 0, function (limit) {
var token, response;
if (limit === void 0) { limit = 50; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
token = (0, tokenAccess_1.getRegisteredAccessToken)();
return [4 /*yield*/, request("/error-logs?limit=".concat(limit), {
headers: {
'X-Access-Token': token,
},
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function reportClientError(payload) {
return __awaiter(this, void 0, void 0, function () {
var _a;
var _b, _c, _d, _e, _f, _g, _h;
return __generator(this, function (_j) {
switch (_j.label) {
case 0:
_j.trys.push([0, 2, , 3]);
return [4 /*yield*/, request('/error-logs/report', {
method: 'POST',
body: JSON.stringify({
source: 'client',
sourceLabel: '프론트엔드',
errorType: payload.errorType,
errorName: (_b = payload.errorName) !== null && _b !== void 0 ? _b : null,
errorMessage: payload.errorMessage,
detail: (_c = payload.detail) !== null && _c !== void 0 ? _c : null,
stackTrace: (_d = payload.stackTrace) !== null && _d !== void 0 ? _d : null,
statusCode: (_e = payload.statusCode) !== null && _e !== void 0 ? _e : null,
requestMethod: (_f = payload.requestMethod) !== null && _f !== void 0 ? _f : null,
requestPath: (_g = payload.requestPath) !== null && _g !== void 0 ? _g : null,
context: (_h = payload.context) !== null && _h !== void 0 ? _h : null,
}),
})];
case 1:
_j.sent();
return [3 /*break*/, 3];
case 2:
_a = _j.sent();
return [3 /*break*/, 3];
case 3: return [2 /*return*/];
}
});
});
}

View File

@@ -101,7 +101,15 @@ function parseRoute(pathname: string): {
};
}
if (top === 'chat' && (first === 'live' || first === 'changes' || first === 'errors' || first === 'manage')) {
if (
top === 'chat' &&
(first === 'live' ||
first === 'changes' ||
first === 'resources' ||
first === 'errors' ||
first === 'manage' ||
first === 'manage-defaults')
) {
return {
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
@@ -112,7 +120,7 @@ function parseRoute(pathname: string): {
};
}
if (top === 'play' && first === 'layout') {
if (top === 'play' && (first === 'layout' || first === 'test' || first === 'cbt')) {
return {
topMenu: 'play',
docsMenu: DOCS_DEFAULT_FOLDER,
@@ -135,7 +143,7 @@ function parseRoute(pathname: string): {
}
return {
topMenu: 'plans',
topMenu: 'chat',
docsMenu: DOCS_DEFAULT_FOLDER,
apiMenu: 'components',
planMenu: 'all',
@@ -169,7 +177,7 @@ function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean, t
return false;
}
return topMenu !== 'docs';
return true;
}
function resolveSidebarOpenKeys(
@@ -202,7 +210,7 @@ function resolveSidebarOpenKeys(
return ['app-log-group'];
}
return chatMenu === 'manage' ? ['chat-manage-group'] : ['codex-live-group'];
return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['codex-live-group'];
}
export function MainLayout() {
@@ -328,6 +336,10 @@ export function MainLayout() {
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-left-middle-right',
hotZoneSize: 36,
minDistance: 180,
minViewportDistanceRatio: 0.35,
maxHorizontalDrift: 72,
onTrigger: () => {
openSearch('window');
},
@@ -467,7 +479,7 @@ export function MainLayout() {
/>
)}
<Layout>
<Layout className="app-shell__body">
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar
activeTopMenu={routeState.topMenu}
@@ -514,7 +526,7 @@ export function MainLayout() {
}}
onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test'));
if (isSidebarOverlayViewport) {
setSidebarCollapsed(true);
}
@@ -525,11 +537,13 @@ export function MainLayout() {
/>
)}
{isSidebarOverlayViewport && !sidebarCollapsed ? null : (
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
<Outlet />
</MainContent>
)}
<MainContent
contentExpanded={contentExpanded}
sidebarOverlayActive={isSidebarOverlayViewport && !sidebarCollapsed}
onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}
>
<Outlet />
</MainContent>
</Layout>
</Layout>
</MainLayoutContextProvider>

View File

@@ -207,6 +207,18 @@ export function buildSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('resources'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:errors',
label: '앱로그 / 에러 로그',
@@ -232,6 +244,18 @@ export function buildSearchOptions({
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage-defaults'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),

View File

@@ -20,6 +20,7 @@ import {
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
startTransition,
useEffect,
useMemo,
useRef,
@@ -33,12 +34,17 @@ import {
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 {
ChatPreviewBody,
resolveChatPreviewGlyph,
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 { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
@@ -110,6 +116,8 @@ type PreviewFetchError = Error & {
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
@@ -199,6 +207,25 @@ function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
}
}
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
const fileName = buildPreviewFileName(item).toLowerCase();
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
return match?.[1] ?? '';
}
function buildResourceChipMeta(item: Pick<PreviewOption, 'url' | 'label' | 'kind'>) {
const extension = resolvePreviewFileExtension(item);
if (extension) {
return extension.toUpperCase();
}
return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)
.replace(/\s+preview$/i, '')
.replace(/\s+download$/i, '')
.toUpperCase();
}
function normalizeRankedLinkTitle(value: string) {
return value
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
@@ -561,6 +588,22 @@ function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
function getMissingRequestMessageText(message: ChatMessage) {
if (!isMissingRequestMessage(message)) {
return '';
}
return message.text.slice(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`.length).trim();
}
function getExecutionFailureMessageText(message: ChatMessage) {
if (!isExecutionFailureMessage(message)) {
return '';
}
return message.text.slice(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`.length).trim();
}
function extractActivityLines(message: ChatMessage) {
return message.text
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
@@ -570,8 +613,20 @@ function extractActivityLines(message: ChatMessage) {
}
function summarizeActivityLines(lines: string[]) {
const latestLine = lines.at(-1) ?? '';
return latestLine;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const summary = lines[index]
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('# 이유:') || line.startsWith('# 진행:') || line.startsWith('# 상태:'));
if (!summary) {
continue;
}
return summary.replace(/^#\s*(||):\s*/u, '').trim();
}
return lines.at(-1) ?? '';
}
function isLikelyCollapsibleMessage(text: string) {
@@ -948,8 +1003,8 @@ type ChatConversationViewProps = {
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
onRemoveComposerAttachment: (attachmentId: string) => void;
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onSend: (draftText?: string) => void;
onSendImmediate: (draftText?: string) => void;
onToggleSendWithoutContext: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
@@ -1018,12 +1073,58 @@ export function ChatConversationView({
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
const [composerDraft, setComposerDraft] = useState(draft);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const lastReportedDraftRef = useRef(draft);
useEffect(() => {
if (draft === lastReportedDraftRef.current) {
return;
}
setComposerDraft(draft);
}, [draft]);
useEffect(() => {
if (composerDraft === lastReportedDraftRef.current) {
return;
}
const timeoutId = window.setTimeout(() => {
lastReportedDraftRef.current = composerDraft;
startTransition(() => {
onDraftChange(composerDraft);
});
}, 120);
return () => {
window.clearTimeout(timeoutId);
};
}, [composerDraft, onDraftChange]);
const orderedMessages = useMemo(() => {
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
const requestId = activityMessage.clientRequestId?.trim();
if (!requestId) {
return true;
}
const requestState = requestStateMap.get(requestId);
const hasCodexResponse = visibleMessages.some(
(candidate) =>
candidate.clientRequestId?.trim() === requestId &&
candidate.author === 'codex' &&
candidate.text.trim().length > 0 &&
!isPreparingChatReplyText(candidate.text),
);
return !isTerminalRequestStatus(requestState?.status) || !hasCodexResponse;
};
const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
const baseMessages = visibleMessages.filter((message) => {
@@ -1038,7 +1139,9 @@ export function ChatConversationView({
return false;
}
latestActivityByRequestId.set(activityKey, message);
if (shouldDisplayActivityMessage(message)) {
latestActivityByRequestId.set(activityKey, message);
}
return false;
});
const insertedActivityRequestIds = new Set<string>();
@@ -1074,19 +1177,9 @@ export function ChatConversationView({
});
return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]);
}, [requestStateMap, visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const isChatTypeReadonly = useMemo(() => {
if (isChatTypeSelectionLocked) {
return true;
}
if (typeof window === 'undefined') {
return false;
}
return Boolean(new URLSearchParams(window.location.search).get('sessionId')?.trim());
}, [isChatTypeSelectionLocked]);
const isChatTypeReadonly = isChatTypeSelectionLocked;
const visiblePreviewItems = useMemo(() => {
if (!showLatestResourceOnly) {
return previewItems;
@@ -1590,8 +1683,20 @@ export function ChatConversationView({
onOpenPreview(item.id);
}}
>
<span title={item.label}>{item.label}</span>
<span>{item.kind}</span>
<span className="app-chat-panel__resource-chip-main">
<span className="app-chat-panel__resource-chip-icon" aria-hidden="true">
{resolveChatPreviewGlyph(item.kind as ChatPreviewKind)}
</span>
<span className="app-chat-panel__resource-chip-label" title={item.label}>
{item.label}
</span>
</span>
<span
className="app-chat-panel__resource-chip-meta"
aria-label={`${resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)} 형식`}
>
{buildResourceChipMeta(item)}
</span>
</button>
))}
</div>
@@ -1638,8 +1743,17 @@ export function ChatConversationView({
const canCollapseMessage = collapsibleMessageIds.includes(message.id);
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const isRecoveredMissingRequest = isMissingRequestMessage(message);
const isRecoveredExecutionFailure = isExecutionFailureMessage(message);
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
}`;
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets } = extractMessageRenderPayload(message);
const renderedText = isRecoveredMissingRequest
? getMissingRequestMessageText(message)
: isRecoveredExecutionFailure
? getExecutionFailureMessageText(message)
: visibleText;
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
@@ -1651,9 +1765,9 @@ export function ChatConversationView({
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
.filter(Boolean)
.join(' ');
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
@@ -1663,10 +1777,26 @@ export function ChatConversationView({
return (
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article className={`app-chat-message app-chat-message--${message.author}`}>
<article
className={`app-chat-message ${
isRecoveredMissingRequest || isRecoveredExecutionFailure
? 'app-chat-message--system-inline'
: `app-chat-message--${message.author}`
}`}
>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{message.author === 'codex' ? 'Codex' : message.author === 'user' ? 'You' : 'System'}</strong>
<strong>
{isRecoveredMissingRequest
? '원문 누락'
: isRecoveredExecutionFailure
? '실행 실패'
: message.author === 'codex'
? 'Codex'
: message.author === 'user'
? 'You'
: 'System'}
</strong>
<span>{formatChatTimestamp(message.timestamp)}</span>
{message.author === 'user' && requestStatusLabel ? (
<span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
@@ -1743,13 +1873,13 @@ export function ChatConversationView({
/>
) : null}
</div>
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{visibleText ? renderMessageBody(visibleText) : null}
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={baseMessageBodyClassName}
>
{renderedText ? renderMessageBody(renderedText) : null}
</div>
{message.author === 'user' && requestDetailText ? (
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
@@ -1917,22 +2047,38 @@ export function ChatConversationView({
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
}`}
icon={<DisconnectOutlined />}
aria-label={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
title={isSendWithoutContextEnabled ? '이전 문맥 없이 보내기 켜짐' : '이전 문맥 없이 보내기 꺼짐'}
aria-label={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
title={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
onClick={onToggleSendWithoutContext}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"
onClick={onSendImmediate}
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSendImmediate(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
type="primary"
icon={<SendOutlined />}
aria-label="큐로 보내기"
onClick={onSend}
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSend(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
</div>
@@ -1987,12 +2133,12 @@ export function ChatConversationView({
<Input.TextArea
ref={composerRef}
value={draft}
value={composerDraft}
autoSize={false}
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
onDraftChange(event.target.value);
setComposerDraft(event.target.value);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
@@ -2012,16 +2158,18 @@ export function ChatConversationView({
event.preventDefault();
event.stopPropagation();
onSend();
lastReportedDraftRef.current = event.currentTarget.value;
onDraftChange(event.currentTarget.value);
onSend(event.currentTarget.value);
}}
/>
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${draft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
className={`app-chat-panel__composer-clear${composerDraft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!draft.trim()}
disabled={!composerDraft.trim()}
>
clear
</Button>

View File

@@ -0,0 +1,191 @@
import { Typography } from 'antd';
import type { ChatPreviewTarget } from './ChatPreviewBody';
const { Text } = Typography;
type TableCellValue = string;
type TabularPreviewModel = {
columns: string[];
rows: TableCellValue[][];
rowCount: number;
sourceLabel: string;
};
type ChatDataTablePreviewProps = {
model: TabularPreviewModel;
};
function stringifyCellValue(value: unknown): TableCellValue {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function normalizeObjectRows(rows: Record<string, unknown>[], sourceLabel: string): TabularPreviewModel | null {
if (!rows.length) {
return null;
}
const columns = Array.from(
rows.reduce((set, row) => {
Object.keys(row).forEach((key) => set.add(key));
return set;
}, new Set<string>()),
);
if (!columns.length) {
return null;
}
return {
columns,
rows: rows.map((row) => columns.map((column) => stringifyCellValue(row[column]))),
rowCount: rows.length,
sourceLabel,
};
}
function resolveJsonRows(value: unknown): Record<string, unknown>[] | null {
if (Array.isArray(value) && value.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
return value as Record<string, unknown>[];
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const entries = Object.values(value as Record<string, unknown>);
for (const entry of entries) {
const resolved = resolveJsonRows(entry);
if (resolved?.length) {
return resolved;
}
}
}
return null;
}
function parseCsvLine(line: string) {
const cells: string[] = [];
let current = '';
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index] ?? '';
const nextChar = line[index + 1] ?? '';
if (char === '"') {
if (quoted && nextChar === '"') {
current += '"';
index += 1;
continue;
}
quoted = !quoted;
continue;
}
if (char === ',' && !quoted) {
cells.push(current.trim());
current = '';
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
function parseCsvTable(previewText: string, sourceLabel: string): TabularPreviewModel | null {
const lines = previewText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length < 2) {
return null;
}
const header = parseCsvLine(lines[0] ?? '');
if (!header.length || header.every((column) => !column)) {
return null;
}
const rows = lines.slice(1).map((line) => {
const parsed = parseCsvLine(line);
return header.map((_, index) => parsed[index] ?? '');
});
return {
columns: header,
rows,
rowCount: rows.length,
sourceLabel,
};
}
export function resolveTabularPreviewModel(target: ChatPreviewTarget, previewText: string): TabularPreviewModel | null {
const pathname = target.url.toLowerCase().split('?')[0] ?? '';
if (pathname.endsWith('.json')) {
try {
const parsed = JSON.parse(previewText) as unknown;
const rows = resolveJsonRows(parsed);
return rows ? normalizeObjectRows(rows, target.label) : null;
} catch {
return null;
}
}
if (pathname.endsWith('.csv')) {
return parseCsvTable(previewText, target.label);
}
return null;
}
export function ChatDataTablePreview({ model }: ChatDataTablePreviewProps) {
return (
<div className="app-chat-panel__preview-table">
<div className="app-chat-panel__preview-table-meta">
<Text strong>{model.sourceLabel}</Text>
<Text type="secondary">{`${model.rowCount}개 · 열 ${model.columns.length}`}</Text>
</div>
<div className="app-chat-panel__preview-table-scroll">
<table className="app-chat-panel__preview-table-grid">
<thead>
<tr>
{model.columns.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{model.rows.map((row, rowIndex) => (
<tr key={`${model.sourceLabel}-${rowIndex}`}>
{row.map((cell, cellIndex) => (
<td key={`${model.columns[cellIndex] ?? cellIndex}-${rowIndex}`}>{cell || '-'}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { InlineImage } from '../../../components/common/InlineImage';
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
import { CodexDiffBlock } from '../../../components/previewer';
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
import { ChatDataTablePreview, resolveTabularPreviewModel } from './ChatDataTablePreview';
import { triggerResourceDownload } from './downloadUtils';
import '../../../components/previewer/PreviewerUI.css';
@@ -359,6 +360,12 @@ export function ChatPreviewBody({
}
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
const tabularModel = resolveTabularPreviewModel(target, previewText);
if (tabularModel) {
return <ChatDataTablePreview model={tabularModel} />;
}
const resolvedLanguage = resolveCodeLanguage(target, previewText);
if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) {

View File

@@ -0,0 +1,32 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeChatResourceUrl = normalizeChatResourceUrl;
var CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
var CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
function extractEmbeddedResourcePath(value) {
var normalized = String(value !== null && value !== void 0 ? value : '').trim();
if (!normalized) {
return '';
}
var apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
return normalized.slice(apiMarkerIndex);
}
var publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
if (publicMarkerIndex >= 0) {
return normalized.slice(publicMarkerIndex);
}
return normalized;
}
function normalizeChatResourceUrl(value) {
var normalized = extractEmbeddedResourcePath(value);
if (typeof window === 'undefined') {
return normalized;
}
try {
return new URL(normalized, window.location.href).toString();
}
catch (_a) {
return normalized;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,8 @@ const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const KST_TIME_ZONE = 'Asia/Seoul';
const chatSessionLastTypeMemory = new Map<string, string>();
@@ -46,18 +48,23 @@ function toConversationSortTime(value: string | null | undefined) {
return Number.isNaN(parsed) ? 0 : parsed;
}
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
if (lastMessageTime > 0) {
return lastMessageTime;
}
return Math.max(
toConversationSortTime(item.createdAt),
toConversationSortTime(item.updatedAt),
);
}
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
return [...items].sort((left, right) => {
const leftTime = Math.max(
toConversationSortTime(left.lastMessageAt),
toConversationSortTime(left.updatedAt),
toConversationSortTime(left.createdAt),
);
const rightTime = Math.max(
toConversationSortTime(right.lastMessageAt),
toConversationSortTime(right.updatedAt),
toConversationSortTime(right.createdAt),
);
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime - leftTime;
@@ -289,18 +296,29 @@ function createLocalMessageId() {
return Date.now() * 1_000 + localMessageSequence;
}
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
function createRecoveredMessageId(
requestId: string,
variant: 'user' | 'codex' | 'activity' | 'missing-request' | 'execution-failure',
) {
const baseId = hashRequestId(requestId) * 10;
if (variant === 'user') {
return -(baseId + 3);
}
if (variant === 'activity') {
if (variant === 'missing-request') {
return -(baseId + 2);
}
return -(baseId + 1);
if (variant === 'activity') {
return -(baseId + 1);
}
if (variant === 'execution-failure') {
return -(baseId + 4);
}
return -(baseId + 5);
}
function hashRequestId(value: string) {
@@ -355,6 +373,170 @@ function isActivityLogMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
}
export function isMissingRequestMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`);
}
export function isExecutionFailureMessage(message: ChatMessage) {
return message.author === 'system' && message.text.startsWith(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`);
}
function isEmptyCodexExecutionResponse(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized === 'Codex 실행 결과가 비어 있습니다.';
}
function extractActivityLogFailureReason(lines?: string[] | null) {
const normalizedLines = (lines ?? []).map((line) => line.trim()).filter(Boolean);
for (let index = normalizedLines.length - 1; index >= 0; index -= 1) {
const line = normalizedLines[index];
if (!line.startsWith('# 오류:')) {
continue;
}
const raw = line.slice('# 오류:'.length).trim();
if (!raw) {
continue;
}
try {
const parsed = JSON.parse(raw) as { message?: unknown };
const message = typeof parsed.message === 'string' ? parsed.message.trim() : '';
if (message) {
return message;
}
} catch {
return raw;
}
}
return '';
}
function buildExecutionFailureMessage(reason: string) {
const normalizedReason = reason.trim();
if (!normalizedReason) {
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n실행 중 오류가 발생했습니다.`;
}
const simplifiedReason = normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')
? `세션 리소스 업로드 폴더를 만들 권한이 없어 응답 생성이 중단되었습니다.\n\n원인: ${normalizedReason}`
: `실행 중 오류가 발생했습니다.\n\n원인: ${normalizedReason}`;
return `${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n${simplifiedReason}`;
}
function buildFailurePreviewText(reason: string) {
const normalizedReason = reason.trim();
if (!normalizedReason) {
return '실행 실패';
}
if (normalizedReason.includes("mkdir '") && normalizedReason.includes('/resource/uploads')) {
return '실행 실패: 세션 리소스 업로드 폴더 권한 오류';
}
return `실행 실패: ${normalizedReason}`;
}
function enrichFailedRequestsWithActivityLogs(
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
) {
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
return requests.map((request) => {
if (request.status !== 'failed') {
return request;
}
const activityLog = activityLogMap.get(request.requestId.trim());
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
const normalizedStatusMessage = String(request.statusMessage ?? '').trim();
if (!failureReason) {
return request;
}
if (normalizedStatusMessage && normalizedStatusMessage !== '요청 처리 실패') {
return request;
}
return {
...request,
statusMessage: failureReason,
};
});
}
function replaceGenericFailureMessages(
messages: ChatMessage[],
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
): ChatMessage[] {
const requestMap = new Map(requests.map((item) => [item.requestId.trim(), item]));
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
return messages.map((message) => {
const requestId = message.clientRequestId?.trim() ?? '';
if (!requestId || message.author !== 'codex' || !isEmptyCodexExecutionResponse(message.text)) {
return message;
}
const request = requestMap.get(requestId);
if (request?.status !== 'failed') {
return message;
}
const failureReason = extractActivityLogFailureReason(activityLogMap.get(requestId)?.lines);
if (!failureReason) {
return message;
}
return {
...message,
author: 'system' as const,
text: buildExecutionFailureMessage(failureReason),
};
});
}
function resolveConversationFailurePreview(
currentPreview: string,
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
) {
if (!isEmptyCodexExecutionResponse(currentPreview)) {
return currentPreview;
}
const latestFailedRequest = [...requests]
.reverse()
.find((request) => request.status === 'failed' && isEmptyCodexExecutionResponse(String(request.responseText ?? '').trim()));
if (!latestFailedRequest) {
return currentPreview;
}
const activityLog = activityLogs.find((item) => item.requestId.trim() === latestFailedRequest.requestId.trim());
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
if (!failureReason) {
return currentPreview;
}
return buildFailurePreviewText(failureReason);
}
function extractActivityLogLines(text: string) {
return text
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
@@ -934,7 +1116,9 @@ export async function fetchChatConversations() {
}
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>(
'/conversations?limit=200',
)
.then((response) => {
return sortChatConversationSummaries(
response.items.map((item) => ({
@@ -971,17 +1155,24 @@ export async function fetchChatConversationDetail(
const response = await requestChatApi<ChatConversationDetailResponse>(
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
);
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
const normalizedRequests = enrichFailedRequestsWithActivityLogs(
response.requests.map((item) => normalizeChatConversationRequest(item)),
response.activityLogs,
);
const visibleRequestIds = new Set(
response.messages
.map((message) => message.clientRequestId?.trim() ?? '')
.filter(Boolean),
);
const hydratedMessages = hydrateActivityLogMessages(
response.messages,
replaceGenericFailureMessages(response.messages, normalizedRequests, response.activityLogs),
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
).filter(
(message) => message.author !== 'system' || isActivityLogMessage(message),
(message) =>
message.author !== 'system' ||
isActivityLogMessage(message) ||
isMissingRequestMessage(message) ||
isExecutionFailureMessage(message),
);
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
@@ -990,6 +1181,11 @@ export async function fetchChatConversationDetail(
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
item: {
...response.item,
lastMessagePreview: resolveConversationFailurePreview(
response.item.lastMessagePreview,
normalizedRequests,
response.activityLogs,
),
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
response.item.sessionId,
response.item.notifyOffline,
@@ -1123,6 +1319,7 @@ export async function createChatConversationRoom(args: {
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -1136,6 +1333,7 @@ export async function createChatConversationRoom(args: {
title: args.title ?? '새 대화',
chatTypeId: args.chatTypeId ?? null,
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
generalSectionName: args.generalSectionName ?? null,
contextLabel: args.contextLabel ?? null,
contextDescription: args.contextDescription ?? null,
notifyOffline,
@@ -1173,6 +1371,7 @@ export async function updateChatConversationRoom(
title?: string;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string | null;
contextDescription?: string | null;
notifyOffline?: boolean;
@@ -1307,6 +1506,14 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
if (isMissingRequestMessage(left) && isMissingRequestMessage(right)) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
if (isExecutionFailureMessage(left) && isExecutionFailureMessage(right)) {
return Boolean(left.clientRequestId && right.clientRequestId && left.clientRequestId === right.clientRequestId);
}
return Boolean(
(left.author === 'user' || left.author === 'codex') &&
left.author === right.author &&
@@ -1321,6 +1528,14 @@ function buildComparableChatMessageKey(message: ChatMessage) {
return `activity:${message.clientRequestId}`;
}
if (isMissingRequestMessage(message) && message.clientRequestId) {
return `missing-request:${message.clientRequestId}`;
}
if (isExecutionFailureMessage(message) && message.clientRequestId) {
return `execution-failure:${message.clientRequestId}`;
}
if (message.author === 'user' && message.clientRequestId) {
return `user-request:${message.clientRequestId}`;
}
@@ -1337,6 +1552,123 @@ function getComparableChatMessageTime(message: ChatMessage) {
return Number.isFinite(parsed) ? parsed : 0;
}
function getChatMessageRequestId(message: ChatMessage) {
return message.clientRequestId?.trim() || '';
}
function getChatMessageOrderRank(message: ChatMessage) {
if (message.author === 'user') {
return 0;
}
if (isMissingRequestMessage(message)) {
return 1;
}
if (isExecutionFailureMessage(message)) {
return 2;
}
if (isActivityLogMessage(message)) {
return 3;
}
if (message.author === 'codex') {
return 3;
}
return 4;
}
function sortConversationMessages(messages: ChatMessage[]) {
if (messages.length <= 1) {
return messages;
}
const messageIndexMap = new Map(messages.map((message, index) => [message, index]));
const requestOrder = new Map<
string,
{
time: number;
firstIndex: number;
}
>();
messages.forEach((message, index) => {
const requestId = getChatMessageRequestId(message);
if (!requestId) {
return;
}
const time = getComparableChatMessageTime(message);
const existing = requestOrder.get(requestId);
if (!existing) {
requestOrder.set(requestId, {
time,
firstIndex: index,
});
return;
}
requestOrder.set(requestId, {
time:
existing.time > 0 && time > 0
? Math.min(existing.time, time)
: existing.time > 0
? existing.time
: time,
firstIndex: Math.min(existing.firstIndex, index),
});
});
return [...messages].sort((left, right) => {
const leftRequestId = getChatMessageRequestId(left);
const rightRequestId = getChatMessageRequestId(right);
if (leftRequestId && rightRequestId && leftRequestId === rightRequestId) {
const rankDiff = getChatMessageOrderRank(left) - getChatMessageOrderRank(right);
if (rankDiff !== 0) {
return rankDiff;
}
}
const leftOrder = leftRequestId ? requestOrder.get(leftRequestId) : null;
const rightOrder = rightRequestId ? requestOrder.get(rightRequestId) : null;
const leftTime = leftOrder?.time ?? getComparableChatMessageTime(left);
const rightTime = rightOrder?.time ?? getComparableChatMessageTime(right);
if (leftTime !== rightTime) {
return leftTime - rightTime;
}
const leftIndex = leftOrder?.firstIndex ?? messageIndexMap.get(left) ?? 0;
const rightIndex = rightOrder?.firstIndex ?? messageIndexMap.get(right) ?? 0;
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
if (leftRequestId && rightRequestId && leftRequestId !== rightRequestId) {
const requestDiff = leftRequestId.localeCompare(rightRequestId, 'ko-KR');
if (requestDiff !== 0) {
return requestDiff;
}
}
const messageTimeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (messageTimeDiff !== 0) {
return messageTimeDiff;
}
return left.id - right.id;
});
}
function buildRecoveredMessagesFromConversationDetail(
requests: ChatConversationRequest[],
activityLogs: ChatConversationActivityLog[],
@@ -1354,6 +1686,9 @@ function buildRecoveredMessagesFromConversationDetail(
const userText = String(request.userText ?? '').trim();
const responseText = String(request.responseText ?? '').trim();
const activityLog = activityLogMap.get(requestId);
const failureReason = extractActivityLogFailureReason(activityLog?.lines);
const shouldReplaceEmptyFailureResponse =
request.status === 'failed' && isEmptyCodexExecutionResponse(responseText) && Boolean(failureReason);
if (userText) {
nextMessages.push({
@@ -1363,9 +1698,17 @@ function buildRecoveredMessagesFromConversationDetail(
timestamp: request.createdAt || request.updatedAt || '',
clientRequestId: requestId,
});
} else if (responseText || activityLog?.lines.length) {
nextMessages.push({
id: createRecoveredMessageId(requestId, 'missing-request'),
author: 'system',
text: `${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n이 요청은 저장된 원문이 없어 실제 요청 문장을 표시할 수 없습니다.`,
timestamp: request.createdAt || request.updatedAt || '',
clientRequestId: requestId,
});
}
if (responseText) {
if (responseText && !shouldReplaceEmptyFailureResponse) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
author: 'codex',
@@ -1375,6 +1718,16 @@ function buildRecoveredMessagesFromConversationDetail(
});
}
if (shouldReplaceEmptyFailureResponse) {
nextMessages.push({
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'execution-failure'),
author: 'system',
text: buildExecutionFailureMessage(failureReason),
timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
clientRequestId: requestId,
});
}
if (activityLog && activityLog.lines.length > 0) {
nextMessages.push({
id: createRecoveredMessageId(requestId, 'activity'),
@@ -1386,15 +1739,7 @@ function buildRecoveredMessagesFromConversationDetail(
}
});
return nextMessages.sort((left, right) => {
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
if (timeDiff !== 0) {
return timeDiff;
}
return left.id - right.id;
});
return sortConversationMessages(nextMessages);
}
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
@@ -1459,7 +1804,7 @@ export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: Ch
});
const unmatchedLocalMessages = Array.from(previousBuckets.values()).flat();
const nextMessages = [...mergedServerMessages, ...unmatchedLocalMessages];
const nextMessages = sortConversationMessages([...mergedServerMessages, ...unmatchedLocalMessages]);
return areChatMessagesEquivalent(previous, nextMessages) ? previous : nextMessages;
}

View File

@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useErrorLogs = exports.useChatConnection = exports.subscribeChatConnection = exports.setSharedChatRuntimeSnapshot = exports.getSharedChatRuntimeSnapshot = exports.updateChatConversationRoom = exports.upsertChatMessage = exports.uploadChatComposerFile = exports.sortChatConversationSummaries = exports.setChatClientSessionId = exports.setStoredChatSessionLastTypeId = exports.resetLastReceivedChatEventId = exports.removeChatRuntimeJob = exports.renameChatConversationRoom = exports.mergeRecoveredChatMessages = exports.markChatConversationResponsesRead = exports.getChatClientSessionId = exports.isPreparingChatReplyText = exports.isMissingRequestMessage = exports.getStoredChatSessionLastTypeId = exports.fetchChatRuntimeSnapshot = exports.fetchChatRuntimeJobDetail = exports.fetchChatConversations = exports.fetchChatConversationDetail = exports.deleteChatConversationRoom = exports.deleteChatConversationRequest = exports.cancelChatRuntimeJob = exports.createLocalMessage = exports.createIntroMessage = exports.createChatMessage = exports.createChatConversationRoom = exports.createActivityLogPlaceholder = exports.resolvePreviewBodyForCopy = exports.copyText = exports.copyPreviewContent = exports.clearStoredChatClientConversationState = exports.buildOfflineReply = exports.ErrorLogViewer = exports.ChatRuntimeDashboard = exports.ChatConversationView = void 0;
var ChatConversationView_1 = require("./ChatConversationView");
Object.defineProperty(exports, "ChatConversationView", { enumerable: true, get: function () { return ChatConversationView_1.ChatConversationView; } });
var ChatRuntimeDashboard_1 = require("./ChatRuntimeDashboard");
Object.defineProperty(exports, "ChatRuntimeDashboard", { enumerable: true, get: function () { return ChatRuntimeDashboard_1.ChatRuntimeDashboard; } });
var ErrorLogViewer_1 = require("./ErrorLogViewer");
Object.defineProperty(exports, "ErrorLogViewer", { enumerable: true, get: function () { return ErrorLogViewer_1.ErrorLogViewer; } });
var chatUtils_1 = require("./chatUtils");
Object.defineProperty(exports, "buildOfflineReply", { enumerable: true, get: function () { return chatUtils_1.buildOfflineReply; } });
Object.defineProperty(exports, "clearStoredChatClientConversationState", { enumerable: true, get: function () { return chatUtils_1.clearStoredChatClientConversationState; } });
Object.defineProperty(exports, "copyPreviewContent", { enumerable: true, get: function () { return chatUtils_1.copyPreviewContent; } });
Object.defineProperty(exports, "copyText", { enumerable: true, get: function () { return chatUtils_1.copyText; } });
Object.defineProperty(exports, "resolvePreviewBodyForCopy", { enumerable: true, get: function () { return chatUtils_1.resolvePreviewBodyForCopy; } });
Object.defineProperty(exports, "createActivityLogPlaceholder", { enumerable: true, get: function () { return chatUtils_1.createActivityLogPlaceholder; } });
Object.defineProperty(exports, "createChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.createChatConversationRoom; } });
Object.defineProperty(exports, "createChatMessage", { enumerable: true, get: function () { return chatUtils_1.createChatMessage; } });
Object.defineProperty(exports, "createIntroMessage", { enumerable: true, get: function () { return chatUtils_1.createIntroMessage; } });
Object.defineProperty(exports, "createLocalMessage", { enumerable: true, get: function () { return chatUtils_1.createLocalMessage; } });
Object.defineProperty(exports, "cancelChatRuntimeJob", { enumerable: true, get: function () { return chatUtils_1.cancelChatRuntimeJob; } });
Object.defineProperty(exports, "deleteChatConversationRequest", { enumerable: true, get: function () { return chatUtils_1.deleteChatConversationRequest; } });
Object.defineProperty(exports, "deleteChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.deleteChatConversationRoom; } });
Object.defineProperty(exports, "fetchChatConversationDetail", { enumerable: true, get: function () { return chatUtils_1.fetchChatConversationDetail; } });
Object.defineProperty(exports, "fetchChatConversations", { enumerable: true, get: function () { return chatUtils_1.fetchChatConversations; } });
Object.defineProperty(exports, "fetchChatRuntimeJobDetail", { enumerable: true, get: function () { return chatUtils_1.fetchChatRuntimeJobDetail; } });
Object.defineProperty(exports, "fetchChatRuntimeSnapshot", { enumerable: true, get: function () { return chatUtils_1.fetchChatRuntimeSnapshot; } });
Object.defineProperty(exports, "getStoredChatSessionLastTypeId", { enumerable: true, get: function () { return chatUtils_1.getStoredChatSessionLastTypeId; } });
Object.defineProperty(exports, "isMissingRequestMessage", { enumerable: true, get: function () { return chatUtils_1.isMissingRequestMessage; } });
Object.defineProperty(exports, "isPreparingChatReplyText", { enumerable: true, get: function () { return chatUtils_1.isPreparingChatReplyText; } });
Object.defineProperty(exports, "getChatClientSessionId", { enumerable: true, get: function () { return chatUtils_1.getChatClientSessionId; } });
Object.defineProperty(exports, "markChatConversationResponsesRead", { enumerable: true, get: function () { return chatUtils_1.markChatConversationResponsesRead; } });
Object.defineProperty(exports, "mergeRecoveredChatMessages", { enumerable: true, get: function () { return chatUtils_1.mergeRecoveredChatMessages; } });
Object.defineProperty(exports, "renameChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.renameChatConversationRoom; } });
Object.defineProperty(exports, "removeChatRuntimeJob", { enumerable: true, get: function () { return chatUtils_1.removeChatRuntimeJob; } });
Object.defineProperty(exports, "resetLastReceivedChatEventId", { enumerable: true, get: function () { return chatUtils_1.resetLastReceivedChatEventId; } });
Object.defineProperty(exports, "setStoredChatSessionLastTypeId", { enumerable: true, get: function () { return chatUtils_1.setStoredChatSessionLastTypeId; } });
Object.defineProperty(exports, "setChatClientSessionId", { enumerable: true, get: function () { return chatUtils_1.setChatClientSessionId; } });
Object.defineProperty(exports, "sortChatConversationSummaries", { enumerable: true, get: function () { return chatUtils_1.sortChatConversationSummaries; } });
Object.defineProperty(exports, "uploadChatComposerFile", { enumerable: true, get: function () { return chatUtils_1.uploadChatComposerFile; } });
Object.defineProperty(exports, "upsertChatMessage", { enumerable: true, get: function () { return chatUtils_1.upsertChatMessage; } });
Object.defineProperty(exports, "updateChatConversationRoom", { enumerable: true, get: function () { return chatUtils_1.updateChatConversationRoom; } });
var useChatConnection_1 = require("./useChatConnection");
Object.defineProperty(exports, "getSharedChatRuntimeSnapshot", { enumerable: true, get: function () { return useChatConnection_1.getSharedChatRuntimeSnapshot; } });
Object.defineProperty(exports, "setSharedChatRuntimeSnapshot", { enumerable: true, get: function () { return useChatConnection_1.setSharedChatRuntimeSnapshot; } });
Object.defineProperty(exports, "subscribeChatConnection", { enumerable: true, get: function () { return useChatConnection_1.subscribeChatConnection; } });
Object.defineProperty(exports, "useChatConnection", { enumerable: true, get: function () { return useChatConnection_1.useChatConnection; } });
var useErrorLogs_1 = require("./useErrorLogs");
Object.defineProperty(exports, "useErrorLogs", { enumerable: true, get: function () { return useErrorLogs_1.useErrorLogs; } });

View File

@@ -20,6 +20,7 @@ export {
fetchChatRuntimeJobDetail,
fetchChatRuntimeSnapshot,
getStoredChatSessionLastTypeId,
isMissingRequestMessage,
isPreparingChatReplyText,
getChatClientSessionId,
markChatConversationResponsesRead,

View File

@@ -1,11 +1,32 @@
const AUTO_DETECTED_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s<>)\]]+|\/[A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]+)/g;
const AUTO_DETECTED_PREVIEW_URL_PATTERN =
/(https?:\/\/[^\s<>)\]]+|\/(?:[A-Za-z0-9._~%-][A-Za-z0-9._~:/?#[\]@!$&'*+,;=%-]*))/g;
const LOCAL_RESOURCE_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const PREVIEWABLE_FILE_EXTENSION_PATTERN =
/\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i;
function stripCodeFenceBlocks(text: string) {
return String(text ?? '').replace(/```[\s\S]*?```/g, '');
}
function trimAutoDetectedUrl(value: string) {
return String(value ?? '').trim().replace(/[`\])}>.,;!?]+$/g, '');
}
function isLikelyLocalPreviewUrl(value: string) {
if (LOCAL_RESOURCE_PREFIXES.some((prefix) => value.startsWith(prefix))) {
return true;
}
const pathname = value.split(/[?#]/, 1)[0] ?? '';
return PREVIEWABLE_FILE_EXTENSION_PATTERN.test(pathname);
}
export function extractAutoDetectedPreviewUrls(text: string) {
const normalized = String(text ?? '');
const normalized = stripCodeFenceBlocks(text);
const urls: string[] = [];
for (const match of normalized.matchAll(AUTO_DETECTED_PREVIEW_URL_PATTERN)) {
const value = match[0]?.trim();
const value = trimAutoDetectedUrl(match[0] ?? '');
if (!value) {
continue;
@@ -19,6 +40,10 @@ export function extractAutoDetectedPreviewUrls(text: string) {
continue;
}
if (value.startsWith('/') && !isLikelyLocalPreviewUrl(value)) {
continue;
}
urls.push(value);
}

View File

@@ -16,6 +16,11 @@ function normalizeUrl(value: string) {
return '';
}
const malformedResourceMatch = normalized.match(/^https?:\/(api\/chat\/resources\/.+)$/i);
if (malformedResourceMatch?.[1]) {
return `/${malformedResourceMatch[1]}`;
}
if (/^(?:https?:\/\/|\/)/i.test(normalized)) {
return normalized;
}
@@ -23,11 +28,43 @@ function normalizeUrl(value: string) {
return '';
}
function decodeUrlComponentSafely(value: string) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function resolveLinkCardUrlAndActionLabel(rawUrl: string, rawActionLabel?: string) {
let resolvedUrl = normalizeText(rawUrl);
let resolvedActionLabel = normalizeText(rawActionLabel);
if (!resolvedActionLabel) {
const decodedUrl = decodeUrlComponentSafely(resolvedUrl);
const dividerIndex = decodedUrl.lastIndexOf('|');
if (dividerIndex > 0 && dividerIndex < decodedUrl.length - 1) {
resolvedUrl = decodedUrl.slice(0, dividerIndex).trim();
resolvedActionLabel = decodedUrl.slice(dividerIndex + 1).trim();
}
}
return {
url: normalizeUrl(resolvedUrl),
actionLabel: resolvedActionLabel || null,
};
}
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isInternalResourceUrl(url: string) {
return RESOURCE_PATH_PREFIXES.some((prefix) => url.startsWith(prefix));
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
@@ -35,15 +72,11 @@ function isStructuredLinkCardCandidate(url: string) {
return false;
}
if (RESOURCE_PATH_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
if (isInternalResourceUrl(normalized)) {
return false;
}
if (/^https?:\/\//i.test(normalized)) {
return !hasKnownFileExtension(normalized);
}
return !hasKnownFileExtension(normalized);
return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
@@ -88,8 +121,7 @@ function buildLinkCardPart(rawBody: string): ChatMessagePart | null {
const [rawTitle, rawUrl, rawActionLabel] = segments;
const title = normalizeText(rawTitle);
const url = normalizeUrl(rawUrl);
const actionLabel = normalizeText(rawActionLabel) || null;
const { url, actionLabel } = resolveLinkCardUrlAndActionLabel(rawUrl, rawActionLabel);
if (!title || !url) {
return null;
@@ -154,6 +186,14 @@ export function extractChatMessageParts(text: string) {
if (!pushPart(buildLinkCardPart(matched[1] ?? ''))) {
keptLines.push(line);
continue;
}
const latestPart = parts.at(-1);
if (latestPart && isInternalResourceUrl(latestPart.url)) {
parts.pop();
seenLinkKeys.delete(`${latestPart.type}:${latestPart.title}:${latestPart.url}:${latestPart.actionLabel ?? ''}`);
keptLines.push(latestPart.url);
}
}

View File

@@ -14,10 +14,117 @@ export type PreviewItem = {
source: 'message' | 'context';
};
const CHAT_RESOURCE_INTERNAL_SEGMENTS = new Set(['resource', 'uploads', 'source', 'src']);
const CHAT_RESOURCE_HIDDEN_FILE_NAMES = new Set(['.env']);
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const RESOURCE_STRIP_ALLOWED_KINDS = new Set<PreviewKind>([
'image',
'video',
'markdown',
'code',
'diff',
'document',
'pdf',
'file',
]);
function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function parsePreviewUrl(url: string) {
try {
return new URL(url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
} catch {
return null;
}
}
function extractInternalChatResourcePath(pathname: string) {
const normalizedPathname = String(pathname ?? '').trim();
if (!normalizedPathname) {
return '';
}
if (normalizedPathname.includes('/.codex_chat/')) {
const markerIndex = normalizedPathname.lastIndexOf('/.codex_chat/');
return normalizedPathname.slice(markerIndex + 1);
}
const apiMarkerIndex = normalizedPathname.lastIndexOf(CHAT_API_RESOURCE_MARKER);
if (apiMarkerIndex >= 0) {
return normalizedPathname.slice(apiMarkerIndex + CHAT_API_RESOURCE_MARKER.length).replace(/^\/+/, '');
}
return '';
}
function hasVisibleFileExtension(fileName: string) {
return /\.[a-z0-9]{1,16}$/i.test(fileName);
}
function hasSupportedPreviewFileExtension(fileName: string) {
return /\.(png|jpe?g|gif|webp|svg|bmp|ico|mp4|webm|mov|m4v|ogg|md|markdown|diff|patch|ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml|txt|log|csv|pdf)$/i.test(
fileName,
);
}
function isMarkdownResourceFile(fileName: string) {
return /\.(md|markdown)$/i.test(fileName);
}
function shouldHideInternalChatResource(url: string) {
const parsed = parsePreviewUrl(url);
const pathname = parsed?.pathname ?? '';
const internalResourcePath = extractInternalChatResourcePath(pathname);
const normalizedInternalResourcePath = internalResourcePath.toLowerCase();
if (!normalizedInternalResourcePath.startsWith('.codex_chat/')) {
return false;
}
const segments = internalResourcePath.split('/').filter(Boolean);
const lastSegment = segments.at(-1)?.trim() ?? '';
const normalizedLastSegment = lastSegment.toLowerCase();
const resourceSegmentIndex = segments.findIndex((segment) => segment === 'resource');
const nextSegment = resourceSegmentIndex >= 0 ? segments[resourceSegmentIndex + 1]?.toLowerCase() ?? '' : '';
if (!lastSegment || pathname.endsWith('/')) {
return true;
}
if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(normalizedLastSegment)) {
return true;
}
if (!hasVisibleFileExtension(lastSegment)) {
return true;
}
if (CHAT_RESOURCE_INTERNAL_SEGMENTS.has(nextSegment) && !hasVisibleFileExtension(lastSegment)) {
return true;
}
if (normalizedLastSegment.startsWith('.')) {
return true;
}
if (CHAT_RESOURCE_HIDDEN_FILE_NAMES.has(normalizedLastSegment)) {
return true;
}
if (resourceSegmentIndex >= 0 && nextSegment === 'src' && !isMarkdownResourceFile(lastSegment)) {
return true;
}
if (!hasSupportedPreviewFileExtension(lastSegment)) {
return true;
}
return false;
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
@@ -118,15 +225,23 @@ export function extractPreviewItems(messages: ChatMessage[]) {
)
.map((part) => part.url);
const matches = [
...extractAutoDetectedPreviewUrls(message.text),
...extractHiddenPreviewUrls(message.text),
...structuredLinkUrls,
...extractAutoDetectedPreviewUrls(message.text),
];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
const kind = classifyPreviewKind(normalizedUrl);
if (shouldHideInternalChatResource(normalizedUrl)) {
return;
}
if (!RESOURCE_STRIP_ALLOWED_KINDS.has(kind)) {
return;
}
if (seen.has(normalizedUrl)) {
return;
}

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -44,9 +44,11 @@ export type ChatViewContext = {
export type ChatConversationSummary = {
sessionId: string;
clientId: string | null;
isDraftOnly?: boolean;
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
generalSectionName: string | null;
contextLabel: string | null;
contextDescription: string | null;
notifyOffline: boolean;
@@ -56,7 +58,9 @@ export type ChatConversationSummary = {
currentJobMessage: string | null;
currentQueueSize: number;
currentStatusUpdatedAt: string | null;
lastRequestPreview: string;
lastMessagePreview: string;
lastResponsePreview: string;
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;

View File

@@ -0,0 +1,495 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getChatConnectionSnapshot = getChatConnectionSnapshot;
exports.subscribeChatConnection = subscribeChatConnection;
exports.getSharedChatRuntimeSnapshot = getSharedChatRuntimeSnapshot;
exports.setSharedChatRuntimeSnapshot = setSharedChatRuntimeSnapshot;
exports.useChatConnection = useChatConnection;
var react_1 = require("react");
var chatUtils_1 = require("./chatUtils");
var tokenAccess_1 = require("../tokenAccess");
var DISCONNECT_UI_DELAY_MS = 1500;
var PRESENCE_PING_INTERVAL_MS = 20000;
var sharedChatConnection = {
connectionState: 'connecting',
connectionErrorDetail: '',
runtimeSnapshot: null,
socketRef: { current: null },
reconnectTimerId: null,
disconnectUiTimerId: null,
connectTimeoutId: null,
sessionId: '',
currentContext: null,
setMessages: null,
onMessageEvent: undefined,
onJobEvent: undefined,
onRuntimeEvent: undefined,
onRuntimeDetailEvent: undefined,
onActivityEvent: undefined,
lastEventId: 0,
websocketUrl: '',
subscribers: new Set(),
pingSubscriberCount: 0,
consumerCount: 0,
pingIntervalId: null,
visibilityHandlerInstalled: false,
pageShowHandlerInstalled: false,
focusHandlerInstalled: false,
onlineHandlerInstalled: false,
hasConnectedOnce: false,
suppressDisconnectNotification: false,
lastBackgroundAt: null,
};
function emitSharedState() {
sharedChatConnection.subscribers.forEach(function (listener) {
listener();
});
}
function getSnapshot() {
return {
connectionState: sharedChatConnection.connectionState,
connectionErrorDetail: sharedChatConnection.connectionErrorDetail,
runtimeSnapshot: sharedChatConnection.runtimeSnapshot,
};
}
function getChatConnectionSnapshot() {
return getSnapshot();
}
function subscribeChatConnection(listener) {
sharedChatConnection.subscribers.add(listener);
return function () {
sharedChatConnection.subscribers.delete(listener);
};
}
function getSharedChatRuntimeSnapshot() {
return sharedChatConnection.runtimeSnapshot;
}
function setSharedChatRuntimeSnapshot(snapshot) {
if (sharedChatConnection.runtimeSnapshot === snapshot) {
return;
}
sharedChatConnection.runtimeSnapshot = snapshot;
emitSharedState();
}
function setSharedConnectionState(nextState) {
if (sharedChatConnection.connectionState === nextState) {
return;
}
sharedChatConnection.connectionState = nextState;
emitSharedState();
}
function setSharedConnectionError(detail) {
if (sharedChatConnection.connectionErrorDetail === detail) {
return;
}
sharedChatConnection.connectionErrorDetail = detail;
emitSharedState();
}
function clearReconnectTimer() {
if (sharedChatConnection.reconnectTimerId !== null) {
window.clearTimeout(sharedChatConnection.reconnectTimerId);
sharedChatConnection.reconnectTimerId = null;
}
}
function clearDisconnectUiTimer() {
if (sharedChatConnection.disconnectUiTimerId !== null) {
window.clearTimeout(sharedChatConnection.disconnectUiTimerId);
sharedChatConnection.disconnectUiTimerId = null;
}
}
function clearConnectTimeout() {
if (sharedChatConnection.connectTimeoutId !== null) {
window.clearTimeout(sharedChatConnection.connectTimeoutId);
sharedChatConnection.connectTimeoutId = null;
}
}
function sendContextUpdate(context) {
if (context === void 0) { context = sharedChatConnection.currentContext; }
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
return;
}
var liveVisibilityState = typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
var livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
socket.send(JSON.stringify({
type: 'context:update',
payload: {
pageId: context.pageId,
pageTitle: context.pageTitle,
topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl,
isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState,
chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription,
},
}));
}
function sendPresencePing() {
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'presence:ping',
payload: {
at: Date.now(),
},
}));
}
function ensureSharedSocket() {
var socket = sharedChatConnection.socketRef.current;
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
connectSharedSocket();
}
function sendEventReceived(eventId) {
var socket = sharedChatConnection.socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) {
return;
}
socket.send(JSON.stringify({
type: 'event:received',
payload: {
eventId: eventId,
},
}));
}
function stopPresenceMonitoring() {
if (sharedChatConnection.pingIntervalId !== null) {
window.clearInterval(sharedChatConnection.pingIntervalId);
sharedChatConnection.pingIntervalId = null;
}
if (sharedChatConnection.visibilityHandlerInstalled) {
window.removeEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = false;
}
if (sharedChatConnection.pageShowHandlerInstalled) {
window.removeEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = false;
}
if (sharedChatConnection.focusHandlerInstalled) {
window.removeEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = false;
}
if (sharedChatConnection.onlineHandlerInstalled) {
window.removeEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = false;
}
}
function handleVisibilityChange() {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
sharedChatConnection.lastBackgroundAt = Date.now();
sendContextUpdate(sharedChatConnection.currentContext);
sendPresencePing();
return;
}
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handlePageShow() {
ensureSharedSocket();
sendPresencePing();
sendContextUpdate(sharedChatConnection.currentContext);
}
function handleWindowFocus() {
ensureSharedSocket();
sendPresencePing();
}
function handleWindowOnline() {
ensureSharedSocket();
}
function startPresenceMonitoring() {
if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') {
stopPresenceMonitoring();
return;
}
sharedChatConnection.lastBackgroundAt = null;
sendPresencePing();
if (sharedChatConnection.pingIntervalId === null) {
sharedChatConnection.pingIntervalId = window.setInterval(function () {
sendPresencePing();
}, PRESENCE_PING_INTERVAL_MS);
}
if (!sharedChatConnection.visibilityHandlerInstalled) {
window.addEventListener('visibilitychange', handleVisibilityChange);
sharedChatConnection.visibilityHandlerInstalled = true;
}
if (!sharedChatConnection.pageShowHandlerInstalled) {
window.addEventListener('pageshow', handlePageShow);
sharedChatConnection.pageShowHandlerInstalled = true;
}
if (!sharedChatConnection.focusHandlerInstalled) {
window.addEventListener('focus', handleWindowFocus);
sharedChatConnection.focusHandlerInstalled = true;
}
if (!sharedChatConnection.onlineHandlerInstalled) {
window.addEventListener('online', handleWindowOnline);
sharedChatConnection.onlineHandlerInstalled = true;
}
}
function scheduleReconnect() {
if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) {
return;
}
sharedChatConnection.reconnectTimerId = window.setTimeout(function () {
sharedChatConnection.reconnectTimerId = null;
connectSharedSocket();
}, chatUtils_1.CHAT_CONNECTION.reconnectDelayMs);
}
function handleSharedDisconnect(message, detail) {
setSharedConnectionError(detail !== null && detail !== void 0 ? detail : '');
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('disconnected');
}
else {
sharedChatConnection.disconnectUiTimerId = window.setTimeout(function () {
var _a;
sharedChatConnection.disconnectUiTimerId = null;
if (((_a = sharedChatConnection.socketRef.current) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
return;
}
setSharedConnectionState('disconnected');
}, DISCONNECT_UI_DELAY_MS);
}
if (message) {
scheduleReconnect();
}
}
function disconnectSharedSocket() {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
var socket = sharedChatConnection.socketRef.current;
sharedChatConnection.suppressDisconnectNotification = true;
sharedChatConnection.socketRef.current = null;
socket === null || socket === void 0 ? void 0 : socket.close();
}
function releaseSharedConnectionConsumer() {
sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1);
if (sharedChatConnection.consumerCount > 0) {
return;
}
sharedChatConnection.currentContext = null;
sharedChatConnection.setMessages = null;
sharedChatConnection.onMessageEvent = undefined;
sharedChatConnection.onJobEvent = undefined;
sharedChatConnection.onRuntimeEvent = undefined;
sharedChatConnection.onRuntimeDetailEvent = undefined;
setSharedChatRuntimeSnapshot(null);
disconnectSharedSocket();
setSharedConnectionError('');
setSharedConnectionState('disconnected');
}
function connectSharedSocket() {
if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) {
return;
}
if (!(0, tokenAccess_1.hasRegisteredAccessTokenAccess)()) {
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
stopPresenceMonitoring();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
setSharedConnectionState('disconnected');
return;
}
var currentSocket = sharedChatConnection.socketRef.current;
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
return;
}
clearReconnectTimer();
clearConnectTimeout();
clearDisconnectUiTimer();
if (sharedChatConnection.connectionState !== 'connected') {
setSharedConnectionState('connecting');
}
sharedChatConnection.websocketUrl = (0, chatUtils_1.resolveChatWebSocketUrl)(sharedChatConnection.sessionId, sharedChatConnection.lastEventId);
var socket;
try {
socket = new WebSocket(sharedChatConnection.websocketUrl);
}
catch (_a) {
handleSharedDisconnect("\uC6CC\uD06C\uC11C\uBC84 WebSocket \uC8FC\uC18C\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uB300\uC0C1: ".concat(sharedChatConnection.websocketUrl || '/ws/chat', " \uC790\uB3D9\uC73C\uB85C \uB2E4\uC2DC \uC5F0\uACB0\uD569\uB2C8\uB2E4."), 'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.');
return;
}
sharedChatConnection.socketRef.current = socket;
sharedChatConnection.suppressDisconnectNotification = false;
var disconnectHandled = false;
var reportDisconnect = function (message, closeEvent) {
if (disconnectHandled) {
return;
}
disconnectHandled = true;
var wasSuppressed = sharedChatConnection.suppressDisconnectNotification;
if (sharedChatConnection.socketRef.current === socket) {
sharedChatConnection.socketRef.current = null;
}
sharedChatConnection.suppressDisconnectNotification = false;
if (wasSuppressed) {
setSharedConnectionError('');
return;
}
if ((closeEvent === null || closeEvent === void 0 ? void 0 : closeEvent.code) === 1008) {
clearReconnectTimer();
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
setSharedConnectionState('disconnected');
return;
}
if ((closeEvent === null || closeEvent === void 0 ? void 0 : closeEvent.code) === 1000 && !message) {
setSharedConnectionError('');
handleSharedDisconnect();
scheduleReconnect();
return;
}
void (0, chatUtils_1.diagnoseConnectionFailure)(sharedChatConnection.websocketUrl, closeEvent).then(function (detail) {
handleSharedDisconnect(message, detail);
});
};
sharedChatConnection.connectTimeoutId = window.setTimeout(function () {
if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) {
return;
}
sharedChatConnection.socketRef.current = null;
socket.close();
reportDisconnect("\uC6CC\uD06C\uC11C\uBC84 \uC5F0\uACB0 \uC2DC\uAC04\uC774 \uCD08\uACFC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uB300\uC0C1: ".concat(sharedChatConnection.websocketUrl || '/ws/chat', " \uC790\uB3D9\uC73C\uB85C \uB2E4\uC2DC \uC5F0\uACB0\uD569\uB2C8\uB2E4."));
}, chatUtils_1.CHAT_CONNECTION.connectTimeoutMs);
socket.addEventListener('open', function () {
clearConnectTimeout();
clearDisconnectUiTimer();
sharedChatConnection.hasConnectedOnce = true;
sharedChatConnection.suppressDisconnectNotification = false;
setSharedConnectionState('connected');
setSharedConnectionError('');
sendContextUpdate(sharedChatConnection.currentContext);
startPresenceMonitoring();
});
socket.addEventListener('message', function (event) {
var _a, _b;
var setMessages = sharedChatConnection.setMessages;
if (!setMessages) {
return;
}
void (0, chatUtils_1.handleChatServerEvent)({
eventData: String(event.data),
currentPageUrl: (_b = (_a = sharedChatConnection.currentContext) === null || _a === void 0 ? void 0 : _a.pageUrl) !== null && _b !== void 0 ? _b : '',
expectedSessionId: sharedChatConnection.sessionId,
setMessages: setMessages,
onMessageEvent: sharedChatConnection.onMessageEvent,
onJobEvent: sharedChatConnection.onJobEvent,
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
onActivityEvent: sharedChatConnection.onActivityEvent,
onEventReceived: function (eventId) {
sharedChatConnection.lastEventId = eventId;
(0, chatUtils_1.persistLastReceivedChatEventId)(sharedChatConnection.sessionId, eventId);
sendEventReceived(eventId);
},
});
try {
var parsedEvent = JSON.parse(String(event.data));
if ((parsedEvent === null || parsedEvent === void 0 ? void 0 : parsedEvent.type) === 'chat:runtime') {
setSharedChatRuntimeSnapshot(parsedEvent.payload);
}
}
catch (_c) {
// ignore malformed payloads here; detailed parsing is already handled downstream
}
});
socket.addEventListener('close', function (event) {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect(event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.', event);
});
socket.addEventListener('error', function () {
clearConnectTimeout();
stopPresenceMonitoring();
reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.');
});
}
function ensureSharedConnection(options) {
var sessionChanged = sharedChatConnection.sessionId !== options.sessionId;
sharedChatConnection.currentContext = options.currentContext;
sharedChatConnection.setMessages = options.setMessages;
sharedChatConnection.onMessageEvent = options.onMessageEvent;
sharedChatConnection.onJobEvent = options.onJobEvent;
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = options.onActivityEvent;
if (sessionChanged) {
sharedChatConnection.sessionId = options.sessionId;
sharedChatConnection.lastEventId = (0, chatUtils_1.getLastReceivedChatEventId)(options.sessionId);
sharedChatConnection.hasConnectedOnce = false;
disconnectSharedSocket();
}
connectSharedSocket();
}
function useChatConnection(_a) {
var sessionId = _a.sessionId, currentContext = _a.currentContext, setMessages = _a.setMessages, onMessageEvent = _a.onMessageEvent, onJobEvent = _a.onJobEvent, onRuntimeEvent = _a.onRuntimeEvent, onRuntimeDetailEvent = _a.onRuntimeDetailEvent, onActivityEvent = _a.onActivityEvent;
var _b = (0, react_1.useState)(function () { return getSnapshot(); }), snapshot = _b[0], setSnapshot = _b[1];
(0, react_1.useEffect)(function () {
sharedChatConnection.consumerCount += 1;
return function () {
releaseSharedConnectionConsumer();
};
}, []);
(0, react_1.useEffect)(function () {
var handleSnapshotChange = function () {
setSnapshot(getSnapshot());
};
var unsubscribe = subscribeChatConnection(handleSnapshotChange);
ensureSharedConnection({
sessionId: sessionId,
currentContext: currentContext,
setMessages: setMessages,
onMessageEvent: onMessageEvent,
onJobEvent: onJobEvent,
onRuntimeEvent: onRuntimeEvent,
onRuntimeDetailEvent: onRuntimeDetailEvent,
onActivityEvent: onActivityEvent,
});
handleSnapshotChange();
return function () {
unsubscribe();
};
}, [sessionId, setMessages]);
(0, react_1.useEffect)(function () {
sharedChatConnection.currentContext = currentContext;
sharedChatConnection.setMessages = setMessages;
sharedChatConnection.onMessageEvent = onMessageEvent;
sharedChatConnection.onJobEvent = onJobEvent;
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = onActivityEvent;
sendContextUpdate(currentContext);
}, [
currentContext,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
setMessages,
]);
(0, react_1.useEffect)(function () {
sharedChatConnection.pingSubscriberCount += 1;
startPresenceMonitoring();
return function () {
sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1);
if (sharedChatConnection.pingSubscriberCount === 0) {
stopPresenceMonitoring();
}
};
}, []);
return {
connectionState: snapshot.connectionState,
connectionErrorDetail: snapshot.connectionErrorDetail,
socketRef: sharedChatConnection.socketRef,
};
}

View File

@@ -483,6 +483,8 @@ function connectSharedSocket() {
if (closeEvent?.code === 1000 && !message) {
setSharedConnectionError('');
handleSharedDisconnect();
scheduleReconnect();
return;
}

View File

@@ -0,0 +1,116 @@
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.useErrorLogs = useErrorLogs;
var react_1 = require("react");
var errorLogApi_1 = require("../errorLogApi");
var errorLogUtils_1 = require("./errorLogUtils");
function useErrorLogs(_a) {
var _this = this;
var activeView = _a.activeView, hasAccess = _a.hasAccess;
var _b = (0, react_1.useState)([]), errorLogs = _b[0], setErrorLogs = _b[1];
var _c = (0, react_1.useState)(null), selectedErrorLogId = _c[0], setSelectedErrorLogId = _c[1];
var _d = (0, react_1.useState)(false), isLoadingErrorLogs = _d[0], setIsLoadingErrorLogs = _d[1];
var _e = (0, react_1.useState)(''), errorLogLoadError = _e[0], setErrorLogLoadError = _e[1];
var _f = (0, react_1.useState)(''), activeErrorResourceUrl = _f[0], setActiveErrorResourceUrl = _f[1];
var _g = (0, react_1.useState)(false), isErrorDetailExpanded = _g[0], setIsErrorDetailExpanded = _g[1];
var loadErrorLogs = function () { return __awaiter(_this, void 0, void 0, function () {
var items_1, error_1;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!hasAccess || isLoadingErrorLogs) {
return [2 /*return*/];
}
setIsLoadingErrorLogs(true);
setErrorLogLoadError('');
_a.label = 1;
case 1:
_a.trys.push([1, 3, 4, 5]);
return [4 /*yield*/, (0, errorLogApi_1.fetchErrorLogs)(50)];
case 2:
items_1 = _a.sent();
setErrorLogs(items_1);
setSelectedErrorLogId(function (current) { var _a, _b; return (_b = current !== null && current !== void 0 ? current : (_a = items_1[0]) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : null; });
return [3 /*break*/, 5];
case 3:
error_1 = _a.sent();
setErrorLogLoadError(error_1 instanceof Error ? error_1.message : '에러 로그를 불러오지 못했습니다.');
return [3 /*break*/, 5];
case 4:
setIsLoadingErrorLogs(false);
return [7 /*endfinally*/];
case 5: return [2 /*return*/];
}
});
}); };
(0, react_1.useEffect)(function () {
if (activeView !== 'errors' || !hasAccess) {
return;
}
void loadErrorLogs();
}, [activeView, hasAccess]);
var selectedErrorLog = (0, react_1.useMemo)(function () { var _a, _b; return (_b = (_a = errorLogs.find(function (item) { return item.id === selectedErrorLogId; })) !== null && _a !== void 0 ? _a : errorLogs[0]) !== null && _b !== void 0 ? _b : null; }, [errorLogs, selectedErrorLogId]);
var selectedErrorLogReferenceSummary = (0, react_1.useMemo)(function () { return (selectedErrorLog ? (0, errorLogUtils_1.buildErrorReferenceSummary)(selectedErrorLog) : null); }, [selectedErrorLog]);
var activeErrorResource = (0, react_1.useMemo)(function () {
var _a, _b;
return (_a = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources.find(function (resource) { return resource.url === activeErrorResourceUrl; })) !== null && _a !== void 0 ? _a : (0, errorLogUtils_1.getDefaultErrorResource)((_b = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources) !== null && _b !== void 0 ? _b : []);
}, [activeErrorResourceUrl, selectedErrorLogReferenceSummary]);
var errorSourceSummary = (0, react_1.useMemo)(function () { return (0, errorLogUtils_1.buildErrorSourceSummary)(errorLogs); }, [errorLogs]);
(0, react_1.useEffect)(function () {
var _a, _b, _c;
var nextUrl = (_c = (_b = (0, errorLogUtils_1.getDefaultErrorResource)((_a = selectedErrorLogReferenceSummary === null || selectedErrorLogReferenceSummary === void 0 ? void 0 : selectedErrorLogReferenceSummary.resources) !== null && _a !== void 0 ? _a : [])) === null || _b === void 0 ? void 0 : _b.url) !== null && _c !== void 0 ? _c : '';
setActiveErrorResourceUrl(nextUrl);
}, [selectedErrorLog === null || selectedErrorLog === void 0 ? void 0 : selectedErrorLog.id, selectedErrorLogReferenceSummary]);
return {
errorLogs: errorLogs,
selectedErrorLog: selectedErrorLog,
selectedErrorLogId: selectedErrorLogId,
selectedErrorLogReferenceSummary: selectedErrorLogReferenceSummary,
activeErrorResource: activeErrorResource,
errorSourceSummary: errorSourceSummary,
isLoadingErrorLogs: isLoadingErrorLogs,
errorLogLoadError: errorLogLoadError,
activeErrorResourceUrl: activeErrorResourceUrl,
isErrorDetailExpanded: isErrorDetailExpanded,
setSelectedErrorLogId: setSelectedErrorLogId,
setActiveErrorResourceUrl: setActiveErrorResourceUrl,
setIsErrorDetailExpanded: setIsErrorDetailExpanded,
loadErrorLogs: loadErrorLogs,
};
}

View File

@@ -13,7 +13,7 @@ export type MainViewInitialNavigation = {
export function resolveInitialNavigation(): MainViewInitialNavigation {
if (typeof window === 'undefined') {
return {
activeTopMenu: 'plans',
activeTopMenu: 'chat',
selectedPlanMenu: 'all',
selectedPlayMenu: 'layout',
initialSelectedPlanId: null,
@@ -26,7 +26,7 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
const requestedTopMenu: TopMenuKey =
topMenuParam === 'docs' || topMenuParam === 'apis' || topMenuParam === 'plans' || topMenuParam === 'chat' || topMenuParam === 'play'
? topMenuParam
: 'plans';
: 'chat';
const planFilterParam = params.get('planFilter');
const selectedPlanMenu =
planFilterParam === 'release'
@@ -45,6 +45,10 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
selectedPlayMenu:
playSectionParam === 'layout'
? 'layout'
: playSectionParam === 'test'
? 'test'
: playSectionParam === 'cbt'
? 'cbt'
: playSectionParam === 'layout-record' && playLayoutIdParam
? resolveSavedLayoutMenuKey(playLayoutIdParam)
: 'layout',

View File

@@ -153,6 +153,18 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:resources',
label: 'Codex Live / 리소스 관리',
group: 'Page',
keywords: ['codex live', 'resource', 'resources', 'file', 'files', '리소스', '파일', '파일 시스템'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('resources');
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:errors',
label: '앱로그 / 에러 로그',
@@ -178,6 +190,18 @@ export function buildMainViewSearchOptions({
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:manage-defaults',
label: '채팅 관리 / 기본 유형 관리',
group: 'Page',
keywords: ['chat manage', 'default type', 'default context', '기본 유형', '기본 context', '채팅 관리'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage-defaults');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []),

View File

@@ -0,0 +1,83 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY = exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY = void 0;
exports.buildScopedPwaNotificationTargetId = buildScopedPwaNotificationTargetId;
exports.getSavedNotificationDeviceId = getSavedNotificationDeviceId;
exports.getSavedPwaNotificationToken = getSavedPwaNotificationToken;
exports.setSavedPwaNotificationToken = setSavedPwaNotificationToken;
exports.clearNotificationIdentity = clearNotificationIdentity;
exports.getAutomationNotificationPreferenceTarget = getAutomationNotificationPreferenceTarget;
var clientIdentity_1 = require("./clientIdentity");
exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY = 'work-server.notification.pwa-token';
function buildScopedPwaNotificationTargetId(token, clientId) {
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
}
function getSavedNotificationDeviceId() {
if (typeof window === 'undefined') {
return '';
}
var clientId = (0, clientIdentity_1.getOrCreateClientId)();
if (clientId) {
window.localStorage.setItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY, clientId);
return clientId;
}
var saved = window.localStorage.getItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY);
if (saved) {
return saved;
}
var generated = "web-".concat(Date.now());
window.localStorage.setItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY, generated);
return generated;
}
function getSavedPwaNotificationToken() {
var _a, _b;
if (typeof window === 'undefined') {
return '';
}
return (_b = (_a = window.localStorage.getItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY)) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
}
function setSavedPwaNotificationToken(token) {
var _a;
if (typeof window === 'undefined') {
return;
}
var normalizedToken = (_a = token === null || token === void 0 ? void 0 : token.trim()) !== null && _a !== void 0 ? _a : '';
if (normalizedToken) {
window.localStorage.setItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY, normalizedToken);
}
else {
window.localStorage.removeItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
}
}
function clearNotificationIdentity() {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(exports.NOTIFICATION_DEVICE_ID_STORAGE_KEY);
window.localStorage.removeItem(exports.PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
(0, clientIdentity_1.clearClientId)();
}
function getAutomationNotificationPreferenceTarget() {
var pwaToken = getSavedPwaNotificationToken();
var clientId = getSavedNotificationDeviceId();
if (pwaToken && clientId) {
return {
targetKind: 'ios-token-client',
targetId: buildScopedPwaNotificationTargetId(pwaToken, clientId),
};
}
if (pwaToken) {
return {
targetKind: 'ios-token',
targetId: pwaToken,
};
}
if (!clientId) {
return null;
}
return {
targetKind: 'client',
targetId: clientId,
};
}

View File

@@ -1,3 +1,5 @@
import { ChatDefaultContextManagementPage } from '../ChatDefaultContextManagementPage';
import { ResourceManagementPage } from '../ResourceManagementPage';
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
import { MainChatPanel } from '../MainChatPanel';
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
@@ -10,6 +12,10 @@ export function ChatPage() {
<div className="app-main-panel">
{selectedChatMenu === 'manage' ? (
<ChatTypeManagementPage />
) : selectedChatMenu === 'manage-defaults' ? (
<ChatDefaultContextManagementPage />
) : selectedChatMenu === 'resources' ? (
<ResourceManagementPage />
) : selectedChatMenu === 'changes' ? (
<ChatSourceChangesPage />
) : (

View File

@@ -9,20 +9,22 @@ export function DocsPage() {
const { selectedDocsMenu, selectedDocs } = useMainLayoutContext();
return (
<div className="app-main-panel">
<div className="app-main-panel docs-page">
<Card
title={`Docs / ${getDocsSectionLabel(selectedDocsMenu)}`}
extra={<Text code>{selectedDocs.length} docs</Text>}
className="app-main-card"
className="app-main-card docs-page__card"
bordered={false}
>
<Space direction="vertical" size={16} className="app-main-stack">
{selectedDocs.map((document) => (
<div key={document.id} id={`document-preview-${document.id}`} data-focus-id={`doc:${document.id}`}>
<MarkdownPreviewCard document={document} />
</div>
))}
</Space>
<div className="docs-page__scroll">
<Space direction="vertical" size={16} className="app-main-stack docs-page__stack">
{selectedDocs.map((document) => (
<div key={document.id} id={`document-preview-${document.id}`} data-focus-id={`doc:${document.id}`}>
<MarkdownPreviewCard document={document} />
</div>
))}
</Space>
</div>
</Card>
</div>
);

View File

@@ -1,4 +1,7 @@
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
import { CbtPlayAppView } from '../../../views/play/apps/cbt/CbtPlayAppView';
import { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView';
import { FeatureMenuLayoutPage } from '../../../features/layout/feature-menu';
import { MemoLayoutPage } from '../../../features/layout/memo';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
@@ -10,13 +13,23 @@ export function PlayPage() {
? savedLayouts.find((layout) => layout.id === selectedSavedLayoutId) ?? null
: null;
const isMemoLayout = selectedSavedLayout?.name === '메모';
const isFeatureMenuLayout = 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}
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
{selectedPlayMenu === 'cbt' ? <CbtPlayAppView /> : null}
{selectedSavedLayoutId && isMemoLayout ? <MemoLayoutPage layoutId={selectedSavedLayoutId} /> : null}
{selectedSavedLayoutId && !isMemoLayout ? (
{selectedSavedLayoutId && isFeatureMenuLayout ? (
<FeatureMenuLayoutPage
layoutId={selectedSavedLayoutId}
savedLayouts={savedLayouts}
onSavedLayoutsChange={setSavedLayouts}
/>
) : null}
{selectedSavedLayoutId && !isMemoLayout && !isFeatureMenuLayout ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
) : null}
</div>

View File

@@ -0,0 +1,277 @@
import { appendClientIdHeader } from './clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
export type ResourceManagerEntryType = 'file' | 'directory';
export type ResourceManagerTreeNode = {
name: string;
path: string;
type: ResourceManagerEntryType;
extension: string | null;
size: number | null;
modifiedAt: string;
previewUrl: string | null;
children?: ResourceManagerTreeNode[];
};
export type ResourceManagerTreeRoot = {
label: string;
rootPath: string;
tree: ResourceManagerTreeNode;
};
export type ResourceManagerDirectoryEntry = {
name: string;
path: string;
type: ResourceManagerEntryType;
extension: string | null;
size: number | null;
modifiedAt: string;
previewUrl: string | null;
};
export type ResourceManagerDirectoryDetail = {
path: string;
items: ResourceManagerDirectoryEntry[];
};
export type ResourceManagerFileDetail = {
name: string;
path: string;
extension: string | null;
size: number;
modifiedAt: string;
mimeType: string;
previewUrl: string;
isTextEditable: boolean;
content: string | null;
};
class ResourceManagerApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ResourceManagerApiError';
this.status = status;
}
}
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;
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalHost) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const RESOURCE_MANAGER_API_BASE_URL = resolveApiBaseUrl();
const RESOURCE_MANAGER_API_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && RESOURCE_MANAGER_API_BASE_URL === '/api'
? resolveFallbackBaseUrl()
: null;
function appendPreviewAccessToken(previewUrl: string | null) {
if (!previewUrl) {
return null;
}
const token = getRegisteredAccessToken();
if (!token) {
return previewUrl;
}
const separator = previewUrl.includes('?') ? '&' : '?';
return `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
}
function normalizeTreeNode(node: ResourceManagerTreeNode): ResourceManagerTreeNode {
return {
...node,
previewUrl: appendPreviewAccessToken(node.previewUrl),
children: node.children?.map((child) => normalizeTreeNode(child)),
};
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
const token = getRegisteredAccessToken();
if (!isAllowedRegistrationToken(token)) {
throw new ResourceManagerApiError('권한 토큰 등록 후에만 리소스 관리를 사용할 수 있습니다.', 403);
}
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new ResourceManagerApiError(payload.message || '리소스 관리 요청에 실패했습니다.', response.status);
} catch {
throw new ResourceManagerApiError(text || '리소스 관리 요청에 실패했습니다.', response.status);
}
}
return response.json() as Promise<T>;
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
try {
return await requestOnce<T>(RESOURCE_MANAGER_API_BASE_URL, path, init);
} catch (error) {
const shouldRetryWithFallback =
RESOURCE_MANAGER_API_FALLBACK_BASE_URL &&
RESOURCE_MANAGER_API_FALLBACK_BASE_URL !== RESOURCE_MANAGER_API_BASE_URL &&
(error instanceof ResourceManagerApiError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(RESOURCE_MANAGER_API_FALLBACK_BASE_URL, path, init);
}
}
export async function fetchResourceManagerTree() {
const response = await request<{ ok: boolean; item: ResourceManagerTreeRoot }>('/resource-manager/tree');
return {
...response.item,
tree: normalizeTreeNode(response.item.tree),
};
}
export async function fetchResourceManagerDirectory(targetPath = '') {
const query = new URLSearchParams({ path: targetPath });
const response = await request<{ ok: boolean; item: ResourceManagerDirectoryDetail }>(
`/resource-manager/directory?${query.toString()}`,
);
return {
...response.item,
items: response.item.items.map((item) => ({
...item,
previewUrl: appendPreviewAccessToken(item.previewUrl),
})),
};
}
export async function fetchResourceManagerFile(targetPath: string) {
const query = new URLSearchParams({ path: targetPath });
const response = await request<{ ok: boolean; item: ResourceManagerFileDetail }>(`/resource-manager/file?${query.toString()}`);
return {
...response.item,
previewUrl: appendPreviewAccessToken(response.item.previewUrl) ?? response.item.previewUrl,
};
}
export async function createResourceManagerDirectory(parentPath: string, name: string) {
await request('/resource-manager/directories', {
method: 'POST',
body: JSON.stringify({ parentPath, name }),
});
}
export async function createResourceManagerFile(parentPath: string, name: string, content = '') {
await request('/resource-manager/files', {
method: 'POST',
body: JSON.stringify({ parentPath, name, content }),
});
}
export async function saveResourceManagerFile(targetPath: string, content: string) {
await request('/resource-manager/files/content', {
method: 'PUT',
body: JSON.stringify({ path: targetPath, content }),
});
}
async function readFileAsBase64(file: File) {
const buffer = await file.arrayBuffer();
let binary = '';
const bytes = new Uint8Array(buffer);
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary);
}
export async function uploadResourceManagerFile(parentPath: string, file: File) {
const contentBase64 = await readFileAsBase64(file);
await request('/resource-manager/files/upload', {
method: 'POST',
body: JSON.stringify({
parentPath,
fileName: file.name,
contentBase64,
}),
});
}
export async function copyResourceManagerItem(targetPath: string, targetDirectoryPath: string, nextName?: string) {
await request('/resource-manager/items/copy', {
method: 'POST',
body: JSON.stringify({
path: targetPath,
targetDirectoryPath,
nextName: nextName?.trim() || null,
}),
});
}
export async function moveResourceManagerItem(targetPath: string, targetDirectoryPath: string, nextName?: string) {
await request('/resource-manager/items/move', {
method: 'POST',
body: JSON.stringify({
path: targetPath,
targetDirectoryPath,
nextName: nextName?.trim() || null,
}),
});
}
export async function deleteResourceManagerItem(targetPath: string) {
const query = new URLSearchParams({ path: targetPath });
await request(`/resource-manager/items?${query.toString()}`, {
method: 'DELETE',
});
}

View File

@@ -18,8 +18,8 @@ export type PlanSectionKey =
| 'automation-type'
| 'automation-context'
| 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
export type PlaySectionKey = 'layout';
export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults';
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
export const DOCS_DEFAULT_FOLDER = 'worklogs';
@@ -56,6 +56,8 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = {
layout: 'Layout Editor',
test: 'Test App',
cbt: 'CBT',
};
export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
@@ -89,7 +91,7 @@ export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
if (selectedPlayMenu === 'layout') {
if (selectedPlayMenu === 'layout' || selectedPlayMenu === 'test' || selectedPlayMenu === 'cbt') {
return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
}
@@ -247,6 +249,7 @@ export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps
children: [
{ key: 'live', label: renderChatUnreadLabel('Codex Live', unreadCount) },
{ key: 'changes', label: '변경 이력' },
{ key: 'resources', label: '리소스 관리' },
],
},
{
@@ -261,7 +264,10 @@ export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps
key: 'chat-manage-group',
icon: <MessageOutlined />,
label: '채팅 관리',
children: [{ key: 'manage', label: '유형 권한 관리' }],
children: [
{ key: 'manage', label: '유형 권한 관리' },
{ key: 'manage-defaults', label: '기본 유형 관리' },
],
},
]
: []),
@@ -288,6 +294,18 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
: [{ key: 'saved-layout-empty', label: '저장된 레이아웃 없음', disabled: true }]),
],
},
{
key: 'play-apps-group',
label: 'Apps',
children: [
{ key: 'test', label: 'Test App' },
{
key: 'play-apps-general-group',
label: '일반',
children: [{ key: 'cbt', label: 'CBT' }],
},
],
},
],
},
];
@@ -298,7 +316,7 @@ export function resolvePlanOpenKeys() {
}
export function resolvePlayOpenKeys() {
return ['play-group', 'play-layout-group'];
return ['play-group', 'play-layout-group', 'play-apps-group', 'play-apps-general-group'];
}
export function resolvePlanQuickFilterMenu(filter: 'working' | 'release-pending-main' | 'automation-failed') {
@@ -364,16 +382,22 @@ export function resolveCurrentPageDescriptor(params: {
}
if (topMenu === 'chat') {
const title =
chatMenu === 'errors'
? '앱로그 / 에러 로그'
: chatMenu === 'changes'
? 'Codex Live / 변경 이력'
: chatMenu === 'resources'
? 'Codex Live / 리소스 관리'
: chatMenu === 'manage'
? '채팅 관리 / 유형 권한 관리'
: chatMenu === 'manage-defaults'
? '채팅 관리 / 기본 유형 관리'
: 'Codex Live / Codex Live';
return {
id: `app-log:${chatMenu}`,
title:
chatMenu === 'errors'
? '앱로그 / 에러 로그'
: chatMenu === 'changes'
? 'Codex Live / 변경 이력'
: chatMenu === 'manage'
? '채팅 관리 / 유형 권한 관리'
: 'Codex Live / Codex Live',
title,
topMenu,
section: chatMenu,
};
@@ -396,7 +420,7 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
return buildPlayPath('layout');
}
return buildPlansPath('all');
return buildChatPath('live');
}
export function createPageWindowId(topMenu: TopMenuKey, section: string) {

View File

@@ -0,0 +1,63 @@
"use strict";
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ALLOWED_REGISTRATION_TOKEN = exports.TOKEN_ACCESS_SYNC_EVENT = exports.TOKEN_ACCESS_STORAGE_KEY = void 0;
exports.isAllowedRegistrationToken = isAllowedRegistrationToken;
exports.getRegisteredAccessToken = getRegisteredAccessToken;
exports.hasRegisteredAccessTokenAccess = hasRegisteredAccessTokenAccess;
exports.setRegisteredAccessToken = setRegisteredAccessToken;
exports.useTokenAccess = useTokenAccess;
var react_1 = require("react");
exports.TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
exports.TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
exports.ALLOWED_REGISTRATION_TOKEN = ((_a = import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN) === null || _a === void 0 ? void 0 : _a.trim()) || 'usr_7f3a9c2d8e1b4a6f';
function normalizeToken(value) {
var _a;
return (_a = value === null || value === void 0 ? void 0 : value.trim()) !== null && _a !== void 0 ? _a : '';
}
function isAllowedRegistrationToken(token) {
return normalizeToken(token) === exports.ALLOWED_REGISTRATION_TOKEN;
}
function getRegisteredAccessToken() {
if (typeof window === 'undefined') {
return '';
}
return normalizeToken(window.localStorage.getItem(exports.TOKEN_ACCESS_STORAGE_KEY));
}
function hasRegisteredAccessTokenAccess() {
return isAllowedRegistrationToken(getRegisteredAccessToken());
}
function setRegisteredAccessToken(token) {
if (typeof window === 'undefined') {
return;
}
var normalizedToken = normalizeToken(token);
if (normalizedToken) {
window.localStorage.setItem(exports.TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
}
else {
window.localStorage.removeItem(exports.TOKEN_ACCESS_STORAGE_KEY);
}
window.dispatchEvent(new CustomEvent(exports.TOKEN_ACCESS_SYNC_EVENT));
}
function useTokenAccess() {
var _a = (0, react_1.useState)(function () { return getRegisteredAccessToken(); }), registeredToken = _a[0], setRegisteredToken = _a[1];
(0, react_1.useEffect)(function () {
if (typeof window === 'undefined') {
return undefined;
}
var syncRegisteredToken = function () {
setRegisteredToken(getRegisteredAccessToken());
};
window.addEventListener('storage', syncRegisteredToken);
window.addEventListener(exports.TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
return function () {
window.removeEventListener('storage', syncRegisteredToken);
window.removeEventListener(exports.TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
};
}, []);
return {
registeredToken: registeredToken,
hasAccess: isAllowedRegistrationToken(registeredToken),
};
}

View File

@@ -59,6 +59,7 @@ export type MainSidebarProps = {
export type MainContentProps = {
contentExpanded: boolean;
sidebarOverlayActive?: boolean;
onToggleContentExpanded: () => void;
children: ReactNode;
};

View File

@@ -0,0 +1,87 @@
const APP_VIEWPORT_HEIGHT_VAR = '--app-viewport-height';
const APP_VIEWPORT_WIDTH_VAR = '--app-viewport-width';
const APP_VISUAL_VIEWPORT_HEIGHT_VAR = '--app-visual-viewport-height';
const APP_VISUAL_VIEWPORT_WIDTH_VAR = '--app-visual-viewport-width';
function roundViewportSize(value: number) {
return Math.round(value * 100) / 100;
}
function getLayoutViewportSize() {
if (typeof window === 'undefined') {
return {
height: 0,
width: 0,
};
}
const documentElement = document.documentElement;
const fallbackHeight = documentElement?.clientHeight ?? 0;
const fallbackWidth = documentElement?.clientWidth ?? 0;
return {
height: roundViewportSize(Math.max(window.innerHeight || 0, fallbackHeight)),
width: roundViewportSize(Math.max(window.innerWidth || 0, fallbackWidth)),
};
}
function getVisualViewportSize() {
if (typeof window === 'undefined') {
return {
height: 0,
width: 0,
};
}
const viewport = window.visualViewport;
return {
height: roundViewportSize(viewport?.height ?? window.innerHeight ?? 0),
width: roundViewportSize(viewport?.width ?? window.innerWidth ?? 0),
};
}
export function applyViewportCssVars() {
if (typeof document === 'undefined') {
return;
}
const layoutViewport = getLayoutViewportSize();
const visualViewport = getVisualViewportSize();
const resolvedHeight = Math.max(layoutViewport.height, visualViewport.height);
document.documentElement.style.setProperty(APP_VIEWPORT_HEIGHT_VAR, `${resolvedHeight}px`);
document.documentElement.style.setProperty(APP_VIEWPORT_WIDTH_VAR, `${layoutViewport.width}px`);
document.documentElement.style.setProperty(APP_VISUAL_VIEWPORT_HEIGHT_VAR, `${visualViewport.height}px`);
document.documentElement.style.setProperty(APP_VISUAL_VIEWPORT_WIDTH_VAR, `${visualViewport.width}px`);
}
export function bindViewportCssVars() {
if (typeof window === 'undefined') {
return () => undefined;
}
let frameId = 0;
const viewport = window.visualViewport;
const sync = () => {
window.cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
applyViewportCssVars();
});
};
sync();
window.addEventListener('resize', sync);
window.addEventListener('orientationchange', sync);
viewport?.addEventListener('resize', sync);
viewport?.addEventListener('scroll', sync);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', sync);
window.removeEventListener('orientationchange', sync);
viewport?.removeEventListener('resize', sync);
viewport?.removeEventListener('scroll', sync);
};
}

61
src/components/README.md Normal file
View File

@@ -0,0 +1,61 @@
# Components Package Guide
`src/components`는 앱 전용 화면이 아니라 여러 화면과 샘플, 문서에서 공통 재사용할 UI 조각을 두는 패키지입니다. 컴포넌트 추가나 수정 시 이 문서를 기본 규약으로 사용합니다.
## 목적
- 화면 조합에 재사용되는 공통 UI를 보관합니다.
- 라이브러리 export 대상과 앱 내부 재사용 대상을 같은 폴더 기준으로 관리합니다.
- 컴포넌트 문서(`docs/components`)와 샘플(`samples`)의 기준 소스 역할을 합니다.
## 공통 설계 원칙
- 샘플(`samples`)을 제외한 컴포넌트에는 API 호출, DB 접근, 라우팅, 화면 전용 상태, 비즈니스 로직을 직접 넣지 않습니다.
- 컴포넌트 설계는 최대한 멍청하게 유지합니다. 직관적인 props를 받고, 그 props에 따라 직관적인 UI 동작만 수행합니다.
- 기능 처리와 상태 orchestration은 `src/features` 또는 해당 화면 전용 패키지 레벨에서 담당합니다.
- 공통 컴포넌트는 어디에서나 재사용될 수 있으므로, 수정 시에는 기존 동작을 바꾸지 않는 범위에서 확장하거나 보완합니다.
## 현재 하위 구조
- `common`: 범용 보조 컴포넌트
- `dashboard`: 진행률, 다중 progress 등 대시보드 계열 공통 UI
- `dataListTable`, `dataStatePanel`, `embeddedMap`, `emptyIllustrationCard`, `evidenceAttachmentStrip`, `formField`, `markdownPreview`, `navigation`, `previewer`, `processFlow`, `queryFilterBuilder`, `search`, `stateKit`, `status-badge`, `stepper`, `timelinePanel`, `window`: 독립 재사용 가능한 컴포넌트 패키지
- `inputs`: 입력 계열 공통 UI
- `primitives`: 가장 작은 입력 단위
- `specialized`: 목적이 뚜렷한 파생 입력
- `composite`: 여러 입력을 묶은 조합형 UI
- `select`, `checkCombo`, `popup`: plugin 확장과 샘플이 포함된 입력 패키지
## 폴더 구성 규약
컴포넌트 패키지는 가능하면 아래 구조를 따릅니다.
```text
component-name/
├─ ComponentName.tsx
├─ ComponentName.css
├─ index.ts
├─ types/ # 외부 노출 타입 또는 내부 분리 타입
├─ plugins/ # plugin factory 또는 preset
└─ samples/ # Docs/APIs 화면에서 쓰는 예제
```
- 진입점은 항상 해당 폴더의 `index.ts`로 둡니다.
- 외부에서 직접 import 해야 하는 타입은 `index.ts` 또는 `types/index.ts`를 통해 다시 export 합니다.
- CSS가 필요하면 컴포넌트 폴더 내부에 함께 둡니다.
- 복잡한 로직이 생기면 `types`, `plugins`, `samples`처럼 역할별 하위 폴더로 분리합니다.
## 구현 규약
- 공통 패키지에는 프로젝트 화면에 종속된 상태나 라우팅 의존을 넣지 않습니다.
- 컴포넌트 이름, 파일명, export 이름은 PascalCase를 유지합니다. 폴더명은 기존 저장소 스타일대로 kebab-case 또는 lowerCamelCase를 따릅니다.
- 라이브러리로 공개할 컴포넌트는 최종적으로 `src/index.ts`에서 다시 export 되어야 합니다.
- 샘플이 필요한 컴포넌트는 `samples/Sample.tsx`를 기본 진입 예제로 두고, 변형 예제는 같은 폴더에 추가합니다.
- plugin 확장형 컴포넌트는 `plugins/*.plugin.ts` 또는 `plugins/index.ts`에서 생성 함수를 모읍니다.
- 공통 타입은 컴포넌트 폴더 안에서 우선 관리하고, 여러 컴포넌트가 공유할 때만 상위 공통 타입으로 승격합니다.
## 문서 규약
- 화면 사용법과 제약은 `docs/components/*.md`에 문서화합니다.
- 새 컴포넌트를 추가하면 최소한 목적, 주요 props, 샘플 위치, plugin 여부를 문서에 남깁니다.
- 패키지 구조나 규약이 바뀌면 이 문서와 해당 컴포넌트 문서를 함께 갱신합니다.

View File

@@ -0,0 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EvidenceAttachmentStrip = exports.EvidenceAttachmentPreviewBody = void 0;
var EvidenceAttachmentStrip_1 = require("./EvidenceAttachmentStrip");
Object.defineProperty(exports, "EvidenceAttachmentPreviewBody", { enumerable: true, get: function () { return EvidenceAttachmentStrip_1.EvidenceAttachmentPreviewBody; } });
Object.defineProperty(exports, "EvidenceAttachmentStrip", { enumerable: true, get: function () { return EvidenceAttachmentStrip_1.EvidenceAttachmentStrip; } });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -50,10 +50,12 @@
.codex-diff-previewer__diff-section {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 0;
background: rgba(15, 23, 42, 0.56);
border-radius: 14px;
overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.codex-diff-previewer__diff-section--expanded {

View File

@@ -5,9 +5,11 @@
width: 100%;
min-width: 0;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.2);
border: 0;
border-radius: 18px;
background: #fff;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2);
box-sizing: border-box;
}
.previewer-ui__header {
@@ -187,9 +189,11 @@
.previewer-ui__editor {
overflow: hidden;
width: 100%;
border: 1px solid rgba(15, 23, 42, 0.45);
border: 0;
border-radius: 0;
background: #1e1e1e;
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.45);
box-sizing: border-box;
}
.previewer-ui__editor-bar {

View File

@@ -0,0 +1,11 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PreviewerUI = exports.CodexDiffPreviewer = exports.parseCodexDiffSections = exports.CODEX_DIFF_STATUS_LABEL_MAP = exports.CodexDiffBlock = void 0;
var CodexDiffBlock_1 = require("./CodexDiffBlock");
Object.defineProperty(exports, "CodexDiffBlock", { enumerable: true, get: function () { return CodexDiffBlock_1.CodexDiffBlock; } });
Object.defineProperty(exports, "CODEX_DIFF_STATUS_LABEL_MAP", { enumerable: true, get: function () { return CodexDiffBlock_1.CODEX_DIFF_STATUS_LABEL_MAP; } });
Object.defineProperty(exports, "parseCodexDiffSections", { enumerable: true, get: function () { return CodexDiffBlock_1.parseCodexDiffSections; } });
var CodexDiffPreviewer_1 = require("./CodexDiffPreviewer");
Object.defineProperty(exports, "CodexDiffPreviewer", { enumerable: true, get: function () { return CodexDiffPreviewer_1.CodexDiffPreviewer; } });
var PreviewerUI_1 = require("./PreviewerUI");
Object.defineProperty(exports, "PreviewerUI", { enumerable: true, get: function () { return PreviewerUI_1.PreviewerUI; } });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

1190
src/features/board/BoardPage.tsx Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import { appendClientIdHeader } from '../../app/main/clientIdentity';
import { normalizeAutomationTypeId } from '../../app/main/automationTypeAccess';
import { getRegisteredAccessToken } from '../../app/main/tokenAccess';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost } from './types';
import type { BoardAttachment, BoardAutomationType, BoardDraft, BoardPost, BoardRequestItem } from './types';
class BoardApiError extends Error {
status: number;
@@ -45,6 +45,54 @@ function normalizeBoardPost(item: BoardPost): BoardPost {
...item,
automationType: normalizeBoardAutomationType(item.automationType),
attachments: Array.isArray(item.attachments) ? item.attachments.map(normalizeBoardAttachment).filter(Boolean) as BoardAttachment[] : [],
requestExecutionMode:
item.requestExecutionMode === 'after_previous_finished' || item.requestExecutionMode === 'after_previous_success'
? item.requestExecutionMode
: 'all_at_once',
requestItems: Array.isArray(item.requestItems)
? item.requestItems.map((requestItem) => ({
...requestItem,
id: Number(requestItem.id ?? 0),
boardPostId: Number(requestItem.boardPostId ?? item.id ?? 0),
sequence: Number(requestItem.sequence ?? 0),
title: String(requestItem.title ?? '').trim(),
content: String(requestItem.content ?? ''),
planItemId:
requestItem.planItemId === null || requestItem.planItemId === undefined
? null
: Number(requestItem.planItemId),
automationReceivedAt:
requestItem.automationReceivedAt === null || requestItem.automationReceivedAt === undefined
? null
: String(requestItem.automationReceivedAt),
workflowState: String(requestItem.workflowState ?? 'pending') as BoardRequestItem['workflowState'],
status: String(requestItem.status ?? 'pending') as BoardRequestItem['status'],
statusLabel: String(requestItem.statusLabel ?? '').trim() || '미접수',
planStatus:
requestItem.planStatus === null || requestItem.planStatus === undefined
? null
: String(requestItem.planStatus),
workerStatus:
requestItem.workerStatus === null || requestItem.workerStatus === undefined
? null
: String(requestItem.workerStatus),
lastError:
requestItem.lastError === null || requestItem.lastError === undefined
? null
: String(requestItem.lastError),
createdAt: String(requestItem.createdAt ?? ''),
updatedAt: String(requestItem.updatedAt ?? ''),
}))
: [],
requestSummary: {
total: Math.max(0, Number(item.requestSummary?.total ?? 0) || 0),
completed: Math.max(0, Number(item.requestSummary?.completed ?? 0) || 0),
failed: Math.max(0, Number(item.requestSummary?.failed ?? 0) || 0),
running: Math.max(0, Number(item.requestSummary?.running ?? 0) || 0),
queued: Math.max(0, Number(item.requestSummary?.queued ?? 0) || 0),
waiting: Math.max(0, Number(item.requestSummary?.waiting ?? 0) || 0),
blocked: Math.max(0, Number(item.requestSummary?.blocked ?? 0) || 0),
},
};
}
@@ -176,6 +224,11 @@ export async function createBoardPost(draft: BoardDraft) {
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
requestExecutionMode: draft.requestExecutionMode,
requestItems: draft.requestItems.map((requestItem) => ({
title: requestItem.title,
content: requestItem.content,
})),
}),
});
@@ -194,6 +247,11 @@ export async function updateBoardPost(draft: BoardDraft) {
content: draft.content,
attachments: draft.attachments,
automationType: draft.automationType,
requestExecutionMode: draft.requestExecutionMode,
requestItems: draft.requestItems.map((requestItem) => ({
title: requestItem.title,
content: requestItem.content,
})),
}),
});
@@ -204,7 +262,7 @@ export async function receiveBoardPostAutomation(id: number) {
const response = await request<{
ok: boolean;
item: BoardPost;
planItemId: number | null;
planItemIds: number[];
alreadyReceived: boolean;
}>(`/board/posts/${id}/actions/automation-receive`, {
method: 'POST',
@@ -213,7 +271,7 @@ export async function receiveBoardPostAutomation(id: number) {
return {
item: normalizeBoardPost(response.item),
planItemId: response.planItemId,
planItemIds: Array.isArray(response.planItemIds) ? response.planItemIds : [],
alreadyReceived: response.alreadyReceived,
};
}

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -9,6 +9,26 @@ export type BoardAttachment = {
mimeType: string;
};
export type BoardRequestExecutionMode = 'all_at_once' | 'after_previous_finished' | 'after_previous_success';
export type BoardRequestItem = {
id: number;
boardPostId: number;
sequence: number;
title: string;
content: string;
planItemId: number | null;
automationReceivedAt: string | null;
workflowState: 'pending' | 'waiting' | 'registered' | 'completed' | 'failed' | 'blocked';
status: 'pending' | 'waiting' | 'queued' | 'in_progress' | 'completed' | 'failed' | 'blocked';
statusLabel: string;
planStatus: string | null;
workerStatus: string | null;
lastError: string | null;
createdAt: string;
updatedAt: string;
};
export type BoardPost = {
id: number;
title: string;
@@ -18,6 +38,18 @@ export type BoardPost = {
automationType: BoardAutomationType;
automationPlanItemId: number | null;
automationReceivedAt: string | null;
automationContextIds?: string[];
requestExecutionMode: BoardRequestExecutionMode;
requestItems: BoardRequestItem[];
requestSummary: {
total: number;
completed: number;
failed: number;
running: number;
queued: number;
waiting: number;
blocked: number;
};
createdAt: string;
updatedAt: string;
};
@@ -28,4 +60,10 @@ export type BoardDraft = {
content: string;
attachments: BoardAttachment[];
automationType: BoardAutomationType;
requestExecutionMode: BoardRequestExecutionMode;
requestItems: Array<{
key: string;
title: string;
content: string;
}>;
};

View File

@@ -29,11 +29,22 @@
- 컴포넌트 간 이벤트 전달, 상태 동기화, focus/selection 이동, 표시/숨김 제어를 기능 명세로 기술할 수 있다
- 한 컴포넌트의 입력/액션이 다른 컴포넌트 목록, 상세, 요약, CTA 상태를 바꾸는 흐름을 기술할 수 있다
- 서버 저장, 조회, 갱신, 삭제 같은 API 연결과 그 응답이 다른 컴포넌트에 반영되는 흐름도 기능 명세 범위에 포함한다
- 가능하면 공통 컴포넌트나 위젯 본체를 직접 수정하기보다, 현재 레이아웃에서 필요한 `props`를 내려 동작과 표시를 조정하는 방식으로 구현한다
- `Layout Editor 실행` 요청은 기본적으로 "현재 화면 조합을 props/배치/상호작용으로 맞춘다"는 의미로 해석하고, 공통 패키지 내부 구현 변경은 최후 수단으로만 검토한다
구현 우선순위:
- 1순위는 기존 컴포넌트/위젯 조합과 `props` 조정만으로 요구사항을 만족시키는 것이다
- 2순위는 현재 프로젝트 전용 래퍼, feature 레이어, 어댑터를 추가해 공통 패키지 수정 없이 화면 요구를 흡수하는 것이다
- 공통 컴포넌트/위젯 수정이 정말 필요할 때만 기존 사용처를 모두 확인한 뒤 제한적으로 수정한다
- 공통 컴포넌트/위젯에 새 동작을 추가할 때는 기본값 `props`를 기존 동작과 동일하게 유지해, 명시적으로 opt-in한 화면만 달라지게 만든다
금지 해석:
- 위젯 또는 컴포넌트 샘플 메타에서 "기본 기능"을 자동으로 끌어와 `Layout Editor` 기능 명세처럼 취급하지 않는다
- 사용자가 직접 추가하지 않은 항목을 기능 명세 목록에 자동 주입하지 않는다
- 레이아웃 기능 명세와 위젯 고유 스펙 문서를 같은 개념으로 섞지 않는다
- 현재 레이아웃 요구를 맞추기 위해 공통 위젯 내부 코드를 바로 덧대고, 그 부작용을 기존 화면이 함께 떠안게 만드는 방식은 지양한다
- 기존 화면 영향도 검토 없이 공통 컴포넌트/위젯의 기본 동작, 기본 스타일, 기본 데이터 흐름을 바꾸지 않는다
구현/문서 작성 시에는 항상 "이 기능은 위젯의 본래 능력 설명인가, 아니면 현재 레이아웃에서 그 컴포넌트가 수행할 역할 설명인가"를 먼저 구분합니다. `Layout Editor` 문맥에서는 후자만 다룹니다.

View File

@@ -0,0 +1,317 @@
.feature-menu-layout-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
height: 100%;
min-height: 0;
padding: 12px;
box-sizing: border-box;
overflow: hidden;
background: linear-gradient(180deg, #f4f6f8 0%, #eef1f4 100%);
}
.feature-menu-layout-page__filters,
.feature-menu-layout-page__editor-shell {
box-sizing: border-box;
border: 1px solid #d6dde5;
border-radius: 0;
background: #f8fafc;
box-shadow: none;
}
.feature-menu-layout-page__filters {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding: 12px;
}
.feature-menu-layout-page__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.feature-menu-layout-page__field-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
color: #51606f;
text-transform: uppercase;
}
.feature-menu-layout-page__select.ant-select,
.feature-menu-layout-page__select .ant-select-selector,
.feature-menu-layout-page__textarea.ant-input {
border-radius: 0;
}
.feature-menu-layout-page__select .ant-select-selector,
.feature-menu-layout-page__textarea.ant-input {
border-color: #c8d1db;
background: #fff;
box-shadow: none;
}
.feature-menu-layout-page__run-button.ant-btn {
height: 44px;
width: 44px;
min-width: 44px;
padding-inline: 0;
border-radius: 0;
justify-content: center;
}
.feature-menu-layout-page__editor-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
height: 100%;
padding: 10px;
overflow: hidden;
}
.feature-menu-layout-page__editor-fields {
display: grid;
grid-template-rows: minmax(0, 1fr);
min-height: 0;
box-sizing: border-box;
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__textarea.ant-input {
border-radius: 0;
display: block;
width: 100%;
box-sizing: border-box;
align-self: stretch;
height: 100% !important;
min-height: 0 !important;
max-height: none;
overflow-y: auto !important;
resize: none;
padding: 14px 16px;
font-size: 14px;
line-height: 1.65;
color: #16202a;
}
.feature-menu-layout-page__textarea.ant-input::placeholder {
color: #8a97a6;
}
.feature-menu-layout-page__editor-toolbar {
display: flex;
justify-content: flex-end;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid #d6dde5;
}
.feature-menu-layout-page__empty {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 240px;
}
.feature-menu-layout-page__tabs {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
flex: 1;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
}
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
.feature-menu-layout-page__tabs .ant-tabs-content,
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
display: flex;
flex: 1 1 auto;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: auto;
}
.feature-menu-layout-page__tabs .ant-tabs-nav {
margin: 0 0 8px;
align-items: center;
border-bottom: 1px solid #d6dde5;
padding-bottom: 8px;
}
.feature-menu-layout-page__tabs .ant-tabs-tab {
border-radius: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav::before {
display: none;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
min-width: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-wrap {
min-width: 0;
}
.feature-menu-layout-page__tab-actions {
justify-content: flex-start;
}
.feature-menu-layout-page__tab-actions .ant-btn {
width: 40px;
min-width: 40px;
padding-inline: 0;
border-radius: 0;
box-shadow: none;
}
.feature-menu-layout-page__notes {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
box-sizing: border-box;
border: 1px solid #d6dde5;
background: #fff;
padding: 14px 16px;
overflow: auto;
padding-bottom: 28px;
}
.feature-menu-layout-page__notes--empty {
align-items: center;
justify-content: center;
}
.feature-menu-layout-page__notes-body.ant-typography {
margin-bottom: 0;
white-space: pre-wrap;
color: #16202a;
}
.feature-menu-layout-page__notes-empty.ant-empty {
margin-block: 0;
}
.feature-menu-layout-page--editor-maximized {
padding: 0;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__filters {
display: none;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
position: fixed;
inset: 16px;
z-index: 40;
padding: 12px;
background: #f8fafc;
}
@media (min-width: 960px) {
.feature-menu-layout-page__filters {
grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr) auto;
align-items: end;
}
.feature-menu-layout-page__run-button.ant-btn {
min-width: 44px;
}
}
@media (max-width: 720px) {
.feature-menu-layout-page {
grid-template-rows: auto minmax(0, 1fr);
padding: 4px 4px calc(2px + env(safe-area-inset-bottom, 0px));
gap: 3px;
overflow: hidden;
}
.feature-menu-layout-page__filters {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
column-gap: 6px;
row-gap: 6px;
}
.feature-menu-layout-page__filters,
.feature-menu-layout-page__editor-shell {
padding: 3px;
}
.feature-menu-layout-page__editor-shell {
grid-template-rows: auto minmax(0, 1fr);
align-self: stretch;
height: calc(100% - 24px);
}
.feature-menu-layout-page__field:first-of-type {
grid-column: 1 / -1;
}
.feature-menu-layout-page__editor-toolbar {
padding-bottom: 0;
margin-bottom: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav {
align-items: flex-start;
margin-bottom: 0;
padding-bottom: 0;
}
.feature-menu-layout-page__tabs .ant-tabs-nav-list {
width: 100%;
}
.feature-menu-layout-page__tabs,
.feature-menu-layout-page__tabs .ant-tabs-content-holder,
.feature-menu-layout-page__tabs .ant-tabs-content,
.feature-menu-layout-page__tabs .ant-tabs-tabpane,
.feature-menu-layout-page__tabs .ant-tabs-tabpane-active {
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__editor-fields {
grid-template-rows: minmax(0, 1fr);
height: 100%;
overflow: hidden;
}
.feature-menu-layout-page__textarea.ant-input {
align-self: stretch;
height: calc(100% - 4px) !important;
min-height: 0 !important;
max-height: none;
padding: 8px 10px;
}
.feature-menu-layout-page__notes {
height: calc(100% - 4px);
max-height: none;
padding: 7px 12px 7px;
padding-bottom: 7px;
}
.feature-menu-layout-page__tab-actions .ant-btn,
.feature-menu-layout-page__run-button.ant-btn {
width: 36px;
height: 36px;
min-width: 36px;
}
.feature-menu-layout-page--editor-maximized .feature-menu-layout-page__editor-shell {
inset: 6px;
padding: 6px;
}
}

View File

@@ -0,0 +1,520 @@
import { ArrowsAltOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, SaveOutlined, ShrinkOutlined } from '@ant-design/icons';
import { Button, Empty, Input, Modal, Space, Tabs, Tooltip, Typography, message } from 'antd';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../../app/main/chatTypeAccess';
import { createChatConversationRoom, fetchChatConversations } from '../../../app/main/mainChatPanel';
import {
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
} from '../../../app/main/chatTypeDefaults';
import { buildChatPath } from '../../../app/main/routes';
import { useTokenAccess } from '../../../app/main/tokenAccess';
import { SelectUI, type SelectOptionItem } from '../../../components/inputs/select';
import { resolvePreferredLayoutCodexChatType } from '../../../views/play/layoutCodexChatType';
import { listSavedLayouts, saveLayout, type SavedLayoutRecord } from '../../../views/play/layoutStorage';
import { isReusableLayoutConversation, resolveLayoutCodexRequestSocketUrl, resolveLayoutConversationTitle } from './featureMenu.chat';
import type { FeatureMenuTabKey, LayoutInteractionRule } from './featureMenu.types';
import { buildFeatureMenuPrompt, normalizeLayoutInteractions } from './featureMenu.utils';
import './FeatureMenuLayoutPage.css';
const { Paragraph, Text } = Typography;
type FeatureMenuLayoutPageProps = {
layoutId: string;
savedLayouts: SavedLayoutRecord[];
onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void;
};
export function FeatureMenuLayoutPage({ layoutId, savedLayouts, onSavedLayoutsChange }: FeatureMenuLayoutPageProps) {
const navigate = useNavigate();
const { chatTypes } = useChatTypeRegistry();
const { hasAccess } = useTokenAccess();
const [messageApi, contextHolder] = message.useMessage();
const [modalApi, modalContextHolder] = Modal.useModal();
const [selectedLayoutId, setSelectedLayoutId] = useState<string | null>(null);
const [selectedFeatureId, setSelectedFeatureId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<FeatureMenuTabKey>('description');
const [isEditorMaximized, setIsEditorMaximized] = useState(false);
const [draftBody, setDraftBody] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isSending, setIsSending] = useState(false);
const chatPermissionRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const availableChatTypes = useMemo(
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
[chatPermissionRoles, chatTypes],
);
const preferredCodexChatType = useMemo(
() => resolvePreferredLayoutCodexChatType(availableChatTypes),
[availableChatTypes],
);
const targetChatType = preferredCodexChatType ?? {
id: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_ID,
name: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_NAME,
description: LAYOUT_EDITOR_GUIDED_CHAT_TYPE_DESCRIPTION,
};
const layoutOptions = useMemo<SelectOptionItem[]>(
() =>
[...savedLayouts]
.sort((left, right) => {
if (left.id === layoutId) {
return -1;
}
if (right.id === layoutId) {
return 1;
}
return 0;
})
.map((item) => ({
code: item.id,
value: item.name,
})),
[layoutId, savedLayouts],
);
useEffect(() => {
if (savedLayouts.length === 0) {
setSelectedLayoutId(null);
return;
}
if (selectedLayoutId && savedLayouts.some((item) => item.id === selectedLayoutId)) {
return;
}
const fallback = savedLayouts.find((item) => item.id === layoutId) ?? savedLayouts[0];
setSelectedLayoutId(fallback?.id ?? null);
}, [layoutId, savedLayouts, selectedLayoutId]);
const selectedLayout = selectedLayoutId ? savedLayouts.find((item) => item.id === selectedLayoutId) ?? null : null;
const selectedLayoutInteractions = useMemo<LayoutInteractionRule[]>(
() => normalizeLayoutInteractions(selectedLayout?.tree ?? null),
[selectedLayout?.tree],
);
const featureOptions = useMemo<SelectOptionItem[]>(
() =>
selectedLayoutInteractions.map((item, index) => ({
code: item.id,
value: item.title || `기능설명 ${index + 1}`,
})),
[selectedLayoutInteractions],
);
useEffect(() => {
if (featureOptions.length === 0) {
setSelectedFeatureId(null);
return;
}
if (selectedFeatureId && featureOptions.some((item) => item.code === selectedFeatureId)) {
return;
}
setSelectedFeatureId(featureOptions[0]?.code ?? null);
}, [featureOptions, selectedFeatureId]);
const selectedFeature =
selectedFeatureId ? selectedLayoutInteractions.find((item) => item.id === selectedFeatureId) ?? null : null;
useEffect(() => {
setDraftBody(selectedFeature?.description ?? '');
setActiveTab('description');
}, [selectedFeature?.description, selectedFeature?.id]);
const isDirty = Boolean(selectedFeature && selectedFeature.description !== draftBody);
const refreshLayouts = async () => {
const nextLayouts = await listSavedLayouts();
onSavedLayoutsChange?.(nextLayouts);
return nextLayouts;
};
const handleSave = async () => {
if (!selectedLayout || !selectedFeature) {
return;
}
const nextDescription = draftBody.trim();
if (!nextDescription) {
void messageApi.warning('기능설명 본문을 입력하세요.');
return;
}
setIsSaving(true);
try {
const nextInteractions = selectedLayoutInteractions.map((item) =>
item.id === selectedFeature.id
? {
...item,
description: nextDescription,
}
: item,
);
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
void messageApi.success('기능설명을 저장했습니다.');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '기능설명 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleAdd = async () => {
if (!selectedLayout) {
return;
}
const nextIndex = selectedLayoutInteractions.length + 1;
const nextFeatureId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `feature-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const nextFeature: LayoutInteractionRule = {
id: nextFeatureId,
title: `기능설명 ${nextIndex}`,
description: '',
implementationNotes: '',
};
const nextInteractions = [...selectedLayoutInteractions, nextFeature];
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
setIsSaving(true);
try {
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
setSelectedFeatureId(nextFeatureId);
setDraftBody('');
setActiveTab('description');
void messageApi.success('기능설명 항목을 추가했습니다.');
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : '기능설명 항목 추가에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = () => {
if (!selectedLayout || !selectedFeature) {
return;
}
void modalApi.confirm({
title: '선택한 기능설명을 삭제할까요?',
content: '삭제 후 되돌릴 수 없습니다.',
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
async onOk() {
const nextInteractions = selectedLayoutInteractions.filter((item) => item.id !== selectedFeature.id);
const nextTree =
selectedLayout.tree && typeof selectedLayout.tree === 'object'
? {
...(selectedLayout.tree as Record<string, unknown>),
interactions: nextInteractions,
interactionMode: 'scoped-v2',
}
: {
root: null,
interactions: nextInteractions,
interactionMode: 'scoped-v2',
};
await saveLayout({
...selectedLayout,
updatedAt: new Date().toISOString(),
tree: nextTree,
});
await refreshLayouts();
void messageApi.success('기능설명을 삭제했습니다.');
},
});
};
const handleCodexExecute = async () => {
if (!selectedLayout) {
return;
}
const rules = selectedFeature ? [selectedFeature] : selectedLayoutInteractions;
const prompt = buildFeatureMenuPrompt(selectedLayout, rules);
const conversationTitle = resolveLayoutConversationTitle(selectedLayout.name);
setIsSending(true);
try {
const conversations = await fetchChatConversations();
const matchedConversation = conversations.find((item) =>
isReusableLayoutConversation(item, conversationTitle, targetChatType.id),
);
const shouldCreateConversation = !matchedConversation;
const targetSessionId =
matchedConversation?.sessionId ||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
if (shouldCreateConversation) {
await createChatConversationRoom({
sessionId: targetSessionId,
title: conversationTitle,
chatTypeId: targetChatType.id,
lastChatTypeId: targetChatType.id,
contextLabel: targetChatType.name,
contextDescription: targetChatType.description,
notifyOffline: true,
});
}
const requestId = `client-${Date.now().toString(36)}`;
const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId);
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(socketUrl);
const timeoutId = window.setTimeout(() => {
socket.close();
reject(new Error('Codex 요청 연결 시간이 초과되었습니다.'));
}, 8000);
socket.addEventListener('open', () => {
socket.send(
JSON.stringify({
type: 'message:send',
payload: {
text: prompt,
chatTypeId: targetChatType.id,
chatTypeLabel: targetChatType.name,
chatTypeDescription: targetChatType.description,
requestId,
mode: 'queue',
},
}),
);
window.setTimeout(() => {
window.clearTimeout(timeoutId);
socket.close();
resolve();
}, 120);
});
socket.addEventListener('error', () => {
window.clearTimeout(timeoutId);
reject(new Error('Codex 요청 연결에 실패했습니다.'));
});
socket.addEventListener('close', (event) => {
if (!event.wasClean && event.code !== 1000) {
window.clearTimeout(timeoutId);
reject(new Error('Codex 요청 연결이 비정상 종료되었습니다.'));
}
});
});
void messageApi.success('Codex 실행 요청을 전송했습니다.');
navigate(`${buildChatPath('live')}?sessionId=${encodeURIComponent(targetSessionId)}`);
} catch (error) {
void messageApi.error(error instanceof Error ? error.message : 'Codex 실행 요청에 실패했습니다.');
} finally {
setIsSending(false);
}
};
const editorTabItems = [
{
key: 'description',
label: '기능설명 입력',
children: (
<div className="feature-menu-layout-page__editor-fields">
<Input.TextArea
value={draftBody}
placeholder="기능설명 본문을 입력하세요."
autoSize={false}
className="feature-menu-layout-page__textarea"
onChange={(event) => {
setDraftBody(event.target.value);
}}
/>
</div>
),
},
{
key: 'notes',
label: 'Codex 설명',
children: selectedFeature?.implementationNotes.trim() ? (
<div className="feature-menu-layout-page__notes">
<Paragraph className="feature-menu-layout-page__notes-body">{selectedFeature.implementationNotes}</Paragraph>
</div>
) : (
<div className="feature-menu-layout-page__notes feature-menu-layout-page__notes--empty">
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={null}
className="feature-menu-layout-page__notes-empty"
/>
</div>
),
},
] satisfies Array<{ key: FeatureMenuTabKey; label: string; children: ReactNode }>;
return (
<div
className={`feature-menu-layout-page${isEditorMaximized ? ' feature-menu-layout-page--editor-maximized' : ''}`}
data-layout-id={layoutId}
>
{contextHolder}
{modalContextHolder}
<section className="feature-menu-layout-page__filters">
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"></Text>
<SelectUI
data={layoutOptions}
value={selectedLayoutId ?? undefined}
allowClear={false}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedLayoutId(nextCode ?? null);
}}
/>
</div>
<div className="feature-menu-layout-page__field">
<Text className="feature-menu-layout-page__field-label"> </Text>
<SelectUI
data={featureOptions}
value={selectedFeatureId ?? undefined}
allowClear={false}
disabled={featureOptions.length === 0}
className="feature-menu-layout-page__select"
onChange={(nextCode) => {
setSelectedFeatureId(nextCode ?? null);
}}
/>
</div>
<Tooltip title="Codex 실행">
<Button
type="primary"
size="large"
icon={<PlayCircleOutlined />}
className="feature-menu-layout-page__run-button"
disabled={!selectedLayout}
loading={isSending}
aria-label="Codex 실행"
onClick={() => {
void handleCodexExecute();
}}
/>
</Tooltip>
</section>
<section className="feature-menu-layout-page__editor-shell">
{selectedFeature ? (
<>
<div className="feature-menu-layout-page__editor-toolbar">
<Space size={8} wrap className="feature-menu-layout-page__tab-actions">
<Tooltip title="추가">
<Button
icon={<PlusOutlined />}
disabled={!selectedLayout}
loading={isSaving && !selectedFeature}
aria-label="추가"
onClick={() => {
void handleAdd();
}}
/>
</Tooltip>
<Tooltip title={isEditorMaximized ? '입력 축소' : '입력 최대화'}>
<Button
type={isEditorMaximized ? 'primary' : 'default'}
icon={isEditorMaximized ? <ShrinkOutlined /> : <ArrowsAltOutlined />}
aria-label={isEditorMaximized ? '입력 축소' : '입력 최대화'}
onClick={() => {
setIsEditorMaximized((current) => !current);
}}
/>
</Tooltip>
<Tooltip title="저장">
<Button
type="primary"
icon={<SaveOutlined />}
disabled={!selectedFeature || !isDirty}
loading={isSaving}
aria-label="저장"
onClick={() => {
void handleSave();
}}
/>
</Tooltip>
<Tooltip title="삭제">
<Button
danger
icon={<DeleteOutlined />}
disabled={!selectedFeature}
aria-label="삭제"
onClick={handleDelete}
/>
</Tooltip>
</Space>
</div>
<Tabs
activeKey={activeTab}
className="feature-menu-layout-page__tabs"
tabPosition="top"
items={editorTabItems}
onChange={(nextKey) => {
setActiveTab(nextKey as FeatureMenuTabKey);
}}
/>
</>
) : (
<div className="feature-menu-layout-page__empty">
<Empty description="선택 가능한 기능설명 항목이 없습니다." />
</div>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { resolveChatWebSocketUrl } from '../../../app/main/mainChatPanel/chatUtils';
export function resolveLayoutConversationTitle(layoutName: string) {
const normalized = layoutName.trim() || '이름 없는 레이아웃';
return `${normalized} 명세`;
}
export function resolveLayoutCodexRequestSocketUrl(sessionId: string) {
const resolvedUrl = new URL(resolveChatWebSocketUrl(sessionId));
if (typeof window === 'undefined') {
return resolvedUrl.toString();
}
if (['127.0.0.1', 'localhost', '0.0.0.0'].includes(window.location.hostname)) {
resolvedUrl.protocol = 'wss:';
resolvedUrl.hostname = 'test.sm-home.cloud';
resolvedUrl.port = '';
resolvedUrl.pathname = '/ws/chat';
}
return resolvedUrl.toString();
}
export function isReusableLayoutConversation(
item: { title: string; chatTypeId: string | null; lastChatTypeId: string | null },
expectedTitle: string,
chatTypeId: string,
) {
return item.title.trim() === expectedTitle && (item.chatTypeId === chatTypeId || item.lastChatTypeId === chatTypeId);
}

View File

@@ -0,0 +1,8 @@
export type LayoutInteractionRule = {
id: string;
title: string;
description: string;
implementationNotes: string;
};
export type FeatureMenuTabKey = 'description' | 'notes';

View File

@@ -0,0 +1,72 @@
import type { SavedLayoutRecord } from '../../../views/play/layoutStorage';
import type { LayoutInteractionRule } from './featureMenu.types';
function normalizeText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
export function normalizeLayoutInteractions(tree: unknown) {
if (!tree || typeof tree !== 'object') {
return [] as LayoutInteractionRule[];
}
const candidate = tree as { interactions?: unknown };
if (!Array.isArray(candidate.interactions)) {
return [];
}
return candidate.interactions
.map((item) => {
if (!item || typeof item !== 'object') {
return null;
}
const next = item as Record<string, unknown>;
const id = normalizeText(next.id);
if (!id) {
return null;
}
return {
id,
title: normalizeText(next.title),
description: typeof next.description === 'string' ? next.description : '',
implementationNotes: typeof next.implementationNotes === 'string' ? next.implementationNotes : '',
};
})
.filter((item): item is LayoutInteractionRule => item !== null);
}
export function buildFeatureMenuPrompt(layout: SavedLayoutRecord, rules: LayoutInteractionRule[]) {
const normalizedRules = rules.filter((rule) => rule.title.trim() && rule.description.trim());
if (normalizedRules.length === 0) {
return `${layout.name} 레이아웃에는 아직 실행할 기능설명 항목이 없습니다.`;
}
const featureBody = normalizedRules
.map((rule, index) => {
const sections = [`${index + 1}. 기능 제목: ${rule.title.trim()}`, `설명: ${rule.description.trim()}`];
if (rule.implementationNotes.trim()) {
sections.push(`관련 설명: ${rule.implementationNotes.trim()}`);
}
sections.push('이 항목에 필요한 React UI, 상태 연결, API 연동만 구현하고 다른 기능은 건드리지 마세요.');
return sections.join('\n');
})
.join('\n\n');
return [
`레이아웃 이름: ${layout.name}`,
'아래 기능설명 기준으로 실제 메뉴 화면 구현을 진행해 주세요.',
'실제 개발이 진행되면 설계문서 전체 최종본은 src/features/layout/feature-menu/resources/feature-menu-final.md에 함께 반영해 주세요.',
'설명 문구를 화면에 그대로 노출하지 말고 동작 구현에만 사용해 주세요.',
'기능설명 입력과 관련 설명은 같은 본문에 섞지 말고 탭으로 구분해 주세요.',
'상시 노출 액션 버튼은 문구 없이 아이콘만 사용하고 tooltip과 aria-label로 의미를 보완해 주세요.',
'저장된 레이아웃 구조와 pane 수는 유지하세요.',
'요구사항:',
featureBody,
].join('\n');
}

View File

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

View File

@@ -0,0 +1,23 @@
# 기능설명 관리 패키지 분석 문서
## 요청 목표
- 기존 `기능설명 관리` 화면에서 모바일 기준 가시성을 높이고, 기능설명 편집 흐름을 단순화한다.
- 최종 산출물을 세션 리소스에만 남기지 않고 `feature-menu` 패키지 내부에서도 바로 추적 가능하게 정리한다.
## 작업 대상
- 패키지 루트: `src/features/layout/feature-menu/`
- 관련 저장 레이아웃 ID: `layout-1777643627048`
- 검증 기준 도메인: `https://test.sm-home.cloud/`
- 최종 확인 기준: `4173 preview`
## 확인된 기존 문제
- 상단 description 요약이 모바일 세로 공간을 과도하게 차지했다.
- 하단 액션과 탭 구성이 좁은 화면에서 잘리거나 입력 영역을 압박했다.
- 기능설명 제목은 선택만 가능하고 편집 입력이 없어 수정 흐름이 한 번에 이어지지 않았다.
- 최종 설계/검증 근거가 세션 리소스에 분산돼 패키지 단위 추적성이 약했다.
## 최종 판단
- 이 화면은 신규 메뉴가 아니라 기존 `feature-menu` 패키지의 수정 작업으로 유지한다.
- 기능설명 입력과 Codex 설명은 탭으로 분리하되, 제목 입력은 제거하고 본문 textarea 단일 편집 흐름으로 유지한다.
- 최종 설계 문서, 구현 완료 문서, 검증 이미지까지 패키지 내부 `resources/`에서 같이 관리한다.
- 헤더 가림 수정 이후 완료 기준 산출물은 패키지 내부 최종 경로로 이관해 세션 리소스 의존도를 줄인다.

View File

@@ -0,0 +1,72 @@
# 기능설명 관리 패키지 최종 설계문서
## 문서 목적
- 이 문서는 `src/features/layout/feature-menu/` 패키지의 최종 설계 기준 문서다.
- 실제 개발이 진행된 뒤에는 세션 리소스 문서보다 이 문서를 우선 기준으로 사용한다.
- 세션 리소스 문서는 대화 기록용 보조 산출물로만 유지하고, 최종 분석/검증 산출물은 패키지 내부 `resources/`를 기준으로 관리한다.
## 대상 범위
- 메뉴명: `기능설명 관리`
- 관련 저장 레이아웃 ID: `layout-1777643627048`
- 패키지 루트: `src/features/layout/feature-menu/`
- 진입 시 현재 메뉴 레이아웃 ID를 우선 선택하고, 같은 이름/ID 레코드에 바로 덮어쓴다.
- 모바일에서는 전체 편집 레이아웃이 부모 높이를 넘지 않도록 루트와 편집 박스의 높이 계산을 `border-box` 기준으로 고정하고, 편집 셸은 남는 높이를 강제로 늘리지 않고 내용 기준 높이로 먼저 맞춘다.
## 패키지 구조 기준
- 전용 화면과 로직은 `feature-menu` 패키지 내부에만 둔다.
- 현재 패키지 구성은 `FeatureMenuLayoutPage.tsx`, `FeatureMenuLayoutPage.css`, `featureMenu.types.ts`, `featureMenu.utils.ts`, `featureMenu.chat.ts`로 분리한다.
- 공용 app 계층으로 승격하는 작업은 다른 화면 재사용 근거가 확인될 때만 진행한다.
## 화면 역할
- 이 메뉴는 일반 메모 관리가 아니라 선택한 레이아웃의 `tree.interactions`를 선택, 편집, 실행하는 관리 화면이다.
- 선택된 기능설명의 저장 대상은 `selectedLayout.tree.interactions[].description`이다.
- `implementationNotes`는 본문 저장 대상이 아니라 관련 설명 탭에서만 읽는 보조 메타데이터다.
## 화면 구성 최종본
1. 상단 필터 영역
- `레이아웃명` 선택
- `기능설명 선택`
- `Codex 실행` 아이콘 버튼
2. 본문 편집 영역
- 상단 요약 description 박스는 두지 않는다.
- `추가`, `저장`, `삭제` 액션 아이콘은 두번째 섹션 상단에 고정해 하단 잘림을 피한다.
- 편집영역 툴바에는 문구 없는 `입력 최대화` 아이콘을 함께 둔다.
- 탭 버튼은 본문 상단에 두고, 선택된 탭 내용이 아래로 바로 이어지게 한다.
- 탭 구성: `기능설명 입력`, `Codex 설명`
- `기능설명 입력` 탭은 별도 제목 입력 없이 textarea 하나만 둔다.
- 기능설명 제목은 상단 `기능설명 선택` 드롭다운 항목명으로만 유지한다.
- `기능설명 입력` textarea는 편집 카드 높이를 최대한 채우도록 늘린다.
- `Codex 설명`이 비어 있을 때는 설명 문구를 따로 노출하지 않는다.
- 완료 기준 문서와 검증 산출물은 세션 리소스가 아니라 이 패키지 내부 경로를 우선 기준으로 본다.
## UI 규칙
- 기능설명 입력과 관련 설명은 같은 본문에 섞지 않고 탭으로 분리한다.
- 상시 노출 액션 버튼은 문구 없이 아이콘만 사용한다.
- 버튼 의미는 tooltip과 `aria-label`로 보완한다.
- 모바일에서는 설명성 문구보다 입력 영역과 하단 액션/탭 가시성을 우선한다.
- 모바일에서는 첫 섹션 높이를 줄여 두번째 섹션이 더 위에서 시작되도록 배치한다.
- `textarea`는 일반 상태와 최대화 상태 모두에서 마지막 줄이 잘리지 않게 내부 스크롤을 유지한다.
- 하단 입력 마지막 줄이 잘리지 않도록 탭 본문은 내부 스크롤과 하단 여백을 유지한다.
- 모바일에서는 제목 input, 탭 헤더, textarea가 같은 편집 카드 안에서 보이되, 편집 카드 자체는 다시 `auto + 1fr`로 남는 높이를 채운다.
- 모바일 편집 셸과 탭 본문은 `1fr` 채움을 유지하되, 하단 safe-area를 포함한 바깥 padding을 최소한으로 남긴다.
- 모바일에서는 아이폰 12 Pro 실기기 기준으로 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 줄이고, 편집 셸 높이 감산은 `24px`, 입력/설명 패널 감산은 `4px` 수준으로 맞춰 wrapper와 입력 영역이 함께 더 아래까지 늘어나게 한다.
- 모바일 `Codex 설명` 탭도 같은 높이 체계를 유지하고, 하단 padding만 줄여 wrapper 하단의 큰 빈 영역처럼 보이지 않게 한다.
- 전체 페이지 overflow는 숨기고, 넘치는 내용은 페이지 바깥이 아니라 textarea 또는 `Codex 설명` 패널 내부 스크롤에서만 처리한다.
## Codex Live 실행 규칙
- 이 메뉴에서 Codex 실행 시 현재 선택된 레이아웃과 기능설명 본문을 프롬프트 입력으로 사용한다.
- 실제 구현 요청을 보낼 때는 이 패키지 최종 설계문서를 함께 갱신 대상으로 간주한다.
- 후속 개발에서 설계가 바뀌면 세션 문서만 수정하지 말고 이 문서를 먼저 갱신한다.
## 검증 기준
- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선한다.
- 검증 대상 기본 도메인은 `https://test.sm-home.cloud/`다.
- 최종 검증 산출물은 `resources/verification/` 아래에 패키지 기준으로 함께 보관한다.
## 패키지 내부 산출물
- 분석 문서: `resources/feature-menu-analysis.md`
- 개발 완료 문서: `resources/feature-menu-implementation.md`
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-mobile-final.png`
- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-desktop-final.png`
- `test.sm-home.cloud` 비교 검증 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`

View File

@@ -0,0 +1,58 @@
# 기능설명 관리 패키지 개발 완료 문서
## 반영 내용
- 상단 description 요약 영역을 제거했다.
- 상단 필터에는 `Codex 실행` 아이콘 버튼만 유지했다.
- 상단 필터의 두번째 선택은 `기능설명 선택`으로 유지하고, 제목은 드롭다운 항목명으로만 유지하게 바꿨다.
- 본문은 `기능설명 입력`, `Codex 설명` 탭으로 구성했다.
- 후속 단순화 요청에 따라 `기능설명 입력` 탭의 제목 `input`은 제거하고 textarea 하나만 남겼다.
- `추가`, `저장`, `삭제` 아이콘 액션은 두번째 섹션 상단으로 옮겼다.
- 편집영역 툴바에 문구 없는 `입력 최대화` 아이콘 토글을 추가했다.
- `기능설명 입력` textarea는 편집 영역의 남는 세로 공간을 `100%` 채우도록 다시 조정했다.
- 모바일에서는 편집 필드를 grid 행으로 재구성하고 툴바/탭/입력 패딩을 더 줄여 textarea가 부모 영역을 넘치지 않게 조정했다.
- 이번 수정에서는 편집 셸과 탭 본문 자체를 `minmax(0, 1fr)` 기반 grid로 다시 묶고, `textarea`를 남은 높이만 채우는 방식으로 바꿨다.
- 후속 미세조정으로 모바일 `textarea``calc(100% - 52px)`까지만 차도록 다시 줄여, 하단 테두리가 화면 안에서 분명히 보이도록 맞췄다.
- 추가 미세조정으로 모바일 wrapper 자체가 덜 눌려 보이도록 페이지/편집 셸 패딩과 탭 간격을 한 번 더 줄이고, `textarea``calc(100% - 60px)`까지만 차도록 낮췄다.
- 이번 후속 수정에서는 textarea 자체보다 부모 wrapper가 길게 늘어난 점을 기준으로, 모바일에서 루트/편집 셸/탭/입력 필드의 `1fr` 확장을 풀고 내용 기준 높이로 다시 줄였다.
- 이번 최신 수정에서는 너무 줄어든 모바일 높이를 다시 되돌려, 루트/편집 셸/탭/입력 필드를 `auto + 1fr` 채움 구조로 복구하고 바깥 패딩, 탭 간격, `notes` 하단 padding만 더 줄였다.
- 이번 최신 미세조정에서는 부모 카드가 덜 잘리고 textarea는 조금 더 다시 커지도록, 모바일 편집 셸 높이는 `30px` 안쪽으로만 줄이고 제목행/툴바/탭 간격을 더 압축한 뒤 `textarea``Codex 설명` 패널 높이는 각각 `30px` 안쪽 기준으로 다시 맞췄다.
- 이번 최신 재조정에서는 부모 카드 하단선을 더 확실히 보이게 하려고 모바일 편집 셸 높이 감산을 `42px`로 늘리고, 대신 `textarea``Codex 설명` 패널 높이 감산은 `24px`로만 유지해 입력 높이 손실을 최소화했다.
- 이번 최신 재조정에서는 wrapper 하단선이 실제로 보이도록 모바일 편집 셸 감산을 `56px`로 더 키우고, 대신 필터/툴바/탭/제목행 고정 높이를 더 줄인 뒤 `textarea``Codex 설명` 패널 감산은 `20px`로만 유지했다.
- 이번 최신 후속 조정에서는 아이폰 12 Pro 실기기 캡처 기준으로 하단 safe-area 여유와 편집 셸 감산이 과하다고 보고, 모바일 페이지 하단 padding을 `calc(4px + env(safe-area-inset-bottom))`로 줄이고 편집 셸 감산도 `44px`로 낮췄다. 동시에 입력/설명 패널 감산은 `12px`로 완화해 두번째 카드가 더 아래까지 늘어나도록 다시 키웠다.
- 이번 추가 보정은 원복 요청에 따라 되돌렸고, 모바일 기준은 다시 아이폰 12 Pro 실기기 캡처를 따라 페이지 하단 padding `calc(4px + env(safe-area-inset-bottom))`, 편집 셸 감산 `44px`, 입력/설명 패널 감산 `12px` 조합으로 복구했다.
- 이번 최신 조정에서는 모바일 하단 여백을 더 줄여달라는 요청에 맞춰 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 낮추고, 편집 셸 감산을 `24px`, 입력/설명 패널 감산을 `4px`로 완화해 wrapper와 textarea를 함께 다시 키웠다.
- `play-saved` 모바일 레이아웃도 헤더 높이를 `52px` 기준으로 맞춰 상단 가림을 제거했다.
- 진입 직후에는 현재 메뉴 레이아웃 ID를 먼저 선택하도록 바꿔, 이전 다른 레이아웃이 기본값으로 먼저 보이지 않게 맞췄다.
- 모바일 하단 여백처럼 보이던 현상은 페이지 루트가 `height: 100%` 상태에서 `padding`까지 바깥으로 더해지던 문제여서, 루트/편집 셸/탭 영역에 `box-sizing: border-box`를 맞춰 전체 레이아웃 overflow를 막았다.
-`Codex 설명` 탭에서는 설명성 문구를 제거했다.
- Codex Live 실패 응답 복구 로직은 `src/app/main/mainChatPanel/chatUtils.ts`에서 별도로 보완됐다.
- 최종 완료 기준 문서와 검증 이미지는 `feature-menu` 패키지 내부 `resources/` 최종 경로로 이관했다.
## 산출물 위치
- 최종 설계 문서: `resources/feature-menu-final.md`
- 최종 분석 문서: `resources/feature-menu-analysis.md`
- 최종 preview 모바일: `resources/verification/feature-menu-preview-mobile-final.png`
- 최종 preview 데스크톱: `resources/verification/feature-menu-preview-desktop-final.png`
- `test.sm-home.cloud` 재현 확인 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png`
## 검증 결과
- `test.sm-home.cloud` 모바일 재현에서는 textarea 하단이 편집 쉘 아래로 약 `91px` 넘치는 기존 상태를 다시 확인했다.
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `tabs.bottom = 651`, `textarea.bottom = 651`로 맞춰졌고, 마지막 줄까지 내부 스크롤로 확인됐다.
- 최종 반영 결과는 `4173 preview` 기준 모바일/데스크톱 캡처로 보관했다.
- 같은 날짜 후속 개선으로 루트/편집 셸/탭 본문을 다시 `auto + minmax(0, 1fr)` 구조로 복구하고, textarea를 `autoSize` 대신 탭 본문 남는 높이 전체를 채우는 방식으로 조정했다.
- `2026-05-03` `4173 preview` 최종 재검증에서는 모바일 기준 `bodyScrollHeight = 844`, `root.bottom = 844`, `textarea.bottom = 831`, `textarea.height = 487.17`로 남는 공간을 채우면서도 페이지 바깥 overflow는 발생하지 않았다.
- 같은 날짜 추가 미세조정 뒤 `4173 preview` 모바일 재검증에서는 `bodyScrollHeight = 664`, `root.bottom = 664`, `textarea.bottom = 599`, `textarea.height = 255.17`로 textarea 하단 여유를 더 확보했고, 페이지 바깥 overflow는 없었다.
- 같은 날짜 최신 재검증에서는 `test.sm-home.cloud`가 여전히 기존 번들로 `textarea.bottom = 747.25`인 반면, `4173 preview` 수정본은 `shell.bottom = 660`, `tabs.bottom = 655`, `textarea.bottom = 595`, `textarea.height = 272.17`로 wrapper 외곽과 하단 입력 영역이 더 안쪽에 들어오도록 정리됐다.
- 이번 후속 수정 검증은 동일한 모바일 문제 이미지 기준으로 부모 wrapper 하단의 과한 빈 영역이 사라졌는지 확인하는 것이 목적이다.
- `2026-05-03` `4173 preview` 모바일 재검증에서는 `shell.bottom = 547.83`, `tabs.bottom = 542.83`, `textarea.bottom = 542.83`, `textarea.height = 220`으로 wrapper 자체가 이전보다 약 `292px` 짧아졌다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 840`, `tabs.bottom = 836`, `textarea.bottom = 836`, `textarea.height = 521.17`로 다시 하단까지 거의 채우면서도 카드 외곽 하단 선이 캡처 안에서 유지됐다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 806`, `tabs.bottom = 801`, `textarea.bottom = 771`, `textarea.height = 455.17`로 부모 카드 하단이 다시 화면 안에 들어오면서 textarea 높이도 이전 `v30`보다 `187px` 커졌다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 794`, `tabs.bottom = 789`, `textarea.bottom = 765`, `textarea.height = 449.17`로 부모 카드 하단선을 `v31`보다 `12px` 더 위로 올리면서도 textarea 높이는 `6px`만 줄였다.
- 같은 날짜 최신 재검증에서는 `4173 preview` 모바일 기준 `shell.bottom = 778`, `tabs.bottom = 774`, `textarea.bottom = 754`, `textarea.height = 447.17`로 wrapper 하단선이 캡처 안에서 분명히 보이면서도 textarea 높이는 직전 대비 `2px`만 줄었다.
- 같은 날짜 원복 기준은 아이폰 12 Pro viewport에서 `test.sm-home.cloud``shell.bottom = 598`, `notes.bottom = 574`인 반면, `4173 preview` 수정본은 `shell.bottom = 616`, `notes.bottom = 600`으로 두번째 카드가 실제로 더 아래까지 내려오던 시점의 값으로 맞춘다.
- 이번 최신 조정 검증은 모바일 wrapper와 textarea를 동시에 더 늘리는 것이 목적이며, `4173 preview` 기준으로 다시 확인한다.
- 같은 날짜 최신 `v36` 검증에서는 아이폰 12 Pro viewport 기준 `4173 preview``shell.bottom = 586`, `tabs.bottom = 582`, `textarea.bottom = 578`, `textarea.height = 323.17`로 확인됐고, 같은 조건 `test.sm-home.cloud``shell.bottom = 564`, `tabs.bottom = 560`, `textarea.bottom = 548`, `textarea.height = 293.17`이었다.
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1088`, `tabs.bottom = 1077`, `textarea.bottom = 1077`로 기존 데스크톱 채움 구조는 유지됐다.
- 같은 날짜 데스크톱 `4173 preview` 재검증에서는 `shell.bottom = 1188`, `tabs.bottom = 1177`, `textarea.bottom = 1177`로 데스크톱 채움 구조가 그대로 유지됐다.
- 같은 날짜 후속 수정으로 `기능설명 입력` 탭은 `title input` 없이 textarea 하나만 남았고, `4173 preview` 기준 `titleInputCount = 0`, `textareaCount = 1`로 확인했다.
- 이번 이관 작업은 패키지 내부 문서/리소스 구조 정리이며 동작 로직 추가 변경은 포함하지 않는다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -170,6 +170,7 @@ export function StockAlertLayoutProvider({ children }: PropsWithChildren) {
alertTypeLabels: toAlertTypeLabels(nextAlertTypes),
currentPrice: null,
changeRate: null,
volumeRate5d: null,
quotedAt: null,
createdAt: null,
updatedAt: null,
@@ -546,39 +547,39 @@ export function StockAlertGridPane() {
field: 'stockName',
headerName: '종목명',
editable: false,
minWidth: 170,
flex: 1.3,
minWidth: 150,
flex: 1.05,
},
{
field: 'changeRate',
headerName: '등락률',
editable: false,
minWidth: 130,
flex: 0.9,
minWidth: 104,
flex: 0.72,
cellRenderer: ChangeRateCellRenderer,
},
{
field: 'currentPrice',
headerName: '현재가',
editable: false,
minWidth: 120,
flex: 0.9,
minWidth: 110,
flex: 0.8,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, number | null>) => formatPrice(params.value ?? null),
},
{
field: 'quotedAt',
headerName: '기준일시',
editable: false,
minWidth: 190,
flex: 1.2,
minWidth: 168,
flex: 1,
valueFormatter: (params: ValueFormatterParams<StockAlertDraftRow, string | null>) => formatQuotedAt(params.value ?? null),
},
{
field: 'alertTypes',
headerName: '알림유형',
editable: false,
minWidth: 220,
flex: 1.1,
minWidth: 180,
flex: 0.95,
cellRenderer: AlertTypeCellEditorRenderer,
cellRendererParams: (params: ICellRendererParams<StockAlertDraftRow>) => ({
isOpen: params.data?.id === activeAlertTypeEditorRowId,

View File

@@ -12,6 +12,7 @@ export type StockAlertItem = {
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
volumeRate5d: number | null;
quotedAt: string | null;
createdAt: string;
updatedAt: string;
@@ -26,6 +27,7 @@ export type StockAlertDraftRow = {
alertTypeLabels: string[];
currentPrice: number | null;
changeRate: number | null;
volumeRate5d: number | null;
quotedAt: string | null;
createdAt: string | null;
updatedAt: string | null;
@@ -61,6 +63,7 @@ function toDraftRow(item: StockAlertItem): StockAlertDraftRow {
alertTypeLabels: item.alertTypeLabels,
currentPrice: item.currentPrice,
changeRate: item.changeRate,
volumeRate5d: item.volumeRate5d,
quotedAt: item.quotedAt,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
@@ -81,6 +84,7 @@ export function createEmptyStockAlertRow(): StockAlertDraftRow {
alertTypeLabels: ['현재가'],
currentPrice: null,
changeRate: null,
volumeRate5d: null,
quotedAt: null,
createdAt: null,
updatedAt: null,

View File

@@ -880,6 +880,8 @@ export function PlanBoardPage({
return [];
}
const sourceWorkCount = Number(selectedItem.usageSnapshot?.sourceWorkCount ?? 0);
const hasSourceWork = Number.isFinite(sourceWorkCount) && sourceWorkCount > 0;
const needsWorkRetryBecauseBranchMissing =
(selectedItem.status === '작업완료' || selectedItem.status === '릴리즈완료') &&
(selectedItem.workerStatus === 'release반영실패' || selectedItem.workerStatus === 'main반영실패') &&
@@ -917,9 +919,10 @@ export function PlanBoardPage({
if (
selectedItem.status === '릴리즈완료' ||
(selectedItem.status === '작업완료' && selectedItem.workerStatus === 'release반영실패')
(selectedItem.status === '작업완료' && selectedItem.workerStatus === 'release반영실패') ||
(selectedItem.workerStatus === 'main반영실패' && !hasSourceWork)
) {
buttons.push({ key: 'cancel-release', label: '작업취소' });
buttons.push({ key: 'cancel-release', label: '취소완료 처리' });
}
buttons.push({
@@ -2762,7 +2765,7 @@ function getActionSuccessMessage(action: PlanActionType) {
}
if (action === 'cancel-release') {
return 'release 배포 내역을 롤백하고 작업취소로 완료 처리했습니다.';
return '취소완료 처리했습니다.';
}
return '이슈 브랜치 기준으로 main 반영을 요청했습니다.';

View File

@@ -1,4 +1,4 @@
import { CopyOutlined } from '@ant-design/icons';
import { CopyOutlined, PlusOutlined } from '@ant-design/icons';
import {
Alert,
Button,
@@ -44,8 +44,10 @@ import {
type PlanScheduleMode,
type PlanScheduleRepeatUnit,
type PlanScheduledTask,
type PlanScheduledTaskDateRange,
type PlanScheduledTaskDraft,
type PlanScheduledTaskSaveResult,
type PlanScheduledTaskTimeWindow,
} from './api';
const { Paragraph, Text, Title } = Typography;
@@ -98,6 +100,46 @@ const DEFAULT_DAILY_RUN_TIME = '09:00';
const KST_TIME_ZONE = 'Asia/Seoul';
const DAY_MS = 24 * 60 * 60 * 1000;
const TIME_OF_DAY_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const WEEKDAY_OPTIONS = [
{ label: '월', value: 1 },
{ label: '화', value: 2 },
{ label: '수', value: 3 },
{ label: '목', value: 4 },
{ label: '금', value: 5 },
{ label: '토', value: 6 },
{ label: '일', value: 0 },
] as const;
const WEEKDAY_LABELS: Record<number, string> = Object.fromEntries(
WEEKDAY_OPTIONS.map((item) => [item.value, item.label] as const),
);
function normalizeScheduleWeekdays(value: number[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(value.map((item) => Number(item)).filter((item) => Number.isInteger(item) && item >= 0 && item <= 6)),
).sort((left, right) => left - right);
}
function buildScheduleWeekdaysSummary(value: number[] | null | undefined) {
const weekdays = normalizeScheduleWeekdays(value);
if (!weekdays.length) {
return '매일';
}
return weekdays.map((weekday) => WEEKDAY_LABELS[weekday] ?? String(weekday)).join(', ');
}
function createEmptyScheduleTimeWindow(): PlanScheduledTaskTimeWindow {
return {
startTime: null,
endTime: null,
};
}
function getRepeatIntervalSeconds(value: number, unit: PlanScheduleRepeatUnit) {
const normalizedValue = Math.max(1, Math.round(Number(value) || 1));
@@ -195,6 +237,87 @@ function normalizeOptionalTimeOfDay(value: string | null | undefined) {
return typeof value === 'string' && TIME_OF_DAY_PATTERN.test(value) ? value : null;
}
function normalizeDateKey(value: string | null | undefined) {
const trimmedValue = typeof value === 'string' ? value.trim() : '';
return DATE_KEY_PATTERN.test(trimmedValue) ? trimmedValue : null;
}
function normalizeScheduleDateRanges(value: PlanScheduledTaskDateRange[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((range) => ({
startDate: normalizeDateKey(range?.startDate) ?? '',
endDate: normalizeDateKey(range?.endDate) ?? '',
}))
.filter((range) => range.startDate && range.endDate)
.sort((left, right) =>
left.startDate === right.startDate
? left.endDate.localeCompare(right.endDate)
: left.startDate.localeCompare(right.startDate),
)
.filter((range, index, ranges) => {
const previousRange = ranges[index - 1];
return !previousRange || previousRange.startDate !== range.startDate || previousRange.endDate !== range.endDate;
});
}
function normalizeScheduleTimeWindows(value: PlanScheduledTaskTimeWindow[] | null | undefined) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((window) => ({
startTime: normalizeOptionalTimeOfDay(window?.startTime),
endTime: normalizeOptionalTimeOfDay(window?.endTime),
}))
.filter((window) => window.startTime || window.endTime)
.sort((left, right) => {
const leftKey = `${left.startTime ?? ''}:${left.endTime ?? ''}`;
const rightKey = `${right.startTime ?? ''}:${right.endTime ?? ''}`;
return leftKey.localeCompare(rightKey);
})
.filter((window, index, windows) => {
const previousWindow = windows[index - 1];
return !previousWindow
|| previousWindow.startTime !== window.startTime
|| previousWindow.endTime !== window.endTime;
});
}
function formatSingleRepeatWindowLabel(window: PlanScheduledTaskTimeWindow) {
if (window.startTime && window.endTime) {
return `${window.startTime}~${window.endTime}`;
}
if (window.startTime) {
return `${window.startTime} 이후`;
}
if (window.endTime) {
return `${window.endTime} 이전`;
}
return '시간 제한 없음';
}
function buildRepeatWindowsSummary(value: PlanScheduledTaskTimeWindow[] | null | undefined) {
const normalizedWindows = normalizeScheduleTimeWindows(value);
if (!normalizedWindows.length) {
return '시간 제한 없음';
}
if (normalizedWindows.length === 1) {
return formatSingleRepeatWindowLabel(normalizedWindows[0]);
}
return `${formatSingleRepeatWindowLabel(normalizedWindows[0])}${normalizedWindows.length - 1}`;
}
function updateOptionalTimeOfDay(
value: string | null | undefined,
part: 'hour' | 'minute',
@@ -208,25 +331,6 @@ function updateOptionalTimeOfDay(
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);
@@ -234,7 +338,7 @@ function formatScheduleCycle(item: PlanScheduledTask) {
return `매일 ${normalizeDailyRunTime(item.dailyRunTime)} 실행`;
}
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${formatRepeatWindowLabel(item.repeatWindowStartTime, item.repeatWindowEndTime)}`;
return `${formatRepeatInterval(item.repeatIntervalValue, item.repeatIntervalUnit, item.repeatIntervalSeconds)} · ${buildRepeatWindowsSummary(item.repeatWindows)}`;
}
function getValidDate(value: string | null | undefined) {
@@ -277,11 +381,68 @@ function createDateFromKstParts(year: number, month: number, day: number, hour:
return new Date(Date.UTC(year, month - 1, day, hour - 9, minute, 0, 0));
}
function createDateFromKstDateKey(dateKey: string, hour: number, minute: number) {
const [year, month, day] = dateKey.split('-').map((value) => Number(value));
return createDateFromKstParts(year, month, day, hour, minute);
}
function resolveRangeStartRunAt(
item: PlanScheduledTask,
dateRange: PlanScheduledTaskDateRange,
) {
if (normalizeScheduleMode(item.scheduleMode) === 'daily') {
const [hour, minute] = normalizeDailyRunTime(item.dailyRunTime).split(':').map((value) => Number(value));
return createDateFromKstDateKey(dateRange.startDate, hour, minute);
}
const startTime = normalizeScheduleTimeWindows(item.repeatWindows)[0]?.startTime ?? normalizeOptionalTimeOfDay(item.repeatWindowStartTime);
const [hour, minute] = (startTime ?? '00:00').split(':').map((value) => Number(value));
const rangeStartAt = createDateFromKstDateKey(dateRange.startDate, hour, minute);
if (item.immediateRunEnabled) {
return rangeStartAt;
}
return new Date(rangeStartAt.getTime() + Math.max(1, item.repeatIntervalSeconds) * 1000);
}
function isWithinScheduleDateRanges(ranges: PlanScheduledTaskDateRange[], now = new Date()) {
const normalizedRanges = normalizeScheduleDateRanges(ranges);
if (!normalizedRanges.length) {
return true;
}
const dateKey = getKstDateKey(now);
return normalizedRanges.some((range) => range.startDate <= dateKey && dateKey <= range.endDate);
}
function resolveUpcomingScheduleRange(
ranges: PlanScheduledTaskDateRange[],
now = new Date(),
) {
const normalizedRanges = normalizeScheduleDateRanges(ranges);
const dateKey = getKstDateKey(now);
if (!dateKey) {
return null;
}
return normalizedRanges.find((range) => range.startDate > dateKey) ?? null;
}
function resolveNextPlanScheduleRunAt(item: PlanScheduledTask, now = new Date()) {
if (!item.enabled) {
return null;
}
const scheduleDateRanges = normalizeScheduleDateRanges(item.scheduleDateRanges);
if (scheduleDateRanges.length && !isWithinScheduleDateRanges(scheduleDateRanges, now)) {
const upcomingRange = resolveUpcomingScheduleRange(scheduleDateRanges, now);
return upcomingRange ? resolveRangeStartRunAt(item, upcomingRange) : null;
}
const lastRegisteredAt = getValidDate(item.lastRegisteredAt);
if (!lastRegisteredAt && item.immediateRunEnabled) {
@@ -333,6 +494,9 @@ function createEmptyScheduleDraft(defaultReleaseTarget = 'release'): PlanSchedul
repeatIntervalSeconds: getRepeatIntervalSeconds(60, 'minute'),
repeatIntervalMinutes: 60,
dailyRunTime: DEFAULT_DAILY_RUN_TIME,
scheduleWeekdays: [],
scheduleDateRanges: [],
repeatWindows: [],
repeatWindowStartTime: null,
repeatWindowEndTime: null,
};
@@ -366,6 +530,9 @@ function toDraft(item: PlanScheduledTask): PlanScheduledTaskDraft {
repeatIntervalSeconds: item.repeatIntervalSeconds ?? getRepeatIntervalSeconds(repeatIntervalValue, repeatIntervalUnit),
repeatIntervalMinutes: item.repeatIntervalMinutes ?? getRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit),
dailyRunTime: normalizeDailyRunTime(item.dailyRunTime),
scheduleWeekdays: normalizeScheduleWeekdays(item.scheduleWeekdays),
scheduleDateRanges: normalizeScheduleDateRanges(item.scheduleDateRanges),
repeatWindows: normalizeScheduleTimeWindows(item.repeatWindows),
repeatWindowStartTime: normalizeOptionalTimeOfDay(item.repeatWindowStartTime),
repeatWindowEndTime: normalizeOptionalTimeOfDay(item.repeatWindowEndTime),
};
@@ -386,13 +553,16 @@ function formatNextPlanScheduleRunAt(item: PlanScheduledTask) {
return '중지';
}
return formatPlanScheduleDateTime(resolveNextPlanScheduleRunAt(item));
const nextRunAt = resolveNextPlanScheduleRunAt(item);
return nextRunAt ? formatPlanScheduleDateTime(nextRunAt) : '기간 종료';
}
function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanScheduledTask[]) {
const messages: string[] = [];
const workId = draft.workId.trim();
const note = draft.note.trim();
const normalizedScheduleWeekdays = normalizeScheduleWeekdays(draft.scheduleWeekdays);
const normalizedRepeatWindows = normalizeScheduleTimeWindows(draft.repeatWindows);
if (!workId) {
messages.push('작업 ID를 입력하세요.');
@@ -404,6 +574,16 @@ function validateScheduleDraft(draft: PlanScheduledTaskDraft, items: PlanSchedul
messages.push('반복 등록할 작업 메모를 입력하세요.');
}
if (normalizedScheduleWeekdays.length > 7) {
messages.push('적용 요일은 최대 7개까지 등록할 수 있습니다.');
}
if (draft.scheduleMode === 'interval') {
if (normalizedRepeatWindows.length > 24) {
messages.push('적용 시간은 최대 24개까지 등록할 수 있습니다.');
}
}
if (!draft.enabled) {
messages.push('비활성 스케줄은 자동 등록되지 않습니다.');
}
@@ -489,10 +669,13 @@ export function PlanSchedulePage() {
repeatIntervalValue,
repeatIntervalSeconds: getRepeatIntervalSeconds(repeatIntervalValue, draft.repeatIntervalUnit),
repeatIntervalMinutes: getRepeatIntervalMinutes(repeatIntervalValue, draft.repeatIntervalUnit),
scheduleWeekdays: normalizeScheduleWeekdays(draft.scheduleWeekdays),
scheduleDateRanges: draft.scheduleMode === 'interval' ? [] : normalizeScheduleDateRanges(draft.scheduleDateRanges),
repeatWindows: draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows) : [],
repeatWindowStartTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowStartTime) : null,
draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows)[0]?.startTime ?? null : null,
repeatWindowEndTime:
draft.scheduleMode === 'interval' ? normalizeOptionalTimeOfDay(draft.repeatWindowEndTime) : null,
draft.scheduleMode === 'interval' ? normalizeScheduleTimeWindows(draft.repeatWindows)[0]?.endTime ?? null : null,
};
const saveResult = draft.id
? await updatePlanScheduledTask(draftToSave)
@@ -702,6 +885,14 @@ const PlanScheduleList = memo(function PlanScheduleList({
{item.executionMode === 'managed-service' ? '외부 서비스 관리형' : 'Codex 직접'}
</Tag>
<Tag>{formatScheduleCycle(item)}</Tag>
<Tag color={item.scheduleWeekdays.length ? 'cyan' : 'default'}>
{item.scheduleWeekdays.length ? `요일 ${buildScheduleWeekdaysSummary(item.scheduleWeekdays)}` : '매일'}
</Tag>
{item.scheduleMode === 'interval' ? (
<Tag color={item.repeatWindows.length ? 'magenta' : 'default'}>
{item.repeatWindows.length ? `시간 ${item.repeatWindows.length}` : '상시'}
</Tag>
) : null}
<Tag color="blue"> {formatNextPlanScheduleRunAt(item)}</Tag>
<Tag>{item.immediateRunEnabled ? '즉시실행' : '주기 후 실행'}</Tag>
{item.refreshContextSnapshotOnNextRun ? <Tag color="purple"> </Tag> : null}
@@ -755,6 +946,12 @@ function PlanScheduleDetail({
description={
<Space direction="vertical" size={4}>
<Text> : {formatNextPlanScheduleRunAt(selectedItem)}</Text>
<Text>
: {buildScheduleWeekdaysSummary(selectedItem.scheduleWeekdays)}
</Text>
{selectedItem.scheduleMode === 'interval' ? (
<Text> : {buildRepeatWindowsSummary(selectedItem.repeatWindows)}</Text>
) : null}
<Text> : {selectedItem.refreshContextSnapshotOnNextRun ? '다음 실행 1회' : '없음'}</Text>
<Text> : {selectedItem.executionMode === 'managed-service' ? '별도 서비스 관리형' : 'Codex 직접 처리'}</Text>
{selectedItem.executionMode === 'managed-service' ? (
@@ -913,46 +1110,64 @@ function PlanScheduleDetail({
onChange={(key) => onChangeDraft((previous) => ({ ...previous, scheduleMode: key as PlanScheduleMode }))}
/>
{draft.scheduleMode === 'daily' ? (
<Space align="center" wrap>
<Text></Text>
<Select
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Select
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Text type="secondary"> </Text>
</Space>
<>
<div className="plan-schedule-page__weekday-section">
<Text strong> </Text>
<Checkbox.Group
className="plan-schedule-page__weekday-group"
options={WEEKDAY_OPTIONS.map((item) => ({ label: item.label, value: item.value }))}
value={normalizeScheduleWeekdays(draft.scheduleWeekdays)}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
scheduleWeekdays: normalizeScheduleWeekdays(value as number[]),
}))
}
/>
<Text type="secondary"> .</Text>
</div>
<Space align="center" wrap>
<Text>{draft.scheduleWeekdays.length ? `${buildScheduleWeekdaysSummary(draft.scheduleWeekdays)} ` : '매일 '}</Text>
<Select
style={{ width: 96 }}
options={HOUR_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'hour', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Select
style={{ width: 96 }}
options={MINUTE_OPTIONS}
value={normalizeDailyRunTime(draft.dailyRunTime).split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
dailyRunTime: updateDailyRunTime(previous.dailyRunTime, 'minute', value),
repeatIntervalValue: 1,
repeatIntervalUnit: 'day',
repeatIntervalSeconds: getRepeatIntervalSeconds(1, 'day'),
repeatIntervalMinutes: getRepeatIntervalMinutes(1, 'day'),
}))
}
/>
<Text type="secondary"> </Text>
</Space>
</>
) : (
<>
<Space align="center" wrap>
@@ -1020,76 +1235,146 @@ 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}
<div className="plan-schedule-page__weekday-section" style={{ marginTop: 12 }}>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Text type="secondary"> .</Text>
</Flex>
<Checkbox.Group
className="plan-schedule-page__weekday-group"
options={WEEKDAY_OPTIONS.map((item) => ({ label: item.label, value: item.value }))}
value={normalizeScheduleWeekdays(draft.scheduleWeekdays)}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindowStartTime: updateOptionalTimeOfDay(previous.repeatWindowStartTime, 'hour', value),
scheduleWeekdays: normalizeScheduleWeekdays(value as number[]),
}))
}
/>
<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 className="plan-schedule-page__time-window-section" style={{ marginTop: 12 }}>
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Button
size="small"
icon={<PlusOutlined />}
disabled={!hasAccess}
onClick={() =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: [...previous.repeatWindows, createEmptyScheduleTimeWindow()],
}))
}
>
</Button>
</Flex>
{draft.repeatWindows.length ? (
<div className="plan-schedule-page__time-window-list">
{draft.repeatWindows.map((window, index) => (
<div key={`schedule-time-window-${index}`} className="plan-schedule-page__time-window-item">
<Text type="secondary"></Text>
<Select
allowClear
placeholder="시"
style={{ width: 88 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(window.startTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, startTime: updateOptionalTimeOfDay(item.startTime, 'hour', value) }
: item,
),
}))
}
/>
<Select
allowClear
placeholder="분"
style={{ width: 88 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(window.startTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, startTime: updateOptionalTimeOfDay(item.startTime, 'minute', value) }
: item,
),
}))
}
/>
<Text type="secondary"></Text>
<Select
allowClear
placeholder="시"
style={{ width: 88 }}
options={HOUR_OPTIONS}
value={normalizeOptionalTimeOfDay(window.endTime)?.split(':')[0]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, endTime: updateOptionalTimeOfDay(item.endTime, 'hour', value) }
: item,
),
}))
}
/>
<Select
allowClear
placeholder="분"
style={{ width: 88 }}
options={MINUTE_OPTIONS}
value={normalizeOptionalTimeOfDay(window.endTime)?.split(':')[1]}
popupClassName="plan-schedule-page__select-popup"
getPopupContainer={(triggerNode) => triggerNode.parentElement ?? document.body}
disabled={!hasAccess}
onChange={(value) =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.map((item, itemIndex) =>
itemIndex === index
? { ...item, endTime: updateOptionalTimeOfDay(item.endTime, 'minute', value) }
: item,
),
}))
}
/>
<Button
danger
size="small"
disabled={!hasAccess}
onClick={() =>
onChangeDraft((previous) => ({
...previous,
repeatWindows: previous.repeatWindows.filter((_, itemIndex) => itemIndex !== index),
}))
}
>
</Button>
</div>
))}
</div>
) : (
<Text type="secondary"> .</Text>
)}
</div>
</>
)}

View File

@@ -0,0 +1,818 @@
"use strict";
var __extends = (this && this.__extends) || (function () {
var extendStatics = function (d, b) {
extendStatics = Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== "function" && b !== null)
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
extendStatics(d, b);
function __() { this.constructor = d; }
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
};
})();
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __generator = (this && this.__generator) || function (thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.setupPlanBoard = setupPlanBoard;
exports.fetchPlanItems = fetchPlanItems;
exports.fetchPlanItemsWithLatestSourceWorks = fetchPlanItemsWithLatestSourceWorks;
exports.createPlanItem = createPlanItem;
exports.updatePlanItem = updatePlanItem;
exports.updatePlanItemJangsingProcessingRequired = updatePlanItemJangsingProcessingRequired;
exports.deletePlanItem = deletePlanItem;
exports.runPlanAction = runPlanAction;
exports.fetchPlanIssueHistories = fetchPlanIssueHistories;
exports.appendPlanIssueAction = appendPlanIssueAction;
exports.fetchPlanActionHistories = fetchPlanActionHistories;
exports.appendPlanActionHistory = appendPlanActionHistory;
exports.fetchPlanSourceWorkHistories = fetchPlanSourceWorkHistories;
exports.fetchReleaseReviewBoardItems = fetchReleaseReviewBoardItems;
exports.updatePlanReleaseReview = updatePlanReleaseReview;
exports.fetchPlanSourceWorkHistory = fetchPlanSourceWorkHistory;
exports.fetchPlanScheduledTasks = fetchPlanScheduledTasks;
exports.createPlanScheduledTask = createPlanScheduledTask;
exports.updatePlanScheduledTask = updatePlanScheduledTask;
exports.deletePlanScheduledTask = deletePlanScheduledTask;
var clientIdentity_1 = require("../../app/main/clientIdentity");
var automationTypeAccess_1 = require("../../app/main/automationTypeAccess");
var tokenAccess_1 = require("../../app/main/tokenAccess");
function resolvePlanApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function normalizePlanAutomationType(value) {
return (0, automationTypeAccess_1.normalizeAutomationTypeId)(value);
}
function resolveWorkServerFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
var hostname = window.location.hostname;
var isLocalWorkServerHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
if (!isLocalWorkServerHost) {
return null;
}
var fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
var PLAN_API_BASE_URL = resolvePlanApiBaseUrl();
var PLAN_API_FALLBACK_BASE_URL = !import.meta.env.VITE_WORK_SERVER_URL && PLAN_API_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
var PlanApiError = /** @class */ (function (_super) {
__extends(PlanApiError, _super);
function PlanApiError(message, status) {
var _this = _super.call(this, message) || this;
_this.name = 'PlanApiError';
_this.status = status;
return _this;
}
return PlanApiError;
}(Error));
function requestOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, timeoutMs, controller, timeoutId, token, response, error_1, text, payload, contentType, text;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
timeoutMs = 8000;
controller = new AbortController();
timeoutId = setTimeout(function () {
controller.abort();
}, timeoutMs);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
token = (0, tokenAccess_1.getRegisteredAccessToken)();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_1 = _e.sent();
clearTimeout(timeoutId);
if (error_1 instanceof DOMException && error_1.name === 'AbortError') {
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error_1;
case 4:
clearTimeout(timeoutId);
if (!!response.ok) return [3 /*break*/, 6];
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
try {
payload = JSON.parse(text);
throw new PlanApiError(payload.message || "요청 처리에 실패했습니다.", response.status);
}
catch (_f) {
throw new PlanApiError(text || "요청 처리에 실패했습니다.", response.status);
}
_e.label = 6;
case 6:
contentType = (_d = response.headers.get("content-type")) !== null && _d !== void 0 ? _d : "";
if (!!contentType.toLowerCase().includes("application/json")) return [3 /*break*/, 8];
return [4 /*yield*/, response.text()];
case 7:
text = _e.sent();
throw new PlanApiError(text ? "서버 응답이 JSON이 아닙니다." : "서버 응답을 확인할 수 없습니다.", 502);
case 8: return [2 /*return*/, response.json()];
}
});
});
}
function request(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_2, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestOnce(PLAN_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_2 = _a.sent();
shouldRetryWithFallback = PLAN_API_FALLBACK_BASE_URL &&
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
(error_2 instanceof PlanApiError
? error_2.status === 404 || error_2.status === 408 || error_2.status === 502
: error_2 instanceof Error && (/not found/i.test(error_2.message) || /404/.test(error_2.message)));
if (!shouldRetryWithFallback) {
throw error_2;
}
return [2 /*return*/, requestOnce(PLAN_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function requestWithMetaOnce(baseUrl, path, init) {
return __awaiter(this, void 0, void 0, function () {
var headers, hasBody, method, timeoutMs, controller, timeoutId, token, startedAt, response, error_3, text, meta, payload, contentType;
var _a, _b, _c, _d;
return __generator(this, function (_e) {
switch (_e.label) {
case 0:
headers = (0, clientIdentity_1.appendClientIdHeader)(init === null || init === void 0 ? void 0 : init.headers);
hasBody = (init === null || init === void 0 ? void 0 : init.body) !== undefined && init.body !== null;
method = (_b = (_a = init === null || init === void 0 ? void 0 : init.method) === null || _a === void 0 ? void 0 : _a.toUpperCase()) !== null && _b !== void 0 ? _b : 'GET';
timeoutMs = 8000;
controller = new AbortController();
timeoutId = setTimeout(function () {
controller.abort();
}, timeoutMs);
if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
token = (0, tokenAccess_1.getRegisteredAccessToken)();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
startedAt = performance.now();
_e.label = 1;
case 1:
_e.trys.push([1, 3, , 4]);
return [4 /*yield*/, fetch("".concat(baseUrl).concat(path), __assign(__assign({}, init), { headers: headers, signal: controller.signal, cache: (_c = init === null || init === void 0 ? void 0 : init.cache) !== null && _c !== void 0 ? _c : (method === 'GET' ? 'no-store' : undefined) }))];
case 2:
response = _e.sent();
return [3 /*break*/, 4];
case 3:
error_3 = _e.sent();
clearTimeout(timeoutId);
if (error_3 instanceof DOMException && error_3.name === 'AbortError') {
throw new PlanApiError('서버 응답이 지연됩니다. 잠시 후 다시 시도해 주세요.', 408);
}
throw error_3;
case 4:
clearTimeout(timeoutId);
return [4 /*yield*/, response.text()];
case 5:
text = _e.sent();
meta = {
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
responseBytes: new TextEncoder().encode(text).length,
};
if (!response.ok) {
try {
payload = JSON.parse(text);
throw new PlanApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
}
catch (_f) {
throw new PlanApiError(text || '요청 처리에 실패했습니다.', response.status);
}
}
contentType = (_d = response.headers.get('content-type')) !== null && _d !== void 0 ? _d : '';
if (!contentType.toLowerCase().includes('application/json')) {
throw new PlanApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
}
return [2 /*return*/, {
data: JSON.parse(text),
meta: meta,
}];
}
});
});
}
function requestWithMeta(path, init) {
return __awaiter(this, void 0, void 0, function () {
var error_4, shouldRetryWithFallback;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
_a.trys.push([0, 2, , 3]);
return [4 /*yield*/, requestWithMetaOnce(PLAN_API_BASE_URL, path, init)];
case 1: return [2 /*return*/, _a.sent()];
case 2:
error_4 = _a.sent();
shouldRetryWithFallback = PLAN_API_FALLBACK_BASE_URL &&
PLAN_API_FALLBACK_BASE_URL !== PLAN_API_BASE_URL &&
(error_4 instanceof PlanApiError
? error_4.status === 404 || error_4.status === 408 || error_4.status === 502
: error_4 instanceof Error && (/not found/i.test(error_4.message) || /404/.test(error_4.message)));
if (!shouldRetryWithFallback) {
throw error_4;
}
return [2 /*return*/, requestWithMetaOnce(PLAN_API_FALLBACK_BASE_URL, path, init)];
case 3: return [2 /*return*/];
}
});
});
}
function setupPlanBoard() {
return __awaiter(this, void 0, void 0, function () {
return __generator(this, function (_a) {
return [2 /*return*/, request('/plan/setup', {
method: 'POST',
body: JSON.stringify({}),
})];
});
});
}
function fetchPlanItems(status) {
return __awaiter(this, void 0, void 0, function () {
var query, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
void status;
query = '';
return [4 /*yield*/, request("/plan/items".concat(query))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items.map(normalizePlanItem)];
}
});
});
}
function fetchPlanItemsWithLatestSourceWorks(status) {
return __awaiter(this, void 0, void 0, function () {
var query, response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
void status;
query = '';
return [4 /*yield*/, requestWithMeta("/plan/items".concat(query))];
case 1:
response = _a.sent();
return [2 /*return*/, {
items: response.data.items.map(normalizePlanItem),
meta: response.meta,
}];
}
});
});
}
function createPlanItem(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request('/plan/items', {
method: 'POST',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function updatePlanItem(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!draft.id) {
throw new Error('수정할 작업 항목 ID가 없습니다.');
}
return [4 /*yield*/, request("/plan/items/".concat(draft.id), {
method: 'PATCH',
body: JSON.stringify({
workId: draft.workId,
note: draft.note,
automationType: draft.automationType,
automationContextIds: draft.automationContextIds,
jangsingProcessingRequired: draft.jangsingProcessingRequired,
autoDeployToMain: draft.autoDeployToMain,
suppressWebPush: draft.suppressWebPush,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function updatePlanItemJangsingProcessingRequired(id, jangsingProcessingRequired) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id), {
method: 'PATCH',
body: JSON.stringify({
jangsingProcessingRequired: jangsingProcessingRequired,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, normalizePlanItem(response.item)];
}
});
});
}
function deletePlanItem(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id), {
method: 'DELETE',
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.id];
}
});
});
}
function runPlanAction(id, action) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions/").concat(action), {
method: 'POST',
body: JSON.stringify({}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: normalizePlanItem(response.item),
message: response.message,
}];
}
});
});
}
function fetchPlanIssueHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/issues"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function appendPlanIssueAction(id_1, actionNote_1) {
return __awaiter(this, arguments, void 0, function (id, actionNote, resolve, retry) {
var response;
if (resolve === void 0) { resolve = false; }
if (retry === void 0) { retry = false; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/issues/action"), {
method: 'POST',
body: JSON.stringify({
actionNote: actionNote,
resolve: resolve,
retry: retry,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: response.item,
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
message: response.message,
}];
}
});
});
}
function fetchPlanActionHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function appendPlanActionHistory(id, actionNote) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/actions/note"), {
method: 'POST',
body: JSON.stringify({
actionNote: actionNote,
actionType: '추가조치',
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: response.item,
planItem: response.planItem ? normalizePlanItem(response.planItem) : undefined,
message: response.message,
}];
}
});
});
}
function normalizePlanItem(item) {
return __assign(__assign({}, item), { automationType: normalizePlanAutomationType(item.automationType), automationBehaviorType: (0, automationTypeAccess_1.normalizeAutomationTypeId)(item.automationBehaviorType), automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], releaseReviewNote: typeof item.releaseReviewNote === 'string' ? item.releaseReviewNote : '', usageSnapshot: normalizePlanAutomationUsageSnapshot(item.usageSnapshot) });
}
function normalizePlanAutomationUsageSnapshot(value) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r;
if (!value) {
return null;
}
return {
tokenTotals: {
total: Number((_b = (_a = value.tokenTotals) === null || _a === void 0 ? void 0 : _a.total) !== null && _b !== void 0 ? _b : 0),
input: Number((_d = (_c = value.tokenTotals) === null || _c === void 0 ? void 0 : _c.input) !== null && _d !== void 0 ? _d : 0),
output: Number((_f = (_e = value.tokenTotals) === null || _e === void 0 ? void 0 : _e.output) !== null && _f !== void 0 ? _f : 0),
cached: Number((_h = (_g = value.tokenTotals) === null || _g === void 0 ? void 0 : _g.cached) !== null && _h !== void 0 ? _h : 0),
reasoning: Number((_k = (_j = value.tokenTotals) === null || _j === void 0 ? void 0 : _j.reasoning) !== null && _k !== void 0 ? _k : 0),
},
totalTokens: Number((_l = value.totalTokens) !== null && _l !== void 0 ? _l : 0),
retryCount: Number((_m = value.retryCount) !== null && _m !== void 0 ? _m : 0),
sourceWorkCount: Number((_o = value.sourceWorkCount) !== null && _o !== void 0 ? _o : 0),
processingStartedAt: (_p = value.processingStartedAt) !== null && _p !== void 0 ? _p : null,
processingEndedAt: (_q = value.processingEndedAt) !== null && _q !== void 0 ? _q : null,
processingEndedAtSource: (_r = value.processingEndedAtSource) !== null && _r !== void 0 ? _r : null,
processingDurationSeconds: value.processingDurationSeconds === null || value.processingDurationSeconds === undefined
? null
: Number(value.processingDurationSeconds),
};
}
function normalizePlanScheduledTaskDateRanges(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map(function (item) { return ({
startDate: typeof (item === null || item === void 0 ? void 0 : item.startDate) === 'string' ? item.startDate.trim() : '',
endDate: typeof (item === null || item === void 0 ? void 0 : item.endDate) === 'string' ? item.endDate.trim() : '',
}); })
.filter(function (item) { return item.startDate && item.endDate; });
}
function normalizePlanScheduledTaskWeekdays(value) {
if (!Array.isArray(value)) {
return [];
}
return Array.from(new Set(value
.map(function (item) { return Number(item); })
.filter(function (item) { return Number.isInteger(item) && item >= 0 && item <= 6; }))).sort(function (left, right) { return left - right; });
}
function normalizePlanScheduledTaskTimeWindows(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map(function (item) { return ({
startTime: typeof (item === null || item === void 0 ? void 0 : item.startTime) === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.startTime.trim())
? item.startTime.trim()
: null,
endTime: typeof (item === null || item === void 0 ? void 0 : item.endTime) === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.endTime.trim())
? item.endTime.trim()
: null,
}); })
.filter(function (item) { return item.startTime || item.endTime; });
}
function fetchPlanSourceWorkHistories(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/source-works"))];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function fetchReleaseReviewBoardItems() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request('/plan/release-reviews')];
case 1:
response = _a.sent();
return [2 /*return*/, response.items];
}
});
});
}
function updatePlanReleaseReview(planItemId, payload) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/release-reviews/".concat(planItemId), {
method: 'PATCH',
body: JSON.stringify(payload),
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.item];
}
});
});
}
function fetchPlanSourceWorkHistory(id, sourceWorkId) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, request("/plan/items/".concat(id, "/source-works/").concat(sourceWorkId))];
case 1:
response = _a.sent();
return [2 /*return*/, response.item];
}
});
});
}
function requestPlanScheduleTask() {
return __awaiter(this, arguments, void 0, function (pathSuffix, init) {
var paths, lastNotFoundError, _i, paths_1, path, error_5;
if (pathSuffix === void 0) { pathSuffix = ''; }
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
paths = [
"/plan/scheduled-tasks".concat(pathSuffix),
"/plan/schedule/tasks".concat(pathSuffix),
"/plan/schedule".concat(pathSuffix),
"/plan/schedules".concat(pathSuffix),
"/plans/scheduled-tasks".concat(pathSuffix),
"/plans/schedule/tasks".concat(pathSuffix),
"/plans/schedule".concat(pathSuffix),
"/plans/schedules".concat(pathSuffix),
];
lastNotFoundError = null;
_i = 0, paths_1 = paths;
_a.label = 1;
case 1:
if (!(_i < paths_1.length)) return [3 /*break*/, 6];
path = paths_1[_i];
_a.label = 2;
case 2:
_a.trys.push([2, 4, , 5]);
return [4 /*yield*/, request(path, init)];
case 3: return [2 /*return*/, _a.sent()];
case 4:
error_5 = _a.sent();
if (error_5 instanceof PlanApiError && error_5.status === 404) {
lastNotFoundError = error_5;
return [3 /*break*/, 5];
}
throw error_5;
case 5:
_i++;
return [3 /*break*/, 1];
case 6: throw lastNotFoundError !== null && lastNotFoundError !== void 0 ? lastNotFoundError : new PlanApiError('스케줄 API 경로를 찾을 수 없습니다.', 404);
}
});
});
}
function fetchPlanScheduledTasks() {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask()];
case 1:
response = _a.sent();
return [2 /*return*/, response.items.map(function (item) { return (__assign(__assign({}, item), { automationType: normalizePlanAutomationType(item.automationType), automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave) })); })];
}
});
});
}
function createPlanScheduledTask(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask('', {
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,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: __assign(__assign({}, response.item), { automationType: normalizePlanAutomationType(response.item.automationType), automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave) }),
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
}];
}
});
});
}
function updatePlanScheduledTask(draft) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0:
if (!draft.id) {
throw new Error('수정할 스케줄 ID가 없습니다.');
}
return [4 /*yield*/, requestPlanScheduleTask("/".concat(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,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
})];
case 1:
response = _a.sent();
return [2 /*return*/, {
item: __assign(__assign({}, response.item), { automationType: normalizePlanAutomationType(response.item.automationType), automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map(function (value) { return String(value).trim(); }).filter(Boolean)
: [], scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays), scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges), repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows), refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun), recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave) }),
registeredPlan: response.registeredPlan,
registeredBoardPosts: Array.isArray(response.registeredBoardPosts) ? response.registeredBoardPosts : [],
}];
}
});
});
}
function deletePlanScheduledTask(id) {
return __awaiter(this, void 0, void 0, function () {
var response;
return __generator(this, function (_a) {
switch (_a.label) {
case 0: return [4 /*yield*/, requestPlanScheduleTask("/".concat(id), {
method: 'DELETE',
})];
case 1:
response = _a.sent();
return [2 /*return*/, response.id];
}
});
});
}

View File

@@ -420,6 +420,52 @@ function normalizePlanAutomationUsageSnapshot(value: PlanAutomationUsageSnapshot
} satisfies PlanAutomationUsageSnapshot;
}
function normalizePlanScheduledTaskDateRanges(value: unknown): PlanScheduledTaskDateRange[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => ({
startDate: typeof item?.startDate === 'string' ? item.startDate.trim() : '',
endDate: typeof item?.endDate === 'string' ? item.endDate.trim() : '',
}))
.filter((item) => item.startDate && item.endDate);
}
function normalizePlanScheduledTaskWeekdays(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
}
return Array.from(
new Set(
value
.map((item) => Number(item))
.filter((item) => Number.isInteger(item) && item >= 0 && item <= 6),
),
).sort((left, right) => left - right);
}
function normalizePlanScheduledTaskTimeWindows(value: unknown): PlanScheduledTaskTimeWindow[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => ({
startTime:
typeof item?.startTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.startTime.trim())
? item.startTime.trim()
: null,
endTime:
typeof item?.endTime === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(item.endTime.trim())
? item.endTime.trim()
: null,
}))
.filter((item) => item.startTime || item.endTime);
}
export async function fetchPlanSourceWorkHistories(id: number) {
const response = await request<{ items: PlanSourceWorkHistory[] }>(`/plan/items/${id}/source-works`);
return response.items;
@@ -479,6 +525,9 @@ export type PlanScheduledTask = {
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
scheduleWeekdays: number[];
scheduleDateRanges: PlanScheduledTaskDateRange[];
repeatWindows: PlanScheduledTaskTimeWindow[];
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
lastRegisteredAt: string | null;
@@ -486,6 +535,16 @@ export type PlanScheduledTask = {
updatedAt: string;
};
export type PlanScheduledTaskDateRange = {
startDate: string;
endDate: string;
};
export type PlanScheduledTaskTimeWindow = {
startTime: string | null;
endTime: string | null;
};
export type PlanScheduleExecutionMode = 'codex' | 'managed-service';
export type PlanScheduleMode = 'interval' | 'daily';
export type PlanScheduleRepeatUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month';
@@ -511,6 +570,9 @@ export type PlanScheduledTaskDraft = {
repeatIntervalSeconds: number;
repeatIntervalMinutes: number;
dailyRunTime: string;
scheduleWeekdays: number[];
scheduleDateRanges: PlanScheduledTaskDateRange[];
repeatWindows: PlanScheduledTaskTimeWindow[];
repeatWindowStartTime: string | null;
repeatWindowEndTime: string | null;
};
@@ -559,6 +621,9 @@ export async function fetchPlanScheduledTasks() {
automationContextIds: Array.isArray(item.automationContextIds)
? item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(item.recreateManagedServiceOnNextSave),
}));
@@ -592,6 +657,9 @@ export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
@@ -604,6 +672,9 @@ export async function createPlanScheduledTask(draft: PlanScheduledTaskDraft) {
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},
@@ -644,6 +715,9 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
repeatIntervalSeconds: draft.repeatIntervalSeconds,
repeatIntervalMinutes: draft.repeatIntervalMinutes,
dailyRunTime: draft.dailyRunTime,
scheduleWeekdays: draft.scheduleWeekdays,
scheduleDateRanges: draft.scheduleDateRanges,
repeatWindows: draft.repeatWindows,
repeatWindowStartTime: draft.repeatWindowStartTime,
repeatWindowEndTime: draft.repeatWindowEndTime,
}),
@@ -656,6 +730,9 @@ export async function updatePlanScheduledTask(draft: PlanScheduledTaskDraft) {
automationContextIds: Array.isArray(response.item.automationContextIds)
? response.item.automationContextIds.map((value) => String(value).trim()).filter(Boolean)
: [],
scheduleWeekdays: normalizePlanScheduledTaskWeekdays(response.item.scheduleWeekdays),
scheduleDateRanges: normalizePlanScheduledTaskDateRanges(response.item.scheduleDateRanges),
repeatWindows: normalizePlanScheduledTaskTimeWindows(response.item.repeatWindows),
refreshContextSnapshotOnNextRun: Boolean(response.item.refreshContextSnapshotOnNextRun),
recreateManagedServiceOnNextSave: Boolean(response.item.recreateManagedServiceOnNextSave),
},

View File

@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.maskNotePreviewByWord = maskNotePreviewByWord;
function maskNotePreviewByWord(note) {
var trimmed = note.trim();
if (!trimmed) {
return '요청 내용이 마스킹되었습니다.';
}
return note
.split(/(\s+)/)
.map(function (segment) {
if (!segment || /\s+/.test(segment)) {
return segment;
}
if (segment.length === 1) {
return '*';
}
var visiblePrefixLength = segment.length >= 4 ? 1 : 0;
var visibleSuffixLength = segment.length >= 6 ? 1 : 0;
var maxMaskLength = Math.max(1, segment.length - visiblePrefixLength - visibleSuffixLength);
var maskLength = Math.min(maxMaskLength, Math.max(1, Math.ceil(segment.length * 0.4)));
var maskStart = Math.max(visiblePrefixLength, Math.floor((segment.length - maskLength) / 2));
var maskEnd = Math.min(segment.length - visibleSuffixLength, maskStart + maskLength);
return "".concat(segment.slice(0, maskStart)).concat('*'.repeat(maskEnd - maskStart)).concat(segment.slice(maskEnd));
})
.join('');
}

View File

@@ -1,14 +1,22 @@
.plan-board-page {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.plan-board-page__overview,
.plan-board-page__list-card,
.plan-board-page__chart-card,
.plan-board-page__editor-card {
display: flex;
flex-direction: column;
min-height: 0;
border: 0;
border-radius: 20px;
box-shadow: none;
@@ -16,9 +24,12 @@
.plan-board-page__split {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
align-items: stretch;
}
.plan-board-page__split--stacked {
@@ -28,7 +39,12 @@
.plan-board-page__list-card .ant-card-body,
.plan-board-page__editor-card .ant-card-body,
.plan-board-page__detail-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.plan-board-page__detail-actions.ant-space {
@@ -122,8 +138,14 @@
.plan-board-page__list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-right: 4px;
}
.plan-board-page__list-filter-bar {

View File

@@ -218,6 +218,67 @@
width: 100%;
}
.plan-schedule-page__date-range-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__date-range-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__time-window-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__time-window-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__weekday-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-schedule-page__weekday-group.ant-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.plan-schedule-page__weekday-group .ant-checkbox-wrapper {
margin-inline-start: 0;
}
.plan-schedule-page__date-range-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.plan-schedule-page__time-window-item {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.plan-schedule-page__date-range-item .ant-input {
min-height: 42px;
border-radius: 12px;
border-color: rgba(22, 93, 255, 0.14);
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.plan-schedule-page__overlay {
position: fixed;
inset: 0;
@@ -298,6 +359,14 @@
border-radius: 16px;
}
.plan-schedule-page__date-range-item {
grid-template-columns: minmax(0, 1fr);
}
.plan-schedule-page__time-window-item {
align-items: stretch;
}
.plan-schedule-page__overlay-card .ant-card-body {
padding: 14px 14px 18px;
}

View File

@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isWorkingPlanItem = isWorkingPlanItem;
exports.normalizeWorkerStatus = normalizeWorkerStatus;
exports.isReleasePendingMainItem = isReleasePendingMainItem;
exports.isAutomationFailedItem = isAutomationFailedItem;
exports.getPlanQuickFilterLabel = getPlanQuickFilterLabel;
var MAIN_PENDING_WORKER_STATUSES = new Set(['main반영대기', 'main반영중', 'main반영실패']);
var AUTOMATION_FAILURE_WORKER_STATUSES = new Set([
'브랜치실패',
'자동작업실패',
'release반영실패',
'main반영실패',
]);
function isWorkingPlanItem(item) {
return item.status === '작업중';
}
function normalizeWorkerStatus(workerStatus) {
var _a;
return (_a = workerStatus === null || workerStatus === void 0 ? void 0 : workerStatus.replace(/\s+/g, '')) !== null && _a !== void 0 ? _a : '';
}
function isReleasePendingMainItem(item) {
var normalizedWorkerStatus = normalizeWorkerStatus(item.workerStatus);
if (MAIN_PENDING_WORKER_STATUSES.has(normalizedWorkerStatus)) {
return true;
}
return item.status === '릴리즈완료';
}
function isAutomationFailedItem(item) {
return AUTOMATION_FAILURE_WORKER_STATUSES.has(normalizeWorkerStatus(item.workerStatus));
}
function getPlanQuickFilterLabel(filter) {
if (filter === 'working') {
return '현재 작업중';
}
if (filter === 'release-pending-main') {
return '현재 release 상태';
}
if (filter === 'automation-failed') {
return '현재 자동화 실패';
}
return null;
}

View File

@@ -0,0 +1,6 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLAN_RELEASE_REVIEW_STATUSES = exports.PLAN_FILTER_STATUSES = exports.PLAN_STATUSES = void 0;
exports.PLAN_STATUSES = ['등록', '작업중', '작업완료', '릴리즈완료', '완료'];
exports.PLAN_FILTER_STATUSES = ['all', 'in-progress', 'done', 'error'];
exports.PLAN_RELEASE_REVIEW_STATUSES = ['pending', 'reviewing', 'approved', 'changes-requested'];

View File

@@ -346,6 +346,21 @@ export function ServerCommandPage() {
label: '확인시각',
children: formatDateTime(item.checkedAt),
},
{
key: 'latest-source-change-at',
label: '소스 수정일',
children: formatDateTime(item.latestSourceChangeAt),
},
{
key: 'latest-source-change-path',
label: '최근 소스 경로',
children: item.latestSourceChangePath?.trim() || '-',
},
{
key: 'latest-build-at',
label: '최신 빌드',
children: formatDateTime(item.latestBuiltAt),
},
{
key: 'content-type',
label: 'Content-Type',
@@ -401,6 +416,12 @@ export function ServerCommandPage() {
</Text>
) : null}
{item.updateSummary ? (
<Text type="secondary" className="server-command-page__preview">
{item.updateSummary}
</Text>
) : null}
{item.responsePreview ? (
<Text type="secondary" className="server-command-page__preview">
{item.responsePreview}

Some files were not shown because too many files have changed in this diff Show More