feat: update main chat and system chat UI

This commit is contained in:
2026-05-25 17:26:37 +09:00
parent fb5ec649cd
commit f59522ffc4
120 changed files with 43262 additions and 3325 deletions

View File

@@ -26,13 +26,17 @@ function retryChunkLoadOnce(errorMessage: string) {
return false; return false;
} }
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') { try {
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
return false;
}
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
window.location.reload();
return true;
} catch {
return false; return false;
} }
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
window.location.reload();
return true;
} }
function App() { function App() {

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import { MainLayout } from './layout/MainLayout'; import { MainLayout } from './layout/MainLayout';
import { ApisPage } from './pages/ApisPage'; import { ApisPage } from './pages/ApisPage';
import { ChatPage } from './pages/ChatPage'; import { ChatPage } from './pages/ChatPage';
import { ChatSharePage } from './pages/ChatSharePage';
import { DocsPage } from './pages/DocsPage'; import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage'; import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage'; import { PlayPage } from './pages/PlayPage';
@@ -10,6 +11,8 @@ import { buildChatPath, buildDocsPath } from './routes';
export function AppShell() { export function AppShell() {
return ( return (
<Routes> <Routes>
<Route path="/chat-share/:token" element={<ChatSharePage />} />
<Route path="/chat/share/:token" element={<ChatSharePage />} />
<Route path="/" element={<MainLayout />}> <Route path="/" element={<MainLayout />}>
<Route index element={<Navigate to={buildChatPath('live')} replace />} /> <Route index element={<Navigate to={buildChatPath('live')} replace />} />
<Route path="docs/:folder" element={<DocsPage />} /> <Route path="docs/:folder" element={<DocsPage />} />
@@ -17,6 +20,8 @@ export function AppShell() {
<Route path="plans/:section" element={<PlansPage />} /> <Route path="plans/:section" element={<PlansPage />} />
<Route path="chat/:section" element={<ChatPage />} /> <Route path="chat/:section" element={<ChatPage />} />
<Route path="play/layout" element={<PlayPage />} /> <Route path="play/layout" element={<PlayPage />} />
<Route path="play/draw" element={<PlayPage />} />
<Route path="play/apps" element={<PlayPage />} />
<Route path="play/test" element={<PlayPage />} /> <Route path="play/test" element={<PlayPage />} />
<Route path="play/cbt" element={<PlayPage />} /> <Route path="play/cbt" element={<PlayPage />} />
<Route path="play/layout-record/:layoutId" element={<PlayPage />} /> <Route path="play/layout-record/:layoutId" element={<PlayPage />} />

View File

@@ -1,6 +1,6 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons'; import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd'; import { Alert, Button, Card, Empty, Form, Input, List, Modal, Space, Switch, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
deleteAutomationContext, deleteAutomationContext,
@@ -8,6 +8,7 @@ import {
upsertAutomationContext, upsertAutomationContext,
useAutomationContextRegistry, useAutomationContextRegistry,
} from './automationContextAccess'; } from './automationContextAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import './AutomationContextManagementPage.css'; import './AutomationContextManagementPage.css';
@@ -51,6 +52,8 @@ export function AutomationContextManagementPage() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationContextFormValue>(); const [form] = Form.useForm<AutomationContextFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const selectedAutomationContext = useMemo( const selectedAutomationContext = useMemo(
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null, () => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
@@ -67,12 +70,20 @@ export function AutomationContextManagementPage() {
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return; return;
} }
const nextFormKey = isCreating ? '__create__' : selectedAutomationContext?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields(); form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext)); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
}, [detailMode, form, isCreating, selectedAutomationContext]); }, [detailMode, form, isCreating, selectedAutomationContext?.id]);
const openCreateForm = () => { const openCreateForm = () => {
setIsCreating(true); setIsCreating(true);
@@ -98,7 +109,14 @@ export function AutomationContextManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) { const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedAutomationContext.title}" Context를 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -122,19 +140,23 @@ export function AutomationContextManagementPage() {
if (!hasAccess) { if (!hasAccess) {
return ( return (
<Card title="Context 관리" className="chat-type-management-page"> <>
<Alert {modalContextHolder}
showIcon <Card title="Context 관리" className="chat-type-management-page">
type="warning" <Alert
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다." showIcon
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요." type="warning"
/> message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
</Card> description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
/>
</Card>
</>
); );
} }
return ( return (
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}> <div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
{modalContextHolder}
{detailMode === 'list' ? ( {detailMode === 'list' ? (
<Card <Card
title="Context 관리" title="Context 관리"

View File

@@ -7,8 +7,8 @@ import {
ShrinkOutlined, ShrinkOutlined,
UnorderedListOutlined, UnorderedListOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd'; import { Alert, Button, Card, Empty, Form, Input, List, Modal, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
deleteAutomationType, deleteAutomationType,
@@ -17,6 +17,7 @@ import {
type AutomationBehaviorType, type AutomationBehaviorType,
type AutomationTypeRecord, type AutomationTypeRecord,
} from './automationTypeAccess'; } from './automationTypeAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import './AutomationTypeManagementPage.css'; import './AutomationTypeManagementPage.css';
@@ -63,6 +64,8 @@ export function AutomationTypeManagementPage() {
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [form] = Form.useForm<AutomationTypeFormValue>(); const [form] = Form.useForm<AutomationTypeFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const isPaneMaximized = maximizedPane !== 'none'; const isPaneMaximized = maximizedPane !== 'none';
const selectedAutomationType = useMemo( const selectedAutomationType = useMemo(
@@ -80,12 +83,20 @@ export function AutomationTypeManagementPage() {
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return; return;
} }
const nextFormKey = isCreating ? '__create__' : selectedAutomationType?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields(); form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType)); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
}, [detailMode, form, isCreating, selectedAutomationType]); }, [detailMode, form, isCreating, selectedAutomationType?.id]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
@@ -150,7 +161,14 @@ export function AutomationTypeManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) { const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -209,14 +227,17 @@ export function AutomationTypeManagementPage() {
if (!hasAccess) { if (!hasAccess) {
return ( return (
<Card title="자동화 유형 관리" className="chat-type-management-page"> <>
<Alert {modalContextHolder}
showIcon <Card title="자동화 유형 관리" className="chat-type-management-page">
type="warning" <Alert
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다." showIcon
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요." type="warning"
/> message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
</Card> description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
/>
</Card>
</>
); );
} }
@@ -226,6 +247,7 @@ export function AutomationTypeManagementPage() {
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : '' isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`} }${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
> >
{modalContextHolder}
{detailMode === 'list' ? ( {detailMode === 'list' ? (
<Card <Card
title="자동화 유형 관리" title="자동화 유형 관리"

View File

@@ -10,8 +10,8 @@ import {
ShrinkOutlined, ShrinkOutlined,
UnorderedListOutlined, UnorderedListOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd'; import { Alert, Button, Card, Empty, Form, Input, List, Modal, Segmented, Space, Switch, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
@@ -22,6 +22,7 @@ import {
useChatContextSettingsRegistry, useChatContextSettingsRegistry,
type ChatDefaultContextRecord, type ChatDefaultContextRecord,
} from './chatContextSettingsAccess'; } from './chatContextSettingsAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import './ChatDefaultContextManagementPage.css'; import './ChatDefaultContextManagementPage.css';
@@ -78,6 +79,8 @@ export function ChatDefaultContextManagementPage() {
const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [isReloading, setIsReloading] = useState(false); const [isReloading, setIsReloading] = useState(false);
const [form] = Form.useForm<ChatDefaultContextFormValue>(); const [form] = Form.useForm<ChatDefaultContextFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const selectedContext = useMemo( const selectedContext = useMemo(
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null, () => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
@@ -113,12 +116,20 @@ export function ChatDefaultContextManagementPage() {
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return; return;
} }
const nextFormKey = isCreating ? '__create__' : selectedContext?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields(); form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedContext)); form.setFieldsValue(toFormValue(isCreating ? null : selectedContext));
}, [detailMode, form, isCreating, selectedContext]); }, [detailMode, form, isCreating, selectedContext?.id]);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -227,7 +238,14 @@ export function ChatDefaultContextManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedContext.title}" 공통 문맥을 삭제할까요?`)) { const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedContext.title}" 공통 문맥을 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -249,14 +267,17 @@ export function ChatDefaultContextManagementPage() {
if (!hasAccess) { if (!hasAccess) {
return ( return (
<Card title="공통 문맥 관리" className="chat-type-management-page"> <>
<Alert {modalContextHolder}
showIcon <Card title="공통 문맥 관리" className="chat-type-management-page">
type="warning" <Alert
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다." showIcon
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공통 문맥을 관리하세요." type="warning"
/> message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
</Card> description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공통 문맥을 관리하세요."
/>
</Card>
</>
); );
} }
@@ -268,6 +289,7 @@ export function ChatDefaultContextManagementPage() {
isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : '' isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''
}`} }`}
> >
{modalContextHolder}
{detailMode === 'list' ? ( {detailMode === 'list' ? (
<Card <Card
title="공통 문맥 관리" title="공통 문맥 관리"

View File

@@ -1,5 +1,4 @@
// @ts-nocheck // @ts-nocheck
import { useEffect, useRef } from 'react';
import { useAppConfig } from './appConfig'; import { useAppConfig } from './appConfig';
import { isPreviewRuntime } from './previewRuntime'; import { isPreviewRuntime } from './previewRuntime';
import { getOrCreateClientId } from './clientIdentity'; import { getOrCreateClientId } from './clientIdentity';
@@ -10,9 +9,9 @@ import {
showLocalClientNotification, showLocalClientNotification,
} from './notificationApi'; } from './notificationApi';
import { chatGateway } from './chatV2'; import { chatGateway } from './chatV2';
import { resolveChatPathForSession } from './isolatedChatRooms';
import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types'; import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types';
const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000;
const MAX_NOTIFICATION_DETAIL_POLLS = 3; const MAX_NOTIFICATION_DETAIL_POLLS = 3;
function createConversationPreviewText(text: string) { function createConversationPreviewText(text: string) {
@@ -60,7 +59,7 @@ function buildChatNotificationLink(sessionId: string) {
return ''; return '';
} }
const targetUrl = new URL('/chat/live', window.location.origin); const targetUrl = new URL(resolveChatPathForSession(normalizedSessionId), window.location.origin);
targetUrl.searchParams.set('topMenu', 'chat'); targetUrl.searchParams.set('topMenu', 'chat');
targetUrl.searchParams.set('sessionId', normalizedSessionId); targetUrl.searchParams.set('sessionId', normalizedSessionId);
return targetUrl.toString(); return targetUrl.toString();
@@ -199,229 +198,10 @@ function selectNotificationPollingCandidates<
export function ChatNotificationBridgeV2() { export function ChatNotificationBridgeV2() {
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const notifiedFailedJobKeysRef = useRef<string[]>([]);
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
const lastFailedRequestKeyBySessionRef = useRef<Record<string, string>>({});
const createChatNotification = ({
targetSessionId,
conversationTitle,
title,
body,
previewText,
priority,
metadata,
}: {
targetSessionId: string;
conversationTitle?: string | null;
title: string;
body: string;
previewText?: string;
priority: 'normal' | 'high';
metadata?: Record<string, unknown>;
}) => {
const resolvedConversationTitle = conversationTitle || '현재 채팅방';
const linkUrl = buildChatNotificationLink(targetSessionId);
const notificationData = {
category: 'chat',
priority,
suppressIfVisible: 'true',
sessionId: targetSessionId,
conversationTitle: resolvedConversationTitle,
targetUrl: linkUrl,
linkUrl,
...metadata,
};
const serializedNotificationData = Object.fromEntries(
Object.entries(notificationData).flatMap(([key, value]) => {
if (value == null) {
return [];
}
return [[key, String(value)]];
}),
);
const pushPayload = {
title,
body,
threadId: `chat:${targetSessionId}`,
data: serializedNotificationData,
targetClientIds: (() => {
const clientId = getOrCreateClientId().trim();
return clientId ? [clientId] : undefined;
})(),
};
if (shouldSuppressChatNotificationWhenAppOpen()) {
return Promise.resolve(undefined);
}
return Promise.allSettled([
createNotificationMessage({
title,
body,
category: 'chat',
source: 'codex-live',
priority,
metadata: {
...notificationData,
previewText,
linkLabel: '채팅 바로 열기',
},
}),
sendClientNotification(pushPayload),
])
.then(async ([storedResult, pushResult]) => {
if (pushResult.status === 'rejected') {
await tryShowLocalChatNotification(pushPayload);
} else if (shouldFallbackToLocalNotification(pushResult.value)) {
await tryShowLocalChatNotification(pushPayload);
}
if (storedResult.status === 'fulfilled') {
return storedResult.value;
}
if (pushResult.status === 'fulfilled') {
return pushResult.value;
}
throw storedResult.reason;
})
.catch(() => undefined);
};
if (isPreviewRuntime() || !appConfig.chat.receiveRoomNotifications) { if (isPreviewRuntime() || !appConfig.chat.receiveRoomNotifications) {
return null; return null;
} }
useEffect(() => {
let cancelled = false;
const pollNotifications = async () => {
if (cancelled || !shouldPollConversationNotifications()) {
return;
}
try {
const conversations = await chatGateway.listConversations();
if (cancelled) {
return;
}
const candidates = selectNotificationPollingCandidates(conversations);
await Promise.all(
candidates.map(async (conversation) => {
const detail = await chatGateway.getConversationDetail(conversation.sessionId, { limit: 40 });
if (cancelled) {
return;
}
const latestCodexMessage = findLatestCodexMessage(detail.messages);
const latestCodexMessageId = latestCodexMessage?.id ?? 0;
const previousCodexMessageId = lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId];
const questionText = findQuestionText(detail.messages, latestCodexMessage?.clientRequestId);
const latestFailedRequest = findLatestFailedRequest(detail.requests);
const failedRequestKey = latestFailedRequest
? `${latestFailedRequest.requestId}:${latestFailedRequest.updatedAt}:${latestFailedRequest.status}`
: '';
const previousFailedRequestKey = lastFailedRequestKeyBySessionRef.current[conversation.sessionId] ?? '';
lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId] = latestCodexMessageId;
lastFailedRequestKeyBySessionRef.current[conversation.sessionId] = failedRequestKey;
if (!shouldNotifyWhileAway()) {
return;
}
if (
latestFailedRequest &&
failedRequestKey &&
previousFailedRequestKey &&
previousFailedRequestKey !== failedRequestKey
) {
const notificationKey = `failed:${conversation.sessionId}:${failedRequestKey}`;
if (!notifiedFailedJobKeysRef.current.includes(notificationKey)) {
notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80);
const failureDetail =
normalizeNotificationDetailText(latestFailedRequest.statusMessage) ||
normalizeNotificationDetailText(latestFailedRequest.responseText) ||
'요청이 실패했습니다.';
await createChatNotification({
targetSessionId: conversation.sessionId,
conversationTitle: conversation.title,
title: `${conversation.title || '현재 채팅방'} 실행 실패`,
body: createChatQuestionAnswerNotificationBody({
questionText: latestFailedRequest.userText,
answerText: failureDetail,
fallback: `${conversation.title || '현재 채팅방'}에서 실행이 실패했습니다.`,
}),
previewText: createChatQuestionOnlyNotificationPreview(
latestFailedRequest.userText,
`${conversation.title || '현재 채팅방'} 실행 실패`,
),
priority: 'high',
metadata: {
type: 'chat-request-failed',
requestId: latestFailedRequest.requestId,
questionText: latestFailedRequest.userText,
answerText: failureDetail,
},
});
}
}
if (
!conversation.hasUnreadResponse ||
!latestCodexMessage ||
latestCodexMessageId <= 0 ||
!previousCodexMessageId ||
latestCodexMessageId === previousCodexMessageId
) {
return;
}
await createChatNotification({
targetSessionId: conversation.sessionId,
conversationTitle: conversation.title,
title: `${conversation.title || '현재 채팅방'} 새 답변`,
body: createChatQuestionAnswerNotificationBody({
questionText,
answerText: latestCodexMessage.text,
fallback: `${conversation.title || '현재 채팅방'}에 새 답변이 도착했습니다.`,
}),
previewText: createChatQuestionOnlyNotificationPreview(questionText, `${conversation.title || '현재 채팅방'} 새 답변`),
priority: 'normal',
metadata: {
type: 'chat-response',
requestId: latestCodexMessage.clientRequestId ?? '',
questionText,
answerText: latestCodexMessage.text,
},
});
}),
);
} catch {
// Ignore polling errors and retry on the next interval.
}
};
void pollNotifications();
const timer = window.setInterval(() => {
void pollNotifications();
}, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [appConfig.chat.receiveRoomNotifications]);
return null; return null;
} }

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchServerCommands } from '../../features/serverCommand/api'; import { fetchServerCommands } from '../../features/serverCommand/api';
import type { ServerCommandItem } from '../../features/serverCommand/types'; import type { ServerCommandItem } from '../../features/serverCommand/types';
import { CodexDiffBlock, parseCodexDiffSections } from '../../components/previewer';
import { fetchChatSourceChanges } from './mainChatPanel'; import { fetchChatSourceChanges } from './mainChatPanel';
import { ChatPromptCard } from './mainChatPanel/ChatPromptCard'; import { ChatPromptCard } from './mainChatPanel/ChatPromptCard';
import { extractChatMessageParts } from './mainChatPanel/messageParts'; import { extractChatMessageParts } from './mainChatPanel/messageParts';
@@ -87,6 +88,17 @@ function hasMeaningfulSourceArtifacts(entry: Pick<ChatSourceChangeSnapshot, 'cha
); );
} }
function combineCodexDiffBlocks(diffBlocks: string[]) {
const normalizedBlocks = diffBlocks.map((block) => block.trim()).filter(Boolean);
const diffText = normalizedBlocks.join('\n\n');
const fileCount = normalizedBlocks.reduce((count, block) => count + parseCodexDiffSections(block).length, 0);
return {
diffText,
fileCount,
};
}
function buildFeatureKeyCandidate(value: string) { function buildFeatureKeyCandidate(value: string) {
return value return value
.trim() .trim()
@@ -247,9 +259,18 @@ export function ChatSourceChangesPage() {
setSelectedEntryId(null); setSelectedEntryId(null);
try { try {
const serverCommands = await fetchServerCommands(); let testServerCommand: ServerCommandItem | null = null;
const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null; let nextLatestTestServerBuiltAt: string | null = null;
const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null;
try {
const serverCommands = await fetchServerCommands();
testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null;
nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null;
} catch {
testServerCommand = null;
nextLatestTestServerBuiltAt = null;
}
const sourceChanges = await fetchChatSourceChanges(300); const sourceChanges = await fetchChatSourceChanges(300);
if (cancelled) { if (cancelled) {
@@ -796,17 +817,19 @@ export function ChatSourceChangesPage() {
</Card> </Card>
<Card size="small" title={`변경 소스 / diff (${selectedEntry.diffBlocks.length})`}> <Card size="small" title={`변경 소스 / diff (${selectedEntry.diffBlocks.length})`}>
{selectedEntry.diffBlocks.length ? ( {(() => {
<Space direction="vertical" size={12} style={{ width: '100%' }}> if (!selectedEntry.diffBlocks.length) {
{selectedEntry.diffBlocks.map((block, index) => ( return <Text type="secondary"> diff . .</Text>;
<pre key={`diff:${selectedEntry.id}:${index}`} className="chat-source-changes-page__diff"> }
<code>{block}</code>
</pre> const combinedDiff = combineCodexDiffBlocks(selectedEntry.diffBlocks);
))} return (
</Space> <CodexDiffBlock
) : ( diffText={combinedDiff.diffText}
<Text type="secondary"> diff . .</Text> summary={`파일 ${combinedDiff.fileCount}개 diff preview`}
)} />
);
})()}
</Card> </Card>
</Space> </Space>
) : ( ) : (

View File

@@ -25,17 +25,22 @@ import {
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
Modal,
Select,
} from 'antd'; } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { MarkdownPreviewContent } from '../../components/markdownPreview';
import { import {
canUseChatType, canUseChatType,
CHAT_PERMISSION_ROLE_LABELS, CHAT_PERMISSION_ROLE_LABELS,
createDefaultChatTypeExecutionPolicy,
deleteChatType, deleteChatType,
resolveCurrentChatPermissionRoles, resolveCurrentChatPermissionRoles,
upsertChatType, upsertChatType,
useChatTypeRegistry, useChatTypeRegistry,
type ChatPermissionRole, type ChatPermissionRole,
type ChatTypeExecutionMode,
type ChatTypeExecutionPolicy,
type ChatTypeRecord, type ChatTypeRecord,
} from './chatTypeAccess'; } from './chatTypeAccess';
import { import {
@@ -43,6 +48,7 @@ import {
upsertChatTypeDefaultContextSelection, upsertChatTypeDefaultContextSelection,
useChatContextSettingsRegistry, useChatContextSettingsRegistry,
} from './chatContextSettingsAccess'; } from './chatContextSettingsAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import { useTokenAccess } from './tokenAccess'; import { useTokenAccess } from './tokenAccess';
import './ChatTypeManagementPage.css'; import './ChatTypeManagementPage.css';
@@ -53,6 +59,7 @@ type ChatTypeFormValue = {
name: string; name: string;
sortOrder: number; sortOrder: number;
description: string; description: string;
executionPolicy: ChatTypeExecutionPolicy;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled: boolean; enabled: boolean;
}; };
@@ -61,6 +68,7 @@ const EMPTY_FORM_VALUE: ChatTypeFormValue = {
name: '', name: '',
sortOrder: 1, sortOrder: 1,
description: '', description: '',
executionPolicy: createDefaultChatTypeExecutionPolicy(),
permissions: ['token-user'], permissions: ['token-user'],
enabled: true, enabled: true,
}; };
@@ -75,11 +83,24 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
name: chatType.name, name: chatType.name,
sortOrder: chatType.sortOrder, sortOrder: chatType.sortOrder,
description: chatType.description, description: chatType.description,
executionPolicy: chatType.executionPolicy,
permissions: chatType.permissions, permissions: chatType.permissions,
enabled: chatType.enabled, enabled: chatType.enabled,
}; };
} }
function resolveExecutionPolicyModeLabel(mode: ChatTypeExecutionMode) {
if (mode === 'summary-free-talking') {
return '회의 기록자 + 프리토킹';
}
if (mode === 'dispatcher-workers') {
return '중계 지시자 + 실작업자';
}
return '직접 구성';
}
export function ChatTypeManagementPage() { export function ChatTypeManagementPage() {
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry(); const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry();
@@ -101,6 +122,8 @@ export function ChatTypeManagementPage() {
const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]); const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
const [form] = Form.useForm<ChatTypeFormValue>(); const [form] = Form.useForm<ChatTypeFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
const isPaneMaximized = maximizedPane !== 'none'; const isPaneMaximized = maximizedPane !== 'none';
const builtInChatTypes: ChatTypeRecord[] = []; const builtInChatTypes: ChatTypeRecord[] = [];
@@ -125,9 +148,17 @@ export function ChatTypeManagementPage() {
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return; return;
} }
const nextFormKey = isCreating ? '__create__' : selectedChatType?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields(); form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
setSelectedDefaultContextIds( setSelectedDefaultContextIds(
@@ -137,7 +168,7 @@ export function ChatTypeManagementPage() {
defaultContexts.some((context) => context.id === contextId && context.enabled), defaultContexts.some((context) => context.id === contextId && context.enabled),
), ),
); );
}, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]); }, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType?.id]);
useEffect(() => { useEffect(() => {
if (detailMode !== 'detail') { if (detailMode !== 'detail') {
@@ -221,7 +252,14 @@ export function ChatTypeManagementPage() {
return; return;
} }
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) { const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedChatType.name}" 요청 유형을 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return; return;
} }
@@ -351,14 +389,17 @@ export function ChatTypeManagementPage() {
if (!hasAccess) { if (!hasAccess) {
return ( return (
<Card title="채팅유형 관리" className="chat-type-management-page"> <>
<Alert {modalContextHolder}
showIcon <Card title="채팅유형 관리" className="chat-type-management-page">
type="warning" <Alert
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다." showIcon
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요." type="warning"
/> message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
</Card> description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
/>
</Card>
</>
); );
} }
@@ -368,6 +409,7 @@ export function ChatTypeManagementPage() {
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : '' isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`} }${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
> >
{modalContextHolder}
{detailMode === 'list' ? ( {detailMode === 'list' ? (
<Card <Card
title="채팅유형 관리" title="채팅유형 관리"
@@ -480,6 +522,7 @@ export function ChatTypeManagementPage() {
{item.permissions.map((permission) => ( {item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag> <Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
))} ))}
<Tag color="purple">{resolveExecutionPolicyModeLabel(item.executionPolicy.mode)}</Tag>
{linkedDefaultContexts.map((context) => ( {linkedDefaultContexts.map((context) => (
<Tag key={`${item.id}-${context.id}`} color="gold"> <Tag key={`${item.id}-${context.id}`} color="gold">
{context.title} {context.title}
@@ -611,6 +654,75 @@ export function ChatTypeManagementPage() {
<Switch checkedChildren="사용" unCheckedChildren="중지" /> <Switch checkedChildren="사용" unCheckedChildren="중지" />
</Form.Item> </Form.Item>
</div> </div>
<div className={`chat-type-management-page__meta-grid${isPaneMaximized ? ' chat-type-management-page__meta-grid--hidden' : ''}`}>
<Form.Item
className="chat-type-management-page__meta-item"
label="실행 정책"
name={['executionPolicy', 'mode']}
>
<Select
options={[
{ value: 'default', label: '직접 구성 · 참가자 역할을 그대로 사용' },
{ value: 'summary-free-talking', label: '회의 기록자 1명 + 프리토킹 Codex' },
{ value: 'dispatcher-workers', label: '중계 지시자 1명 + 실작업자' },
]}
onChange={(value) => {
const nextPolicy = createDefaultChatTypeExecutionPolicy(value as ChatTypeExecutionMode);
const currentPolicy = form.getFieldValue('executionPolicy') as ChatTypeExecutionPolicy;
form.setFieldsValue({
executionPolicy: {
...nextPolicy,
reviewPolicy:
value === 'dispatcher-workers' ? currentPolicy?.reviewPolicy ?? nextPolicy.reviewPolicy : 'self',
resourceReportPolicy: currentPolicy?.resourceReportPolicy ?? nextPolicy.resourceReportPolicy,
},
});
}}
/>
</Form.Item>
<Form.Item shouldUpdate noStyle>
{({ getFieldValue }) => {
const executionMode = (getFieldValue(['executionPolicy', 'mode']) as ChatTypeExecutionMode) ?? 'default';
return (
<>
<Form.Item className="chat-type-management-page__meta-item" label="참가자 자동 배치" name={['executionPolicy', 'participantBinding']}>
<Select
options={[
{ value: 'manual', label: '수동 · 참가자 역할을 그대로 사용' },
{ value: 'first-moderator-rest-conversation', label: '첫 참가자 중재, 나머지 프리토킹/실작업' },
{ value: 'first-moderator-rest-conversation-last-reviewer', label: '첫 참가자 중재, 마지막 참가자 검토, 나머지 실작업' },
]}
/>
</Form.Item>
<Form.Item className="chat-type-management-page__meta-item" label="최종 검토" name={['executionPolicy', 'reviewPolicy']}>
<Select
disabled={executionMode !== 'dispatcher-workers'}
options={[
{ value: 'self', label: '중재자가 직접 검토 후 종합' },
{ value: 'reviewer', label: '별도 검토자 지정 후 중재자가 종합' },
]}
/>
</Form.Item>
<Form.Item className="chat-type-management-page__meta-item" label="결과물 보고" name={['executionPolicy', 'resourceReportPolicy']}>
<Select
options={[
{ value: 'if-generated', label: '산출물이 생긴 경우만 보고' },
{ value: 'always', label: '항상 결과물/검증 여부 보고' },
{ value: 'none', label: '별도 resource 보고 없음' },
]}
/>
</Form.Item>
<Form.Item className="chat-type-management-page__meta-item" label="중간 개입 허용" name={['executionPolicy', 'allowModeratorIntervention']} valuePropName="checked">
<Switch checkedChildren="허용" unCheckedChildren="고정" />
</Form.Item>
<Form.Item className="chat-type-management-page__meta-item" label="마지막 종합 강제" name={['executionPolicy', 'finalSummaryRequired']} valuePropName="checked">
<Switch checkedChildren="강제" unCheckedChildren="선택" />
</Form.Item>
</>
);
}}
</Form.Item>
</div>
{isMobileViewport ? ( {isMobileViewport ? (
<Segmented <Segmented
className="chat-type-management-page__mobile-toggle" className="chat-type-management-page__mobile-toggle"

View File

@@ -1,9 +1,42 @@
.header-message-center__badge.ant-badge {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: visible;
}
.header-message-center__badge.ant-badge .ant-badge-count {
min-width: 16px;
height: 16px;
padding: 0 4px;
border: 1.5px solid #ffffff;
box-shadow: 0 4px 12px rgba(248, 113, 113, 0.28);
font-size: 10px;
line-height: 14px;
inset-block-start: 4px;
transform: translate(40%, -24%);
}
.header-message-center__trigger.ant-btn { .header-message-center__trigger.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px; width: 36px;
height: 36px; height: 36px;
padding: 0;
border-radius: 12px; border-radius: 12px;
} }
.header-message-center__trigger.ant-btn > span,
.header-message-center__trigger.ant-btn .ant-btn-icon,
.header-message-center__trigger.ant-btn .anticon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
vertical-align: middle;
}
.header-message-center__summary { .header-message-center__summary {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,6 +175,18 @@
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-message-center__trigger.ant-btn {
width: 31px;
height: 31px;
min-width: 31px;
border-radius: 10px;
}
.header-message-center__badge.ant-badge .ant-badge-count {
inset-block-start: 5px;
transform: translate(34%, -16%);
}
.header-message-center__item-button { .header-message-center__item-button {
padding: 12px 12px 34px; padding: 12px 12px 34px;
} }

View File

@@ -308,7 +308,7 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
return ( return (
<> <>
<Badge count={unreadCount} size="small" offset={[-2, 4]}> <Badge count={unreadCount} size="small" offset={[-2, 4]} className="header-message-center__badge">
<Button <Button
type="text" type="text"
className="header-message-center__trigger" className="header-message-center__trigger"

View File

@@ -212,6 +212,28 @@
align-items: center; align-items: center;
} }
.app-chat-panel__context-drawer-shell .ant-drawer-content,
.app-chat-panel__context-drawer-shell .ant-drawer-wrapper-body {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
}
.app-chat-panel__context-drawer-shell .ant-drawer-header,
.app-chat-panel__context-drawer-shell .ant-drawer-header-title,
.app-chat-panel__context-drawer-shell .ant-drawer-extra {
min-width: 0;
}
.app-chat-panel__context-drawer-shell .ant-drawer-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.app-chat-panel__context-drawer { .app-chat-panel__context-drawer {
display: flex; display: flex;
flex: 1; flex: 1;
@@ -219,6 +241,8 @@
gap: 16px; gap: 16px;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0;
overflow-x: hidden;
} }
.app-chat-panel__context-drawer-tabs { .app-chat-panel__context-drawer-tabs {
@@ -227,10 +251,12 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0;
} }
.app-chat-panel__context-drawer-tabs .ant-tabs-nav { .app-chat-panel__context-drawer-tabs .ant-tabs-nav {
flex: 0 0 auto; flex: 0 0 auto;
min-width: 0;
} }
.app-chat-panel__context-drawer-tabs .ant-tabs-content-holder, .app-chat-panel__context-drawer-tabs .ant-tabs-content-holder,
@@ -240,6 +266,7 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0;
} }
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active { .app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active {
@@ -248,6 +275,7 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
min-width: 0;
} }
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden { .app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden {
@@ -259,6 +287,8 @@
flex: 0 0 auto; flex: 0 0 auto;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-height: 0;
min-width: 0;
padding: 14px; padding: 14px;
border-radius: 18px; border-radius: 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
@@ -282,22 +312,78 @@
.app-chat-panel__context-drawer-radio, .app-chat-panel__context-drawer-radio,
.app-chat-panel__context-drawer-checkbox { .app-chat-panel__context-drawer-checkbox {
width: 100%; width: 100%;
min-width: 0;
}
.app-chat-panel__context-drawer-space.ant-space {
display: flex;
}
.app-chat-panel__context-drawer-space.ant-space,
.app-chat-panel__context-drawer-space .ant-space-item,
.app-chat-panel__context-drawer-checkbox .ant-checkbox-group,
.app-chat-panel__context-drawer-checkbox .ant-checkbox-wrapper,
.app-chat-panel__context-drawer-checkbox .ant-checkbox + span {
min-width: 0;
max-width: 100%;
}
.app-chat-panel__context-drawer-section--participants {
overflow: hidden;
}
.app-chat-panel__context-drawer-scroll {
flex: 1 1 auto;
min-height: 0;
padding-right: 4px;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
-webkit-overflow-scrolling: touch;
} }
.app-chat-panel__context-drawer-card { .app-chat-panel__context-drawer-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
min-width: 0;
padding: 12px 14px; padding: 12px 14px;
border-radius: 14px; border-radius: 14px;
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.96); border: 1px solid rgba(226, 232, 240, 0.96);
overflow-wrap: anywhere;
box-sizing: border-box;
} }
.app-chat-panel__context-drawer-card--readonly { .app-chat-panel__context-drawer-card--readonly {
gap: 6px; gap: 6px;
} }
.app-chat-panel__context-drawer-collapsible-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.app-chat-panel__context-drawer-collapsible-copy {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 4px;
}
.app-chat-panel__context-drawer-collapsible-summary {
color: #475569;
font-size: 12px;
}
.app-chat-panel__context-drawer-collapsible-toggle.ant-btn {
flex: 0 0 auto;
padding-inline: 8px;
}
.app-chat-panel__context-drawer-card-copy { .app-chat-panel__context-drawer-card-copy {
padding-left: 24px; padding-left: 24px;
} }
@@ -327,6 +413,18 @@
gap: 8px; gap: 8px;
} }
.app-chat-panel__context-drawer-mobile-actions {
display: flex;
gap: 10px;
flex: 0 0 auto;
padding-top: 4px;
}
.app-chat-panel__context-drawer-mobile-actions .ant-btn {
flex: 1 1 0;
min-width: 0;
}
.app-chat-panel__error-layout { .app-chat-panel__error-layout {
position: relative; position: relative;
display: flex; display: flex;
@@ -918,10 +1016,23 @@
gap: 12px; gap: 12px;
} }
.app-chat-panel__context-drawer-shell .ant-drawer-header {
padding-inline: 14px;
}
.app-chat-panel__context-drawer-shell .ant-drawer-body {
padding: 12px 14px 14px;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-nav { .app-chat-panel__context-drawer-tabs .ant-tabs-nav {
margin-bottom: 12px; margin-bottom: 12px;
} }
.app-chat-panel__context-drawer-tabs .ant-tabs-nav-wrap,
.app-chat-panel__context-drawer-tabs .ant-tabs-nav-list {
min-width: 0;
}
.app-chat-panel__context-drawer-tabs .ant-tabs-tab { .app-chat-panel__context-drawer-tabs .ant-tabs-tab {
padding: 8px 0; padding: 8px 0;
} }
@@ -942,4 +1053,12 @@
.app-chat-panel__context-drawer-textarea { .app-chat-panel__context-drawer-textarea {
min-height: 320px; min-height: 320px;
} }
.app-chat-panel__context-drawer-mobile-actions {
position: sticky;
bottom: 0;
z-index: 2;
padding: 12px 0 calc(env(safe-area-inset-bottom, 0px) + 2px);
background: linear-gradient(180deg, rgba(248, 250, 252, 0), rgba(248, 250, 252, 0.96) 18%, rgba(248, 250, 252, 0.99));
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,15 @@ import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
import { AutomationContextManagementPage } from './AutomationContextManagementPage'; import { AutomationContextManagementPage } from './AutomationContextManagementPage';
import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage'; import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage';
import { ResourceManagementPage } from './ResourceManagementPage'; import { ResourceManagementPage } from './ResourceManagementPage';
import { SharedChatManagementPage } from './SharedChatManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage'; import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage'; import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel'; import { MainChatPanel } from './MainChatPanel';
import { SystemChatPanel } from './SystemChatPanel';
import { PlayAppOverlay } from './PlayAppOverlay';
import { PreviewAppOverlay } from './PreviewAppOverlay'; import { PreviewAppOverlay } from './PreviewAppOverlay';
import { SharedResourceManagementPage } from './SharedResourceManagementPage';
import { TokenSettingManagementPage } from './TokenSettingManagementPage';
import type { PreviewTargetDescriptor } from './previewRuntime'; import type { PreviewTargetDescriptor } from './previewRuntime';
import { useMainLayoutContext } from './layout/MainLayoutContext'; import { useMainLayoutContext } from './layout/MainLayoutContext';
import { buildPlayPath } from './routes'; import { buildPlayPath } from './routes';
@@ -49,6 +54,7 @@ function parseWidgetSelectionId(selectionId: string): PreviewTargetDescriptor {
} }
const SAVED_LAYOUT_WINDOW_SELECTION_PREFIX = 'page:play:layout-record:'; const SAVED_LAYOUT_WINDOW_SELECTION_PREFIX = 'page:play:layout-record:';
const PLAY_APP_SELECTION_PREFIX = 'page:play:app:';
function parseSavedLayoutSelectionId(selectionId: string) { function parseSavedLayoutSelectionId(selectionId: string) {
return selectionId.startsWith(SAVED_LAYOUT_WINDOW_SELECTION_PREFIX) return selectionId.startsWith(SAVED_LAYOUT_WINDOW_SELECTION_PREFIX)
@@ -56,6 +62,10 @@ function parseSavedLayoutSelectionId(selectionId: string) {
: null; : null;
} }
function parsePlayAppSelectionId(selectionId: string) {
return selectionId.startsWith(PLAY_APP_SELECTION_PREFIX) ? selectionId.slice(PLAY_APP_SELECTION_PREFIX.length) : null;
}
export function MainContent({ export function MainContent({
contentExpanded, contentExpanded,
sidebarOverlayActive = false, sidebarOverlayActive = false,
@@ -93,8 +103,15 @@ export function MainContent({
() => windowSelections.find((selection) => selection.id === 'page:preview:app') ?? null, () => windowSelections.find((selection) => selection.id === 'page:preview:app') ?? null,
[windowSelections], [windowSelections],
); );
const playAppSelection = useMemo(
() => windowSelections.find((selection) => selection.id.startsWith(PLAY_APP_SELECTION_PREFIX)) ?? null,
[windowSelections],
);
const regularWindowSelections = useMemo( const regularWindowSelections = useMemo(
() => windowSelections.filter((selection) => selection.id !== 'page:preview:app'), () =>
windowSelections.filter(
(selection) => selection.id !== 'page:preview:app' && !selection.id.startsWith(PLAY_APP_SELECTION_PREFIX),
),
[windowSelections], [windowSelections],
); );
const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame => const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame =>
@@ -231,6 +248,14 @@ export function MainContent({
return <AutomationContextManagementPage />; return <AutomationContextManagementPage />;
} }
if (selectionId === 'page:plans:token-setting') {
return <TokenSettingManagementPage />;
}
if (selectionId === 'page:plans:shared-resource') {
return <SharedResourceManagementPage />;
}
const planStatus = getPlanStatusFromWindowSelection(selectionId); const planStatus = getPlanStatusFromWindowSelection(selectionId);
if (planStatus) { if (planStatus) {
@@ -248,6 +273,10 @@ export function MainContent({
return <MainChatPanel initialView="live" />; return <MainChatPanel initialView="live" />;
} }
if (selectionId === 'page:chat:rooms') {
return <SystemChatPanel />;
}
if (selectionId === 'page:chat:errors') { if (selectionId === 'page:chat:errors') {
return <MainChatPanel initialView="errors" />; return <MainChatPanel initialView="errors" />;
} }
@@ -267,6 +296,11 @@ export function MainContent({
if (selectionId === 'page:chat:manage-defaults') { if (selectionId === 'page:chat:manage-defaults') {
return <ChatDefaultContextManagementPage />; return <ChatDefaultContextManagementPage />;
} }
if (selectionId === 'page:chat:manage-share') {
return <SharedChatManagementPage />;
}
if (selectionId === 'page:play:layout') { if (selectionId === 'page:play:layout') {
return <LayoutPlaygroundView />; return <LayoutPlaygroundView />;
} }
@@ -303,6 +337,7 @@ export function MainContent({
{!disableWindowLayer && previewAppSelection ? ( {!disableWindowLayer && previewAppSelection ? (
<div className="app-main-preview-layer"> <div className="app-main-preview-layer">
<PreviewAppOverlay <PreviewAppOverlay
key={previewAppSelection.instanceId}
pathname={buildPlayPath('cbt')} pathname={buildPlayPath('cbt')}
onClose={() => { onClose={() => {
clearWindowSelection(previewAppSelection.instanceId); clearWindowSelection(previewAppSelection.instanceId);
@@ -310,6 +345,18 @@ export function MainContent({
/> />
</div> </div>
) : null} ) : null}
{!disableWindowLayer && playAppSelection ? (
<div className="app-main-play-app-layer">
<PlayAppOverlay
key={playAppSelection.instanceId}
appId={parsePlayAppSelectionId(playAppSelection.id) ?? ''}
label={playAppSelection.label}
onClose={() => {
clearWindowSelection(playAppSelection.instanceId);
}}
/>
</div>
) : null}
{!disableWindowLayer && regularWindowSelections.length > 0 ? ( {!disableWindowLayer && regularWindowSelections.length > 0 ? (
<div className="app-main-window-layer"> <div className="app-main-window-layer">
<div ref={stageRef} className="app-main-window-layer__stage"> <div ref={stageRef} className="app-main-window-layer__stage">

View File

@@ -1,24 +1,28 @@
import { import {
ApiOutlined, ApiOutlined,
BellOutlined, BellOutlined,
BgColorsOutlined,
BookOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CopyOutlined, CopyOutlined,
ClusterOutlined,
DownOutlined, DownOutlined,
FileMarkdownOutlined,
LoadingOutlined, LoadingOutlined,
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
ProfileOutlined, ProfileOutlined,
ReloadOutlined, ReloadOutlined,
RocketOutlined,
RightOutlined, RightOutlined,
SearchOutlined,
SettingOutlined, SettingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Drawer,
Dropdown, Dropdown,
Drawer,
Grid, Grid,
Input, Input,
InputNumber, InputNumber,
@@ -72,6 +76,7 @@ import {
} from './tokenAccess'; } from './tokenAccess';
import { isPreviewRuntime } from './previewRuntime'; import { isPreviewRuntime } from './previewRuntime';
import { chatConnectionGateway, chatGateway } from './chatV2'; import { chatConnectionGateway, chatGateway } from './chatV2';
import { emitChatConversationCleared, emitChatConversationsUpdated } from './chatV2/data/chatClientEvents';
import { HeaderMessageCenter } from './HeaderMessageCenter'; import { HeaderMessageCenter } from './HeaderMessageCenter';
import { fetchChatRuntimeJobDetail } from './mainChatPanel'; import { fetchChatRuntimeJobDetail } from './mainChatPanel';
import { import {
@@ -92,6 +97,7 @@ const APP_SETTINGS_CATEGORIES = [
const APP_SETTINGS_SECTIONS: Array<{ const APP_SETTINGS_SECTIONS: Array<{
value: value:
| 'headerAppearance'
| 'chatSettings' | 'chatSettings'
| 'automationRuntime' | 'automationRuntime'
| 'planDefaults' | 'planDefaults'
@@ -102,6 +108,7 @@ const APP_SETTINGS_SECTIONS: Array<{
label: string; label: string;
category: (typeof APP_SETTINGS_CATEGORIES)[number]['value']; category: (typeof APP_SETTINGS_CATEGORIES)[number]['value'];
}> = [ }> = [
{ value: 'headerAppearance', label: '헤더 표시', category: 'workspace' },
{ value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' }, { value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' },
{ value: 'automationRuntime', label: '자동접수 / 주기', category: 'automation' }, { value: 'automationRuntime', label: '자동접수 / 주기', category: 'automation' },
{ value: 'planDefaults', label: '자동화 기본값', category: 'automation' }, { value: 'planDefaults', label: '자동화 기본값', category: 'automation' },
@@ -165,6 +172,88 @@ const RESERVED_RESTART_WORK_SERVER_DELAY_MS = 5_000;
const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000; const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000;
const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000; const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000;
const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000; const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000;
const HEADER_THEME_STORAGE_KEY = 'work-server.header-theme';
const DESKTOP_HEADER_HEIGHT_STORAGE_KEY = 'work-server.desktop-header-height';
const MOBILE_HEADER_HEIGHT_STORAGE_KEY = 'work-server.mobile-header-height';
const DESKTOP_HEADER_HEIGHT_MIN = 48;
const DESKTOP_HEADER_HEIGHT_MAX = 88;
const DESKTOP_HEADER_HEIGHT_DEFAULT = 60;
const MOBILE_HEADER_HEIGHT_MIN = 32;
const MOBILE_HEADER_HEIGHT_MAX = 56;
const MOBILE_HEADER_HEIGHT_DEFAULT = 36;
type HeaderThemeKey = 'ocean' | 'sunset' | 'forest';
const HEADER_THEME_OPTIONS: Array<{
key: HeaderThemeKey;
label: string;
description: string;
icon: ReactNode;
}> = [
{ key: 'ocean', label: 'Ocean', description: '시원한 블루 중심 테마', icon: <BgColorsOutlined /> },
{ key: 'sunset', label: 'Sunset', description: '주황과 로즈 포인트 테마', icon: <RocketOutlined /> },
{ key: 'forest', label: 'Forest', description: '그린과 민트 중심 테마', icon: <ClusterOutlined /> },
];
function normalizeHeaderTheme(value: string | null | undefined): HeaderThemeKey {
return HEADER_THEME_OPTIONS.some((option) => option.key === value) ? (value as HeaderThemeKey) : 'ocean';
}
function getStoredHeaderTheme() {
if (typeof window === 'undefined') {
return 'ocean' satisfies HeaderThemeKey;
}
try {
return normalizeHeaderTheme(window.localStorage.getItem(HEADER_THEME_STORAGE_KEY));
} catch {
return 'ocean' satisfies HeaderThemeKey;
}
}
function normalizeMobileHeaderHeight(value: number | string | null | undefined) {
const parsedValue = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsedValue)) {
return MOBILE_HEADER_HEIGHT_DEFAULT;
}
return Math.min(MOBILE_HEADER_HEIGHT_MAX, Math.max(MOBILE_HEADER_HEIGHT_MIN, Math.round(parsedValue)));
}
function normalizeDesktopHeaderHeight(value: number | string | null | undefined) {
const parsedValue = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(parsedValue)) {
return DESKTOP_HEADER_HEIGHT_DEFAULT;
}
return Math.min(DESKTOP_HEADER_HEIGHT_MAX, Math.max(DESKTOP_HEADER_HEIGHT_MIN, Math.round(parsedValue)));
}
function getStoredDesktopHeaderHeight() {
if (typeof window === 'undefined') {
return DESKTOP_HEADER_HEIGHT_DEFAULT;
}
try {
return normalizeDesktopHeaderHeight(window.localStorage.getItem(DESKTOP_HEADER_HEIGHT_STORAGE_KEY));
} catch {
return DESKTOP_HEADER_HEIGHT_DEFAULT;
}
}
function getStoredMobileHeaderHeight() {
if (typeof window === 'undefined') {
return MOBILE_HEADER_HEIGHT_DEFAULT;
}
try {
return normalizeMobileHeaderHeight(window.localStorage.getItem(MOBILE_HEADER_HEIGHT_STORAGE_KEY));
} catch {
return MOBILE_HEADER_HEIGHT_DEFAULT;
}
}
function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) {
return ( return (
@@ -580,7 +669,7 @@ function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draf
} }
if (saved.openWindowSearch !== draft.openWindowSearch) { if (saved.openWindowSearch !== draft.openWindowSearch) {
changedLabels.push('Window UI 검색 열기'); changedLabels.push('시스템 채팅 열기');
} }
return changedLabels; return changedLabels;
@@ -1511,6 +1600,7 @@ export function MainHeader({
onToggleSidebar, onToggleSidebar,
onToggleContentExpanded, onToggleContentExpanded,
onChangeTopMenu, onChangeTopMenu,
onOpenSearch,
onOpenPlanQuickFilter, onOpenPlanQuickFilter,
}: MainHeaderProps) { }: MainHeaderProps) {
void contentExpanded; void contentExpanded;
@@ -1520,6 +1610,9 @@ export function MainHeader({
const [modalApi, modalContextHolder] = Modal.useModal(); const [modalApi, modalContextHolder] = Modal.useModal();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [headerTheme, setHeaderTheme] = useState<HeaderThemeKey>(() => getStoredHeaderTheme());
const [desktopHeaderHeight, setDesktopHeaderHeight] = useState<number>(() => getStoredDesktopHeaderHeight());
const [mobileHeaderHeight, setMobileHeaderHeight] = useState<number>(() => getStoredMobileHeaderHeight());
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false); const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
@@ -1575,6 +1668,33 @@ export function MainHeader({
const serverRestartReservationReloadTaskIdRef = useRef(0); const serverRestartReservationReloadTaskIdRef = useRef(0);
const { registeredToken, hasAccess } = useTokenAccess(); const { registeredToken, hasAccess } = useTokenAccess();
const appConfig = useAppConfig(); const appConfig = useAppConfig();
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.appTheme = headerTheme;
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(HEADER_THEME_STORAGE_KEY, headerTheme);
}
}, [headerTheme]);
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--app-desktop-header-height', `${desktopHeaderHeight}px`);
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(DESKTOP_HEADER_HEIGHT_STORAGE_KEY, String(desktopHeaderHeight));
}
}, [desktopHeaderHeight]);
useEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.style.setProperty('--app-mobile-header-height', `${mobileHeaderHeight}px`);
}
if (typeof window !== 'undefined') {
window.localStorage.setItem(MOBILE_HEADER_HEIGHT_STORAGE_KEY, String(mobileHeaderHeight));
}
}, [mobileHeaderHeight]);
const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig); const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig);
const [appConfigFeedback, setAppConfigFeedback] = useState<InlineFeedback | null>(null); const [appConfigFeedback, setAppConfigFeedback] = useState<InlineFeedback | null>(null);
const [appConfigSaving, setAppConfigSaving] = useState(false); const [appConfigSaving, setAppConfigSaving] = useState(false);
@@ -1692,55 +1812,44 @@ export function MainHeader({
serverRestartReservationNowTimestamp, serverRestartReservationNowTimestamp,
serverRestartReservationReloadPending, serverRestartReservationReloadPending,
); );
const renderTopMenuOptionLabel = (menu: 'docs' | 'plans' | 'play', label: ReactNode, iconLabel: string) => ( const renderTopMenuOptionLabel = (
menu: 'docs' | 'plans' | 'play',
label: ReactNode,
icon: ReactNode,
iconLabel: string,
) => (
<span <span
className="app-header__menu-option"
aria-label={iconLabel} aria-label={iconLabel}
onClick={() => { onClick={() => {
onChangeTopMenu(menu); onChangeTopMenu(menu);
}} }}
> >
{isMobileViewport ? <span aria-label={iconLabel}>{label}</span> : label} <span className="app-header__menu-option-icon" aria-hidden="true">
{icon}
</span>
{isMobileViewport ? null : <span className="app-header__menu-option-label">{label}</span>}
</span> </span>
); );
const headerTopMenuOptions = hasAccess const headerTopMenuOptions = hasAccess
? [ ? [
{ {
label: renderTopMenuOptionLabel( label: renderTopMenuOptionLabel('docs', 'Docs', <BookOutlined />, 'Docs'),
'docs',
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
'Docs',
),
value: 'docs', value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
}, },
{ {
label: renderTopMenuOptionLabel( label: renderTopMenuOptionLabel('plans', '작업', <ClusterOutlined />, '작업'),
'plans',
isMobileViewport ? <ProfileOutlined /> : '작업',
'작업',
),
value: 'plans', value: 'plans',
icon: isMobileViewport ? undefined : <ProfileOutlined />,
}, },
{ {
label: renderTopMenuOptionLabel( label: renderTopMenuOptionLabel('play', 'Play', <RocketOutlined />, 'Play'),
'play',
isMobileViewport ? <ApiOutlined /> : 'Play',
'Play',
),
value: 'play', value: 'play',
icon: isMobileViewport ? undefined : <ApiOutlined />,
}, },
] ]
: [ : [
{ {
label: renderTopMenuOptionLabel( label: renderTopMenuOptionLabel('docs', 'Docs', <BookOutlined />, 'Docs'),
'docs',
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
'Docs',
),
value: 'docs', value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
}, },
]; ];
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0; const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
@@ -1765,6 +1874,7 @@ export function MainHeader({
} }
const chatConnectionLabel = chatConnectionLabelParts.join(' · '); const chatConnectionLabel = chatConnectionLabelParts.join(' · ');
const searchShortcutLabel = appConfig.gestureShortcuts.openSearch || DEFAULT_APP_CONFIG.gestureShortcuts.openSearch;
const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${ const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${
hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : '' hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : ''
}`; }`;
@@ -2742,7 +2852,8 @@ export function MainHeader({
'재기동 요청 완료', '재기동 요청 완료',
`${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`, `${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`,
); );
const verified = await waitForServerRestart(key, baseline, progressTaskId); const verificationBaseline = result.restartState === 'accepted' ? result.item : baseline;
const verified = await waitForServerRestart(key, verificationBaseline, progressTaskId);
if (verified.cancelled || isRestartProgressCancelled(progressTaskId)) { if (verified.cancelled || isRestartProgressCancelled(progressTaskId)) {
setServerRestartFeedback({ setServerRestartFeedback({
@@ -3030,7 +3141,7 @@ export function MainHeader({
const handleResetNotificationIdentity = () => { const handleResetNotificationIdentity = () => {
modalApi.confirm({ modalApi.confirm({
title: '알림 클라이언트 초기화', title: '알림 클라이언트 초기화',
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.', content: '현재 브라우저의 알림 토큰/디바이스 식별자를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
okText: '초기화', okText: '초기화',
cancelText: '취소', cancelText: '취소',
autoFocusButton: 'ok', autoFocusButton: 'ok',
@@ -3111,33 +3222,40 @@ export function MainHeader({
return null; return null;
} }
const handleCopyFeedbackMessage = () => {
void copyTextToClipboard(feedback.message)
.then(() => {
setCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
};
return ( return (
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert <Alert
showIcon showIcon
type={feedback.tone} type={feedback.tone}
message={feedback.message} message={feedback.message}
action={ action={!screens.xs ? (
<Button <Button
type="text" type="text"
size="small" size="small"
aria-label="메시지 복사" aria-label="메시지 복사"
icon={<CopyOutlined />} icon={<CopyOutlined />}
onClick={() => { onClick={handleCopyFeedbackMessage}
void copyTextToClipboard(feedback.message)
.then(() => {
setCopyFeedback({
tone: 'success',
message: '메시지를 복사했습니다.',
});
})
.catch(() => {
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
}}
/> />
} ) : null}
/> />
{screens.xs ? (
<Button block icon={<CopyOutlined />} onClick={handleCopyFeedbackMessage}>
</Button>
) : null}
{copyFeedback ? <Alert showIcon type={copyFeedback.tone} message={copyFeedback.message} /> : null} {copyFeedback ? <Alert showIcon type={copyFeedback.tone} message={copyFeedback.message} /> : null}
</Space> </Space>
); );
@@ -3244,6 +3362,39 @@ export function MainHeader({
setTokenFeedback({ tone: 'info', message: '권한 토큰을 제거했습니다.' }); setTokenFeedback({ tone: 'info', message: '권한 토큰을 제거했습니다.' });
}; };
const handleClearActiveConversation = () => {
const sessionId = activeConversationSnapshot.sessionId.trim();
const conversationTitle = activeConversationSnapshot.title.trim();
if (!sessionId) {
return;
}
modalApi.confirm({
title: '채팅방 데이터를 초기화할까요?',
content: conversationTitle
? `"${conversationTitle}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`
: '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.',
okText: '초기화',
cancelText: '취소',
autoFocusButton: 'cancel',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
onOk: async () => {
const clearedItem = await chatGateway.clearConversation(sessionId);
emitChatConversationCleared(clearedItem);
setSettingsOpen(false);
void chatGateway
.listConversations()
.then((items) => {
emitChatConversationsUpdated(items);
})
.catch(() => undefined);
},
});
};
const settingsTriggerButton = ( const settingsTriggerButton = (
<Button <Button
type="text" type="text"
@@ -3257,6 +3408,63 @@ export function MainHeader({
/> />
); );
const activeHeaderThemeOption = HEADER_THEME_OPTIONS.find((option) => option.key === headerTheme) ?? HEADER_THEME_OPTIONS[0];
const renderHeaderHeightControls = ({
label,
description,
value,
min,
max,
onChange,
}: {
label: string;
description: string;
value: number;
min: number;
max: number;
onChange: (nextValue: number) => void;
}) => (
<div className="app-header__theme-height-panel">
<div className="app-header__theme-height-copy">
<span className="app-header__theme-height-label">{label}</span>
<span className="app-header__theme-height-meta">{description}</span>
</div>
<div className="app-header__theme-height-controls">
<Button
type="text"
className="app-header__theme-height-step"
onClick={() => onChange(value - 1)}
aria-label={`${label} 1픽셀 줄이기`}
>
-1px
</Button>
<InputNumber
min={min}
max={max}
step={1}
value={value}
controls={false}
className="app-header__theme-height-input"
addonAfter="px"
onChange={(nextValue) => {
if (typeof nextValue === 'number') {
onChange(nextValue);
}
}}
onStep={(nextValue) => onChange(nextValue)}
/>
<Button
type="text"
className="app-header__theme-height-step"
onClick={() => onChange(value + 1)}
aria-label={`${label} 1픽셀 늘리기`}
>
+1px
</Button>
</div>
</div>
);
const tokenTriggerButton = ( const tokenTriggerButton = (
<Button <Button
type="text" type="text"
@@ -4156,8 +4364,8 @@ export function MainHeader({
} }
description={ description={
gestureShortcutSettingsDirty gestureShortcutSettingsDirty
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, Window UI 검색 ${appConfigDraft.gestureShortcuts.openWindowSearch}` ? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch}` : `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch}`
} }
/> />
{appConfigFeedback ? ( {appConfigFeedback ? (
@@ -4224,7 +4432,7 @@ export function MainHeader({
})); }));
}} }}
/> />
<Text type="secondary"> `Window UI` . : `Mod+Shift+K`</Text> <Text type="secondary"> . . : `Mod+Shift+K`</Text>
</Space> </Space>
<Space wrap> <Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}> <Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
@@ -4235,6 +4443,74 @@ export function MainHeader({
</Space> </Space>
); );
const headerAppearancePanel = (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
.
</Paragraph>
<div className="app-header__settings-theme-group">
<div className="app-header__settings-theme-header">
<span className="app-header__settings-theme-title"> </span>
<span className="app-header__settings-theme-meta">{activeHeaderThemeOption.label} </span>
</div>
<div className="app-header__settings-theme-options">
{HEADER_THEME_OPTIONS.map((option) => {
const isActive = option.key === headerTheme;
return (
<button
key={`header-appearance-${option.key}`}
type="button"
className={`app-header__theme-option app-header__theme-option--compact${
isActive ? ' app-header__theme-option--active' : ''
}`}
onClick={() => {
setHeaderTheme(option.key);
}}
>
<span className="app-header__theme-option-icon" aria-hidden="true">
{option.icon}
</span>
<span className="app-header__theme-option-copy">
<span className="app-header__theme-option-label">{option.label}</span>
<span className="app-header__theme-option-description">{option.description}</span>
</span>
<span className="app-header__theme-option-state">{isActive ? '적용 중' : '선택'}</span>
</button>
);
})}
</div>
</div>
{renderHeaderHeightControls({
label: '데스크톱 헤더 높이',
description: '기본값은 60px이며, 상단 고정 헤더 높이를 현재 브라우저 기준으로 저장합니다.',
value: desktopHeaderHeight,
min: DESKTOP_HEADER_HEIGHT_MIN,
max: DESKTOP_HEADER_HEIGHT_MAX,
onChange: (nextValue) => setDesktopHeaderHeight(normalizeDesktopHeaderHeight(nextValue)),
})}
{renderHeaderHeightControls({
label: '모바일 헤더 높이',
description: '기본값은 36px이며, 모바일 폭에서만 적용되는 헤더 높이를 저장합니다.',
value: mobileHeaderHeight,
min: MOBILE_HEADER_HEIGHT_MIN,
max: MOBILE_HEADER_HEIGHT_MAX,
onChange: (nextValue) => setMobileHeaderHeight(normalizeMobileHeaderHeight(nextValue)),
})}
<Space wrap>
<Button
onClick={() => {
setHeaderTheme('ocean');
setDesktopHeaderHeight(DESKTOP_HEADER_HEIGHT_DEFAULT);
setMobileHeaderHeight(MOBILE_HEADER_HEIGHT_DEFAULT);
}}
>
</Button>
</Space>
</Space>
);
const settingsMenu = ( const settingsMenu = (
<div className="app-header__settings-menu"> <div className="app-header__settings-menu">
{hasAccess ? ( {hasAccess ? (
@@ -4352,9 +4628,9 @@ export function MainHeader({
className="app-header__settings-item" className="app-header__settings-item"
disabled={!hasAccess} disabled={!hasAccess}
onClick={() => { onClick={() => {
openSettingsModal('appSettings', 'planDefaults'); openSettingsModal('appSettings', 'headerAppearance');
}} }}
> >
<span className="app-header__settings-icon"> <span className="app-header__settings-icon">
<SettingOutlined /> <SettingOutlined />
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" /> <span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
@@ -4475,6 +4751,13 @@ export function MainHeader({
</Space> </Space>
<Space size={4} className="app-header__actions"> <Space size={4} className="app-header__actions">
<Button
type="text"
aria-label={`통합 검색 열기 · ${searchShortcutLabel}`}
title={isMobileViewport ? undefined : `통합 검색 열기 (${searchShortcutLabel})`}
icon={<SearchOutlined />}
onClick={onOpenSearch}
/>
{hasAccess ? ( {hasAccess ? (
<> <>
<button <button
@@ -4763,6 +5046,7 @@ export function MainHeader({
{activeAppSettingsSection === 'automationRuntime' ? automationRuntimePanel : null} {activeAppSettingsSection === 'automationRuntime' ? automationRuntimePanel : null}
{activeAppSettingsSection === 'planDefaults' ? planDefaultsPanel : null} {activeAppSettingsSection === 'planDefaults' ? planDefaultsPanel : null}
{activeAppSettingsSection === 'planCost' ? planCostPanel : null} {activeAppSettingsSection === 'planCost' ? planCostPanel : null}
{activeAppSettingsSection === 'headerAppearance' ? headerAppearancePanel : null}
{activeAppSettingsSection === 'chatSettings' ? chatSettingsPanel : null} {activeAppSettingsSection === 'chatSettings' ? chatSettingsPanel : null}
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null} {activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null} {activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}

File diff suppressed because it is too large Load Diff

View File

@@ -43,12 +43,12 @@ export function MainSidebar({
return; return;
} }
if (keyPath.includes('plan-group') || keyPath.includes('server-group')) { if (keyPath.includes('plan-group') || keyPath.includes('token-management-group') || keyPath.includes('server-group')) {
onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']); onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']);
return; return;
} }
if (keyPath.includes('codex-live-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) { if (keyPath.includes('chat-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) {
onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']); onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']);
return; return;
} }

View File

@@ -0,0 +1,91 @@
import { CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { useEffect } from 'react';
import { EReaderAppView } from '../../views/play/apps/e-reader/EReaderAppView';
import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzzleAppView';
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
import { TheQuestAppView } from '../../views/play/apps/the-quest/TheQuestAppView';
import { TetrisAppView } from '../../views/play/apps/tetris/TetrisAppView';
type PlayAppOverlayProps = {
appId: string;
label: string;
onClose: () => void;
};
const BODY_OPEN_CLASS = 'play-app-overlay-open';
function renderPlayApp(appId: string, onClose: () => void) {
if (appId === 'e-reader') {
return <EReaderAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'photoprism') {
return <PhotoPrismAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'photo-puzzle') {
return <PhotoPuzzleAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'tetris') {
return <TetrisAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'the-quest') {
return <TheQuestAppView onBack={onClose} launchContext="embedded" />;
}
return null;
}
export function PlayAppOverlay({ appId, label, onClose }: PlayAppOverlayProps) {
useEffect(() => {
document.body.classList.add(BODY_OPEN_CLASS);
return () => {
document.body.classList.remove(BODY_OPEN_CLASS);
};
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
const app = renderPlayApp(appId, onClose);
if (!app) {
return null;
}
const isPhotoPrism = appId === 'photoprism';
return (
<div className="play-app-overlay" aria-label={`${label} 오버레이`}>
<button type="button" className="play-app-overlay__backdrop" aria-label="앱 오버레이 닫기" onClick={onClose} />
{!isPhotoPrism ? (
<section className="play-app-overlay__surface">
<Button
type="text"
aria-label={`${label} 닫기`}
className="play-app-overlay__close"
icon={<CloseOutlined />}
onClick={onClose}
/>
<div className="play-app-overlay__viewport">{app}</div>
</section>
) : null}
{isPhotoPrism ? app : null}
</div>
);
}

View File

@@ -1,7 +1,16 @@
import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons'; import {
CloseOutlined,
CodeOutlined,
CopyOutlined,
DesktopOutlined,
MinusOutlined,
MobileOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Button } from 'antd'; import { Button } from 'antd';
import { import {
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
@@ -9,7 +18,13 @@ import {
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PreviewAppWindow } from './PreviewAppWindow'; import { PreviewAppWindow } from './PreviewAppWindow';
import type { PreviewTargetDescriptor } from './previewRuntime'; import { copyTextToClipboard } from '../../utils/clipboard';
import {
resolvePreviewAppOrigin,
type PreviewRuntimeConsoleBridgeMessage,
type PreviewRuntimeConsoleLevel,
type PreviewTargetDescriptor,
} from './previewRuntime';
type PreviewAppOverlayProps = { type PreviewAppOverlayProps = {
pathname: string; pathname: string;
@@ -23,16 +38,48 @@ type DragPosition = {
y: number; y: number;
}; };
type DetachedConsoleSize = {
width: number;
height: number;
};
const HEADER_HEIGHT = 44; const HEADER_HEIGHT = 44;
const MINIMIZED_WIDTH = 168; const MINIMIZED_WIDTH = 168;
const MOBILE_SHELL_WIDTH = 430; const MOBILE_SHELL_WIDTH = 430;
const MOBILE_SHELL_HEIGHT = 860; const MOBILE_SHELL_HEIGHT = 860;
const VIEWPORT_PADDING = 12; const VIEWPORT_PADDING = 12;
const MAX_CONSOLE_ENTRIES = 300;
const DETACHED_CONSOLE_WIDTH = 460;
const DETACHED_CONSOLE_HEIGHT = 340;
const DETACHED_CONSOLE_MIN_WIDTH = 360;
const DETACHED_CONSOLE_MAX_WIDTH = 840;
const DETACHED_CONSOLE_MIN_HEIGHT = 240;
const DETACHED_CONSOLE_MAX_HEIGHT = 620;
const DETACHED_CONSOLE_DRAGGING_CLASS = 'preview-app-overlay-console-dragging';
const CONSOLE_LEVELS: PreviewRuntimeConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
type PreviewConsoleEntry = PreviewRuntimeConsoleBridgeMessage & {
id: string;
};
function clamp(value: number, min: number, max: number) { function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max); return Math.min(Math.max(value, min), max);
} }
function formatConsoleEntryCopyText(entry: PreviewConsoleEntry) {
return [
`${entry.level.toUpperCase()} ${new Date(entry.timestamp).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}`,
entry.args.join('\n'),
]
.filter((value) => value.trim().length > 0)
.join('\n');
}
function getDefaultMinimizedPosition(): DragPosition { function getDefaultMinimizedPosition(): DragPosition {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return { return {
@@ -77,35 +124,154 @@ function getDefaultMobileShellPosition(): DragPosition {
}; };
} }
function getDetachedConsoleMetrics() {
if (typeof window === 'undefined') {
return {
width: DETACHED_CONSOLE_WIDTH,
height: DETACHED_CONSOLE_HEIGHT,
};
}
return {
width: Math.min(DETACHED_CONSOLE_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
height: Math.min(DETACHED_CONSOLE_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)),
};
}
function getDetachedConsoleSizeBounds() {
if (typeof window === 'undefined') {
return {
minWidth: DETACHED_CONSOLE_MIN_WIDTH,
maxWidth: DETACHED_CONSOLE_MAX_WIDTH,
minHeight: DETACHED_CONSOLE_MIN_HEIGHT,
maxHeight: DETACHED_CONSOLE_MAX_HEIGHT,
};
}
return {
minWidth: Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
maxWidth: Math.max(
Math.min(DETACHED_CONSOLE_MAX_WIDTH, window.innerWidth - VIEWPORT_PADDING * 2),
Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)),
),
minHeight: Math.min(
DETACHED_CONSOLE_MIN_HEIGHT,
Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2),
),
maxHeight: Math.max(
Math.min(DETACHED_CONSOLE_MAX_HEIGHT, window.innerHeight - VIEWPORT_PADDING * 2),
Math.min(DETACHED_CONSOLE_MIN_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)),
),
};
}
function normalizeDetachedConsoleSize(size: DetachedConsoleSize): DetachedConsoleSize {
const { minWidth, maxWidth, minHeight, maxHeight } = getDetachedConsoleSizeBounds();
return {
width: clamp(size.width, minWidth, maxWidth),
height: clamp(size.height, minHeight, maxHeight),
};
}
function getDefaultDetachedConsoleSize(): DetachedConsoleSize {
return normalizeDetachedConsoleSize({
width: DETACHED_CONSOLE_WIDTH,
height: DETACHED_CONSOLE_HEIGHT,
});
}
function getDefaultDetachedConsolePosition(): DragPosition {
if (typeof window === 'undefined') {
return {
x: VIEWPORT_PADDING,
y: VIEWPORT_PADDING,
};
}
const { width, height } = getDetachedConsoleMetrics();
return {
x: Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
y: Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
};
}
export function PreviewAppOverlay({ export function PreviewAppOverlay({
pathname, pathname,
search = '', search = '',
targetDescriptor = null, targetDescriptor = null,
onClose, onClose,
}: PreviewAppOverlayProps) { }: PreviewAppOverlayProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition()); const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition());
const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition()); const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition());
const detachedConsolePositionRef = useRef<DragPosition>(getDefaultDetachedConsolePosition());
const detachedConsoleSizeRef = useRef<DetachedConsoleSize>(getDefaultDetachedConsoleSize());
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const consoleBodyRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<{ const dragStateRef = useRef<{
pointerId: number; pointerId: number;
lastX: number; lastX: number;
lastY: number; lastY: number;
captureTarget: HTMLDivElement; captureTarget: HTMLDivElement;
} | null>(null); } | null>(null);
const detachedConsoleDragStateRef = useRef<{
pointerId: number;
lastX: number;
lastY: number;
captureTarget: HTMLDivElement;
} | null>(null);
const detachedConsoleResizeStateRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
captureTarget: HTMLDivElement;
} | null>(null);
const dragMovedRef = useRef(false); const dragMovedRef = useRef(false);
const [minimized, setMinimized] = useState(false); const [minimized, setMinimized] = useState(false);
const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile'); const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile');
const [consoleOpen, setConsoleOpen] = useState(false);
const [consoleDetached, setConsoleDetached] = useState(false);
const [consoleEntries, setConsoleEntries] = useState<PreviewConsoleEntry[]>([]);
const [consoleLevelFilter, setConsoleLevelFilter] = useState<PreviewRuntimeConsoleLevel[]>(CONSOLE_LEVELS);
const [reloadKey, setReloadKey] = useState(0);
const [isMobileViewport, setIsMobileViewport] = useState(() => const [isMobileViewport, setIsMobileViewport] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false, typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false,
); );
const [position, setPosition] = useState<DragPosition>(() => minimizedPositionRef.current); const [position, setPosition] = useState<DragPosition>(() => minimizedPositionRef.current);
const [detachedConsolePosition, setDetachedConsolePosition] = useState<DragPosition>(
() => detachedConsolePositionRef.current,
);
const [detachedConsoleSize, setDetachedConsoleSize] = useState<DetachedConsoleSize>(
() => detachedConsoleSizeRef.current,
);
const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport; const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport;
const canDetachConsole = !isMobileViewport;
const showDetachedConsole = consoleOpen && consoleDetached && canDetachConsole;
const showAttachedConsole = consoleOpen && !showDetachedConsole;
const previewOrigin = useMemo(() => resolvePreviewAppOrigin(), []);
const clearDetachedConsoleDraggingState = () => {
if (typeof document === 'undefined') {
return;
}
document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS);
};
useEffect(() => {
setConsoleEntries([]);
}, [pathname, search, targetDescriptor, deviceMode]);
useEffect(() => { useEffect(() => {
document.body.classList.add('preview-app-overlay-open'); document.body.classList.add('preview-app-overlay-open');
return () => { return () => {
document.body.classList.remove('preview-app-overlay-open'); document.body.classList.remove('preview-app-overlay-open');
document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS);
}; };
}, []); }, []);
@@ -127,6 +293,12 @@ export function PreviewAppOverlay({
}; };
}, []); }, []);
useEffect(() => {
if (isMobileViewport) {
setConsoleDetached(false);
}
}, [isMobileViewport]);
useEffect(() => { useEffect(() => {
if (!minimized && !isDesktopMobileShell) { if (!minimized && !isDesktopMobileShell) {
return; return;
@@ -288,6 +460,214 @@ export function PreviewAppOverlay({
} }
}, [isDesktopMobileShell]); }, [isDesktopMobileShell]);
useEffect(() => {
if (!showDetachedConsole) {
detachedConsoleDragStateRef.current = null;
detachedConsoleResizeStateRef.current = null;
clearDetachedConsoleDraggingState();
return;
}
const resizeListener = () => {
const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current);
setDetachedConsolePosition((current) => {
const nextPosition = {
x: clamp(
current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
),
y: clamp(
current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
),
};
detachedConsolePositionRef.current = nextPosition;
return nextPosition;
});
};
window.addEventListener('resize', resizeListener);
resizeListener();
return () => {
window.removeEventListener('resize', resizeListener);
};
}, [showDetachedConsole]);
useEffect(() => {
if (!showDetachedConsole) {
return;
}
const handlePointerMove = (event: PointerEvent) => {
const dragState = detachedConsoleDragStateRef.current;
const resizeState = detachedConsoleResizeStateRef.current;
if (dragState && dragState.pointerId === event.pointerId) {
event.preventDefault();
const deltaX = event.clientX - dragState.lastX;
const deltaY = event.clientY - dragState.lastY;
dragState.lastX = event.clientX;
dragState.lastY = event.clientY;
const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current);
setDetachedConsolePosition((current) => {
const nextPosition = {
x: clamp(
current.x + deltaX,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING),
),
y: clamp(
current.y + deltaY,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING),
),
};
detachedConsolePositionRef.current = nextPosition;
return nextPosition;
});
return;
}
if (resizeState && resizeState.pointerId === event.pointerId) {
event.preventDefault();
updateDetachedConsoleSize({
width: resizeState.startWidth + (event.clientX - resizeState.startX),
height: resizeState.startHeight + (event.clientY - resizeState.startY),
});
}
};
const finishPointerDrag = (event: PointerEvent) => {
const dragState = detachedConsoleDragStateRef.current;
const resizeState = detachedConsoleResizeStateRef.current;
if (dragState?.pointerId === event.pointerId) {
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
detachedConsoleDragStateRef.current = null;
}
if (resizeState?.pointerId === event.pointerId) {
if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) {
resizeState.captureTarget.releasePointerCapture(event.pointerId);
}
detachedConsoleResizeStateRef.current = null;
}
clearDetachedConsoleDraggingState();
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', finishPointerDrag);
window.addEventListener('pointercancel', finishPointerDrag);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', finishPointerDrag);
window.removeEventListener('pointercancel', finishPointerDrag);
};
}, [showDetachedConsole]);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== previewOrigin) {
return;
}
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
const payload = event.data as PreviewRuntimeConsoleBridgeMessage | null;
if (!payload || payload.source !== 'sm-home.preview-runtime.console' || !Array.isArray(payload.args)) {
return;
}
const nextEntry: PreviewConsoleEntry = {
...payload,
id: `${payload.timestamp}-${Math.random().toString(36).slice(2, 10)}`,
};
setConsoleEntries((current) => {
const nextEntries = [...current, nextEntry];
if (nextEntries.length <= MAX_CONSOLE_ENTRIES) {
return nextEntries;
}
return nextEntries.slice(nextEntries.length - MAX_CONSOLE_ENTRIES);
});
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [previewOrigin]);
useEffect(() => {
if (!consoleOpen) {
return;
}
const nextFrame = window.requestAnimationFrame(() => {
const container = consoleBodyRef.current;
if (!container) {
return;
}
container.scrollTop = container.scrollHeight;
});
return () => {
window.cancelAnimationFrame(nextFrame);
};
}, [consoleEntries, consoleOpen]);
const latestConsoleEntry = consoleEntries.length > 0 ? consoleEntries[consoleEntries.length - 1] : null;
const latestConsoleHref = latestConsoleEntry?.href?.trim() || iframeRef.current?.src || '';
const consoleLevelCounts = useMemo(
() =>
CONSOLE_LEVELS.reduce<Record<PreviewRuntimeConsoleLevel, number>>(
(counts, level) => ({
...counts,
[level]: consoleEntries.filter((entry) => entry.level === level).length,
}),
{
log: 0,
info: 0,
warn: 0,
error: 0,
debug: 0,
},
),
[consoleEntries],
);
const filteredConsoleEntries = useMemo(
() => consoleEntries.filter((entry) => consoleLevelFilter.includes(entry.level)),
[consoleEntries, consoleLevelFilter],
);
const errorEntryCount = consoleLevelCounts.error;
const warnEntryCount = consoleLevelCounts.warn;
const hasAllConsoleLevelsSelected = consoleLevelFilter.length === CONSOLE_LEVELS.length;
const hasAnyConsoleLevelsSelected = consoleLevelFilter.length > 0;
const handleHeaderPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => { const handleHeaderPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!minimized && !isDesktopMobileShell) { if (!minimized && !isDesktopMobileShell) {
return; return;
@@ -344,94 +724,417 @@ export function PreviewAppOverlay({
event.stopPropagation(); event.stopPropagation();
}; };
return createPortal( const handleDetachedConsolePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
<div if (!showDetachedConsole || event.button !== 0) {
ref={rootRef} return;
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${ }
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
detachedConsoleDragStateRef.current = {
pointerId: event.pointerId,
lastX: event.clientX,
lastY: event.clientY,
captureTarget: event.currentTarget,
};
document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS);
event.currentTarget.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
};
const handleDetachedConsolePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = detachedConsoleDragStateRef.current;
if (dragState?.pointerId === event.pointerId) {
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
dragState.captureTarget.releasePointerCapture(event.pointerId);
}
detachedConsoleDragStateRef.current = null;
}
clearDetachedConsoleDraggingState();
event.stopPropagation();
};
const handleDetachedConsoleResizePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!showDetachedConsole || event.button !== 0) {
return;
}
detachedConsoleResizeStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startWidth: detachedConsoleSizeRef.current.width,
startHeight: detachedConsoleSizeRef.current.height,
captureTarget: event.currentTarget,
};
document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS);
event.currentTarget.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
};
const handleDetachedConsoleResizePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const resizeState = detachedConsoleResizeStateRef.current;
if (resizeState?.pointerId === event.pointerId) {
if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) {
resizeState.captureTarget.releasePointerCapture(event.pointerId);
}
detachedConsoleResizeStateRef.current = null;
}
clearDetachedConsoleDraggingState();
event.stopPropagation();
};
const updateDetachedConsoleSize = (nextSize: DetachedConsoleSize) => {
const normalized = normalizeDetachedConsoleSize(nextSize);
detachedConsoleSizeRef.current = normalized;
setDetachedConsoleSize(normalized);
setDetachedConsolePosition((current) => {
const nextPosition = {
x: clamp(
current.x,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerWidth - normalized.width - VIEWPORT_PADDING),
),
y: clamp(
current.y,
VIEWPORT_PADDING,
Math.max(VIEWPORT_PADDING, window.innerHeight - normalized.height - VIEWPORT_PADDING),
),
};
detachedConsolePositionRef.current = nextPosition;
return nextPosition;
});
};
const toggleConsoleLevel = (level: PreviewRuntimeConsoleLevel) => {
setConsoleLevelFilter((current) => {
if (current.includes(level)) {
if (current.length === 1) {
return current;
}
return current.filter((item) => item !== level);
}
return CONSOLE_LEVELS.filter((item) => item === level || current.includes(item));
});
};
const renderConsolePanel = (detached: boolean) => (
<section
className={`preview-app-overlay__console-panel${
detached ? ' preview-app-overlay__console-panel--detached' : ''
}`} }`}
aria-label="Preview 콘솔"
style={ style={
minimized || isDesktopMobileShell detached
? { ? {
left: `${position.x}px`, width: `${detachedConsoleSize.width}px`,
top: `${position.y}px`, height: `${detachedConsoleSize.height}px`,
left: `${detachedConsolePosition.x}px`,
top: `${detachedConsolePosition.y}px`,
} }
: undefined : undefined
} }
> >
<div <div
className="preview-app-overlay__header" className={`preview-app-overlay__console-head${
onClick={() => { detached ? ' preview-app-overlay__console-head--detached' : ''
if (minimized && !dragMovedRef.current) { }`}
setMinimized(false); onPointerDown={detached ? handleDetachedConsolePointerDown : undefined}
} onPointerUp={detached ? handleDetachedConsolePointerUp : undefined}
}} onPointerCancel={detached ? handleDetachedConsolePointerUp : undefined}
onPointerDown={handleHeaderPointerDown}
onPointerUp={handleHeaderPointerUp}
onPointerCancel={handleHeaderPointerUp}
> >
<div className={`preview-app-overlay__title${minimized ? ' preview-app-overlay__title--minimized' : ''}`}> <div className="preview-app-overlay__console-head-copy">
{minimized ? ( <strong>{detached ? 'Console Detached' : 'Console'}</strong>
<div className="preview-app-overlay__minimized-content"> <div className="preview-app-overlay__console-summary" aria-label="Preview 콘솔 요약">
<span className="preview-app-overlay__minimized-dot" aria-hidden="true" /> <span> {consoleEntries.length}</span>
<span className="preview-app-overlay__minimized-label">Preview App</span> <span> {warnEntryCount}</span>
</div> <span> {errorEntryCount}</span>
) : ( </div>
<> <div className="preview-app-overlay__console-location" title={latestConsoleHref || 'Preview URL 확인 대기 중'}>
<span className="preview-app-overlay__title-badge" aria-hidden="true" /> {latestConsoleHref || 'Preview URL 확인 대기 중'}
<span className="preview-app-overlay__title-copy"> </div>
<strong>Preview App</strong>
<span> </span>
</span>
</>
)}
</div> </div>
<div className="preview-app-overlay__actions"> <div
{!minimized && !isMobileViewport ? ( className="preview-app-overlay__console-head-actions"
onPointerDown={(event) => {
event.stopPropagation();
}}
>
{canDetachConsole ? (
<Button <Button
type="text" type="text"
aria-label={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'} size="small"
title={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'} aria-label={detached ? '콘솔 오버레이에 붙이기' : '콘솔 분리하기'}
icon={deviceMode === 'mobile' ? <DesktopOutlined /> : <MobileOutlined />} onClick={() => {
onClick={(event) => { setConsoleDetached((current) => !current);
handleActionButtonClick(event);
setDeviceMode((current) => (current === 'mobile' ? 'desktop' : 'mobile'));
}} }}
/> >
) : null} {detached ? 'Attach' : 'Detach'}
{!minimized ? ( </Button>
<Button
type="text"
aria-label="Preview 최소화"
icon={<MinusOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
handleMinimizeToggle();
}}
/>
) : null} ) : null}
<Button <Button
type="text" type="text"
danger size="small"
aria-label="Preview 닫기" icon={<CopyOutlined />}
icon={<CloseOutlined />} aria-label="Preview URL 복사"
onClick={(event) => { disabled={!latestConsoleHref}
handleActionButtonClick(event); onClick={() => {
dragMovedRef.current = false; if (!latestConsoleHref) {
onClose(); return;
}
void copyTextToClipboard(latestConsoleHref);
}} }}
/> >
Copy URL
</Button>
<Button
type="text"
size="small"
aria-label="Preview 콘솔 비우기"
onClick={() => {
setConsoleEntries([]);
}}
>
Clear
</Button>
{detached ? (
<Button
type="text"
size="small"
aria-label="Preview 콘솔 닫기"
onClick={() => {
setConsoleOpen(false);
}}
>
Close
</Button>
) : null}
</div> </div>
</div> </div>
<div className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}> <div className="preview-app-overlay__console-filters" aria-label="Preview 콘솔 레벨 필터">
<PreviewAppWindow <Button
pathname={pathname} type="text"
search={search} size="small"
targetDescriptor={targetDescriptor} className={`preview-app-overlay__console-filter${hasAllConsoleLevelsSelected ? ' is-active' : ''}`}
deviceMode={deviceMode} aria-pressed={hasAllConsoleLevelsSelected}
/> onClick={() => {
setConsoleLevelFilter(CONSOLE_LEVELS);
}}
>
All {consoleEntries.length}
</Button>
{CONSOLE_LEVELS.map((level) => (
<Button
key={level}
type="text"
size="small"
className={`preview-app-overlay__console-filter preview-app-overlay__console-filter--${level}${
consoleLevelFilter.includes(level) ? ' is-active' : ''
}`}
aria-pressed={consoleLevelFilter.includes(level)}
onClick={() => {
toggleConsoleLevel(level);
}}
>
{level.toUpperCase()} {consoleLevelCounts[level]}
</Button>
))}
</div> </div>
</div>, <div ref={consoleBodyRef} className="preview-app-overlay__console-body">
{filteredConsoleEntries.length ? (
filteredConsoleEntries.map((entry) => {
const entryTimeLabel = new Date(entry.timestamp).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return (
<article
key={entry.id}
className={`preview-app-overlay__console-entry preview-app-overlay__console-entry--${entry.level}`}
>
<div className="preview-app-overlay__console-meta">
<div className="preview-app-overlay__console-meta-copy">
<span>{entry.level.toUpperCase()}</span>
<time dateTime={entry.timestamp}>{entryTimeLabel}</time>
</div>
<Button
type="text"
size="small"
className="preview-app-overlay__console-entry-copy"
icon={<CopyOutlined />}
aria-label="콘솔 항목 복사"
onClick={() => {
void copyTextToClipboard(formatConsoleEntryCopyText(entry));
}}
/>
</div>
<pre>{entry.args.join('\n')}</pre>
</article>
);
})
) : (
<div className="preview-app-overlay__console-empty">
{consoleEntries.length && !hasAnyConsoleLevelsSelected
? '표시할 콘솔 레벨이 선택되지 않았습니다.'
: consoleEntries.length
? '선택한 레벨에 해당하는 콘솔 로그가 아직 없습니다.'
: 'iframe 콘솔 로그가 아직 없습니다.'}
<br />
{consoleEntries.length
? '필터를 조정하거나 Preview 앱에서 동작을 다시 재현해 주세요.'
: 'Preview 앱에서 동작을 재현하면 이 패널에서 바로 확인할 수 있습니다.'}
</div>
)}
</div>
{detached ? (
<div
className="preview-app-overlay__console-resize-handle"
aria-label="분리 콘솔 크기 조절"
role="presentation"
onPointerDown={handleDetachedConsoleResizePointerDown}
onPointerUp={handleDetachedConsoleResizePointerUp}
onPointerCancel={handleDetachedConsoleResizePointerUp}
/>
) : null}
</section>
);
return createPortal(
<>
<div
ref={rootRef}
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
}`}
style={
minimized || isDesktopMobileShell
? {
left: `${position.x}px`,
top: `${position.y}px`,
}
: undefined
}
>
<div
className="preview-app-overlay__header"
onClick={() => {
if (minimized && !dragMovedRef.current) {
setMinimized(false);
}
}}
onPointerDown={handleHeaderPointerDown}
onPointerUp={handleHeaderPointerUp}
onPointerCancel={handleHeaderPointerUp}
>
<div className={`preview-app-overlay__title${minimized ? ' preview-app-overlay__title--minimized' : ''}`}>
{minimized ? (
<div className="preview-app-overlay__minimized-content">
<span className="preview-app-overlay__minimized-dot" aria-hidden="true" />
<span className="preview-app-overlay__minimized-label">Preview App</span>
</div>
) : (
<>
<span className="preview-app-overlay__title-badge" aria-hidden="true" />
<span className="preview-app-overlay__title-copy">
<strong>Preview App</strong>
<span> </span>
</span>
</>
)}
</div>
<div className="preview-app-overlay__actions">
{!minimized && !isMobileViewport ? (
<Button
type="text"
aria-label={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
title={deviceMode === 'mobile' ? '전체 폭으로 보기' : '모바일 폭으로 보기'}
icon={deviceMode === 'mobile' ? <DesktopOutlined /> : <MobileOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
setDeviceMode((current) => (current === 'mobile' ? 'desktop' : 'mobile'));
}}
/>
) : null}
{!minimized ? (
<Button
type="text"
aria-label="Preview 새로고침"
title="Preview 새로고침"
icon={<ReloadOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
setConsoleEntries([]);
setReloadKey((current) => current + 1);
}}
/>
) : null}
{!minimized ? (
<Button
type="text"
aria-label={consoleOpen ? 'Preview 콘솔 닫기' : 'Preview 콘솔 보기'}
title={consoleOpen ? 'Preview 콘솔 닫기' : 'Preview 콘솔 보기'}
className={`preview-app-overlay__console-toggle${consoleEntries.length ? ' preview-app-overlay__console-toggle--active' : ''}`}
icon={<CodeOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
setConsoleOpen((current) => !current);
}}
>
Console
</Button>
) : null}
{!minimized ? (
<Button
type="text"
aria-label="Preview 최소화"
icon={<MinusOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
handleMinimizeToggle();
}}
/>
) : null}
<Button
type="text"
danger
aria-label="Preview 닫기"
icon={<CloseOutlined />}
onClick={(event) => {
handleActionButtonClick(event);
dragMovedRef.current = false;
onClose();
}}
/>
</div>
</div>
<div className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}>
<PreviewAppWindow
ref={iframeRef}
pathname={pathname}
search={search}
targetDescriptor={targetDescriptor}
deviceMode={deviceMode}
reloadKey={reloadKey}
/>
{showAttachedConsole ? renderConsolePanel(false) : null}
</div>
</div>
{showDetachedConsole ? renderConsolePanel(true) : null}
</>,
document.body, document.body,
); );
} }

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { forwardRef, useMemo } from 'react';
import { getRegisteredAccessToken } from './tokenAccess'; import { getRegisteredAccessToken } from './tokenAccess';
import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime'; import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime';
@@ -7,14 +7,19 @@ type PreviewAppWindowProps = {
search?: string; search?: string;
targetDescriptor?: PreviewTargetDescriptor; targetDescriptor?: PreviewTargetDescriptor;
deviceMode?: 'desktop' | 'mobile'; deviceMode?: 'desktop' | 'mobile';
reloadKey?: number;
}; };
export function PreviewAppWindow({ export const PreviewAppWindow = forwardRef<HTMLIFrameElement, PreviewAppWindowProps>(function PreviewAppWindow(
pathname, {
search = '', pathname,
targetDescriptor = null, search = '',
deviceMode = 'desktop', targetDescriptor = null,
}: PreviewAppWindowProps) { deviceMode = 'desktop',
reloadKey = 0,
},
ref,
) {
const previewUrl = useMemo( const previewUrl = useMemo(
() => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode), () => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode),
[deviceMode, pathname, search, targetDescriptor], [deviceMode, pathname, search, targetDescriptor],
@@ -27,6 +32,8 @@ export function PreviewAppWindow({
> >
<div className={`preview-app-window__viewport preview-app-window__viewport--${deviceMode}`}> <div className={`preview-app-window__viewport preview-app-window__viewport--${deviceMode}`}>
<iframe <iframe
key={`${previewUrl}::${reloadKey}`}
ref={ref}
title="Preview App" title="Preview App"
src={previewUrl} src={previewUrl}
className="preview-app-window__frame" className="preview-app-window__frame"
@@ -36,4 +43,4 @@ export function PreviewAppWindow({
</div> </div>
</div> </div>
); );
} });

View File

@@ -500,6 +500,10 @@ function collectExpandedKeys(treeRoot: ResourceManagerTreeRoot | null) {
return keys; return keys;
} }
function collectCollapsedKeys() {
return ['/'] as Key[];
}
function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: string) { function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: string) {
if (!treeRoot) { if (!treeRoot) {
return null; return null;
@@ -803,7 +807,7 @@ export function ResourceManagementPage() {
const previewTouchGestureRef = useRef<PreviewTouchGestureState | null>(null); const previewTouchGestureRef = useRef<PreviewTouchGestureState | null>(null);
const previewShellRef = useRef<HTMLDivElement | null>(null); const previewShellRef = useRef<HTMLDivElement | null>(null);
const [treeRoot, setTreeRoot] = useState<ResourceManagerTreeRoot | null>(null); const [treeRoot, setTreeRoot] = useState<ResourceManagerTreeRoot | null>(null);
const [expandedKeys, setExpandedKeys] = useState<Key[]>(['/']); const [expandedKeys, setExpandedKeys] = useState<Key[]>(() => collectCollapsedKeys());
const [selectedTreePath, setSelectedTreePath] = useState(''); const [selectedTreePath, setSelectedTreePath] = useState('');
const [selectedDirectoryPath, setSelectedDirectoryPath] = useState(''); const [selectedDirectoryPath, setSelectedDirectoryPath] = useState('');
const [selectedListPath, setSelectedListPath] = useState(''); const [selectedListPath, setSelectedListPath] = useState('');
@@ -1144,7 +1148,7 @@ export function ResourceManagementPage() {
try { try {
const nextTree = await fetchResourceManagerTree(); const nextTree = await fetchResourceManagerTree();
setTreeRoot(nextTree); setTreeRoot(nextTree);
setExpandedKeys((current) => (preserveExpandedKeys && current.length > 0 ? current : collectExpandedKeys(nextTree))); setExpandedKeys((current) => (preserveExpandedKeys && current.length > 0 ? current : collectCollapsedKeys()));
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : '리소스 트리를 불러오지 못했습니다.'); setErrorMessage(error instanceof Error ? error.message : '리소스 트리를 불러오지 못했습니다.');
} finally { } finally {
@@ -2379,7 +2383,7 @@ export function ResourceManagementPage() {
size="small" size="small"
icon={<MinusSquareOutlined />} icon={<MinusSquareOutlined />}
aria-label="전체 접기" aria-label="전체 접기"
onClick={() => setExpandedKeys(['/'])} onClick={() => setExpandedKeys(collectCollapsedKeys())}
/> />
<Button <Button
type="text" type="text"

View File

@@ -0,0 +1,245 @@
.scoped-chat-rooms-window {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 1400;
display: flex;
flex-direction: column;
width: min(620px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
border: 1px solid rgba(196, 210, 226, 0.92);
border-radius: 26px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 30%);
box-shadow:
0 30px 72px rgba(15, 23, 42, 0.16),
0 10px 24px rgba(148, 163, 184, 0.18);
backdrop-filter: blur(14px);
}
.scoped-chat-rooms-window--mobile {
right: 0;
bottom: 0;
width: 100vw;
height: 100dvh;
border-radius: 0;
border-inline: 0;
border-bottom: 0;
}
.scoped-chat-rooms-window--minimized {
right: auto;
bottom: auto;
width: 176px;
height: auto;
border: 0;
border-radius: 20px;
overflow: visible;
box-shadow: none;
touch-action: none;
background: transparent;
}
.scoped-chat-rooms-window__header {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 0 14px 0 16px;
background:
linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
border-bottom: 1px solid rgba(148, 163, 184, 0.24);
cursor: default;
}
.scoped-chat-rooms-window--minimized .scoped-chat-rooms-window__header {
min-height: 0;
padding: 8px 8px 10px;
border: 1px solid rgba(196, 210, 226, 0.92);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 32%);
box-shadow:
0 18px 34px rgba(15, 23, 42, 0.14),
0 6px 18px rgba(148, 163, 184, 0.16);
flex-direction: column;
align-items: stretch;
}
.scoped-chat-rooms-window__title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
padding: 0;
border: 0;
background: transparent;
text-align: left;
font-size: 13px;
font-weight: 700;
color: #0f172a;
cursor: default;
}
.scoped-chat-rooms-window__title-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #2563eb;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
0 6px 16px rgba(148, 163, 184, 0.12);
font-size: 14px;
}
.scoped-chat-rooms-window__title-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.scoped-chat-rooms-window__title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scoped-chat-rooms-window__title-subtitle {
min-width: 0;
color: #64748b;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scoped-chat-rooms-window__actions {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.scoped-chat-rooms-window__action.ant-btn {
width: 30px;
min-width: 30px;
height: 30px;
color: #334155;
border-radius: 999px;
border: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 249, 0.9));
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
0 6px 16px rgba(148, 163, 184, 0.12);
}
.scoped-chat-rooms-window__action.ant-btn:hover {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(219, 234, 254, 0.94));
box-shadow:
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
0 8px 18px rgba(96, 165, 250, 0.16);
}
.scoped-chat-rooms-window__action--close.ant-btn:hover {
color: #b91c1c;
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.92));
box-shadow:
inset 0 0 0 1px rgba(248, 113, 113, 0.3),
0 8px 18px rgba(248, 113, 113, 0.14);
}
.scoped-chat-rooms-window__drag-handle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 20px;
flex: 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: #334155;
cursor: grab;
touch-action: none;
user-select: none;
}
.scoped-chat-rooms-window__drag-handle:active {
cursor: grabbing;
}
.scoped-chat-rooms-window__drag-grip {
width: 20px;
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
background:
radial-gradient(circle, rgba(100, 116, 139, 0.9) 1.2px, transparent 1.4px) 0 0 / 6px 6px;
opacity: 0.85;
}
.scoped-chat-rooms-window__drag-title {
min-width: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scoped-chat-rooms-window__minimized-copy {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.scoped-chat-rooms-window__actions--minimized {
gap: 6px;
}
.scoped-chat-rooms-window__restore-button.ant-btn {
flex: 1 1 auto;
height: 32px;
padding-inline: 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
}
.scoped-chat-rooms-window__body {
flex: 1;
min-height: 0;
padding: 0;
background: transparent;
}
.scoped-chat-rooms-window__body .app-chat-panel {
height: 100%;
max-height: 100%;
border-radius: 0;
background: transparent;
}
@media (max-width: 768px) {
.scoped-chat-rooms-window--minimized {
width: min(176px, calc(100vw - 24px));
}
}

View File

@@ -0,0 +1,152 @@
import { AppstoreOutlined, CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { useCallback, useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { FullscreenPreviewModal } from '../../components/previewer/FullscreenPreviewModal';
import {
removeMinimizedIsolatedChatRoomEntry,
upsertMinimizedIsolatedChatRoomEntry,
useActiveIsolatedChatRoomScope,
useMinimizedIsolatedChatRoomEntries,
writeActiveIsolatedChatRoomScope,
writeIsolatedChatRoomsWindowOpen,
} from './isolatedChatRoomScopeStore';
import './ScopedChatRoomsWindow.css';
const SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT = 'scoped-chat-rooms-window:action';
const MODAL_Z_INDEX = 1400;
const MINIMIZED_Z_INDEX = MODAL_Z_INDEX + 5;
type ScopedChatRoomsWindowProps = {
children: ReactNode;
onClose?: (() => void) | null;
};
export function requestScopedChatRoomsWindowAction(action: 'minimize' | 'close') {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(
new CustomEvent(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, {
detail: { action },
}),
);
}
export function ScopedChatRoomsWindow({ children, onClose = null }: ScopedChatRoomsWindowProps) {
const activeScope = useActiveIsolatedChatRoomScope();
const title = activeScope?.featureTitle?.trim() || activeScope?.menuTitle?.trim() || '시스템 채팅방';
const handleMinimize = useCallback(() => {
upsertMinimizedIsolatedChatRoomEntry(activeScope);
writeIsolatedChatRoomsWindowOpen(false);
}, [activeScope]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleWindowAction = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as { action?: 'minimize' | 'close' })
: null;
if (detail?.action === 'close') {
writeIsolatedChatRoomsWindowOpen(false);
onClose?.();
return;
}
if (detail?.action === 'minimize') {
handleMinimize();
}
};
window.addEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
return () => {
window.removeEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
};
}, [handleMinimize, onClose]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<FullscreenPreviewModal
open
hideHeader
zIndex={MODAL_Z_INDEX}
maskClosable={false}
className="scoped-chat-rooms-window__program-modal scoped-chat-rooms-window__program-modal--system-chat-room"
contentClassName="scoped-chat-rooms-window__program-modal-content"
fillContent
title={title}
onMinimize={handleMinimize}
onClose={() => {
writeIsolatedChatRoomsWindowOpen(false);
onClose?.();
}}
>
<div className="scoped-chat-rooms-window__program-app-shell scoped-chat-rooms-window__program-app-shell--system-chat-room">
{children}
</div>
</FullscreenPreviewModal>,
document.body,
);
}
export function ScopedChatRoomsWindowDock() {
const minimizedEntries = useMinimizedIsolatedChatRoomEntries();
if (typeof document === 'undefined' || minimizedEntries.length === 0) {
return null;
}
return createPortal(
<div className="scoped-chat-rooms-window__dock" style={{ zIndex: MINIMIZED_Z_INDEX }}>
{minimizedEntries.map((entry) => {
const title = entry.scope.featureTitle?.trim() || entry.scope.menuTitle?.trim() || '시스템 채팅방';
return (
<div key={entry.id} className="scoped-chat-rooms-window__program-minimized">
<div className="scoped-chat-rooms-window__program-minimized-drag">
<span className="scoped-chat-rooms-window__program-minimized-drag-grip" aria-hidden="true" />
<span className="scoped-chat-rooms-window__program-minimized-title">{title}</span>
</div>
<div className="scoped-chat-rooms-window__program-minimized-actions">
<Button
type="primary"
size="small"
icon={<AppstoreOutlined />}
className="scoped-chat-rooms-window__program-minimized-button"
onClick={() => {
writeActiveIsolatedChatRoomScope(entry.scope);
removeMinimizedIsolatedChatRoomEntry(entry.id);
writeIsolatedChatRoomsWindowOpen(true);
}}
>
</Button>
<Button
type="text"
size="small"
className="scoped-chat-rooms-window__program-minimized-icon scoped-chat-rooms-window__program-minimized-close"
icon={<CloseOutlined />}
aria-label="최소화 항목 닫기"
onClick={() => {
removeMinimizedIsolatedChatRoomEntry(entry.id);
}}
/>
</div>
</div>
);
})}
</div>,
document.body,
);
}

View File

@@ -0,0 +1,29 @@
.shared-app-settings-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
min-height: 100%;
background: #f7f8fb;
}
.shared-app-settings-page--loading {
align-items: center;
justify-content: center;
}
.shared-app-settings-page__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.shared-app-settings-page .ant-card {
border-radius: 16px;
}
.shared-app-settings-page .ant-card-body {
display: flex;
flex-direction: column;
gap: 4px;
}

View File

@@ -0,0 +1,252 @@
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Flex, Form, Input, InputNumber, Select, Space, Spin, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import {
DEFAULT_APP_CONFIG,
getWeeklyScheduleOptions,
saveAppConfigToServer,
type AppConfig,
type PlanCostTimeUnit,
type WeeklyScheduleDay,
} from './appConfig';
import './SharedAppSettingsPage.css';
const { Paragraph, Text, Title } = Typography;
const PLAN_COST_TIME_UNIT_OPTIONS: Array<{ value: PlanCostTimeUnit; label: string }> = [
{ value: 'hour', label: '시간' },
{ value: 'minute', label: '분' },
{ value: 'second', label: '초' },
];
type SharedAppSettingsPageProps = {
shareToken: string;
};
type SharedAppSettingsFormValue = AppConfig;
export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps) {
const { message } = App.useApp();
const [form] = Form.useForm<SharedAppSettingsFormValue>();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [savedConfig, setSavedConfig] = useState<AppConfig>(DEFAULT_APP_CONFIG);
const loadConfig = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await fetch('/api/app-config', {
headers: {
'X-Chat-Share-Token': shareToken,
},
cache: 'no-store',
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(payload?.message || '앱 설정을 불러오지 못했습니다.');
}
const payload = (await response.json()) as { config?: AppConfig };
const nextConfig = payload.config ?? DEFAULT_APP_CONFIG;
setSavedConfig(nextConfig);
form.setFieldsValue(nextConfig);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정을 불러오지 못했습니다.');
} finally {
setIsLoading(false);
}
}, [form, shareToken]);
useEffect(() => {
void loadConfig();
}, [loadConfig]);
const handleSave = useCallback(
async (values: SharedAppSettingsFormValue) => {
setIsSaving(true);
setErrorMessage('');
try {
const saved = await saveAppConfigToServer(values, {
shareToken,
skipAutomationNotifications: true,
});
setSavedConfig(saved);
form.setFieldsValue(saved);
message.success('앱 설정을 저장했습니다.');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
},
[form, message, shareToken],
);
if (isLoading) {
return (
<div className="shared-app-settings-page shared-app-settings-page--loading">
<Spin size="large" />
</div>
);
}
return (
<div className="shared-app-settings-page">
<Flex align="center" justify="space-between" gap={12} wrap>
<div>
<Title level={4}> </Title>
<Paragraph type="secondary">
.
</Paragraph>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadConfig()} disabled={isSaving}>
</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={() => void form.submit()} loading={isSaving}>
</Button>
</Space>
</Flex>
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
<Form<SharedAppSettingsFormValue>
form={form}
layout="vertical"
initialValues={savedConfig}
onFinish={(values) => void handleSave(values)}
>
<div className="shared-app-settings-page__grid">
<Card size="small" title="채팅 문맥 설정">
<Form.Item label="최근 메시지 수" name={['chat', 'maxContextMessages']}>
<InputNumber min={1} max={50} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="최대 문자 수" name={['chat', 'maxContextChars']}>
<InputNumber min={500} max={20000} step={100} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="Codex Live 최대 실행 시간(초)" name={['chat', 'codexLiveMaxExecutionSeconds']}>
<InputNumber min={60} max={7200} step={30} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="무출력 실패 시간(초)" name={['chat', 'codexLiveIdleTimeoutSeconds']}>
<InputNumber min={30} max={3600} step={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="재기동 완료 자동 실행 대기(초)" name={['chat', 'restartReservationCompletionDelaySeconds']}>
<InputNumber min={1} max={300} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['chat', 'receiveRoomNotifications']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</Card>
<Card size="small" title="자동접수 / 주기">
<Form.Item name={['automation', 'autoRefreshEnabled']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="자동 새로고침 간격(초)" name={['automation', 'autoRefreshIntervalSeconds']}>
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="자동접수 방식" name={['automation', 'autoReceiveScheduleType']}>
<Select
options={[
{ value: 'interval', label: '간격' },
{ value: 'daily', label: '매일' },
{ value: 'weekly', label: '매주' },
]}
/>
</Form.Item>
<Form.Item label="간격(초)" name={['automation', 'autoReceiveIntervalSeconds']}>
<InputNumber min={1} max={3600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="매일 시각" name={['automation', 'autoReceiveDailyTime']}>
<Input placeholder="09:00" />
</Form.Item>
<Form.Item label="매주 요일" name={['automation', 'autoReceiveWeeklyDay']}>
<Select options={getWeeklyScheduleOptions()} />
</Form.Item>
<Form.Item label="매주 시각" name={['automation', 'autoReceiveWeeklyTime']}>
<Input placeholder="09:00" />
</Form.Item>
</Card>
<Card size="small" title="자동화 기본값">
<Form.Item name={['planDefaults', 'jangsingProcessingRequired']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['planDefaults', 'autoDeployToMain']} valuePropName="checked">
<Checkbox>main </Checkbox>
</Form.Item>
<Form.Item name={['planDefaults', 'openEditorAfterCreate']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
</Card>
<Card size="small" title="업무일지 자동화">
<Form.Item name={['worklogAutomation', 'autoCreateDailyWorklog']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="생성 시각" name={['worklogAutomation', 'dailyCreateTime']}>
<Input placeholder="18:00" />
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeScreenshots']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeChangedFiles']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item name={['worklogAutomation', 'includeCommandLogs']} valuePropName="checked">
<Checkbox> </Checkbox>
</Form.Item>
<Form.Item label="템플릿" name={['worklogAutomation', 'template']}>
<Select
options={[
{ value: 'simple', label: '간단' },
{ value: 'detailed', label: '상세' },
]}
/>
</Form.Item>
</Card>
<Card size="small" title="비용 표시 / 단축키">
<Form.Item label="백만 토큰당 기본 비용" name={['planCost', 'baseCostPerMillionTokens']}>
<InputNumber min={100} max={1000000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="재시도 비용 배수(%)" name={['planCost', 'retryCostMultiplierPercent']}>
<InputNumber min={0} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="시간 비용 배수(%)" name={['planCost', 'hourlyCostMultiplierPercent']}>
<InputNumber min={0} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="시간 단위" name={['planCost', 'timeCostUnit']}>
<Select options={PLAN_COST_TIME_UNIT_OPTIONS} />
</Form.Item>
<Form.Item label="주의 배수" name={['planCost', 'attentionCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="경고 배수" name={['planCost', 'warningCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="고비용 배수" name={['planCost', 'highCostThresholdMultiplier']}>
<InputNumber min={0.1} max={100} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
<Input />
</Form.Item>
<Form.Item label="시스템 채팅 단축키" name={['gestureShortcuts', 'openWindowSearch']}>
<Input />
</Form.Item>
</Card>
</div>
</Form>
<Text type="secondary">
.
</Text>
</div>
);
}

View File

@@ -0,0 +1,246 @@
.shared-chat-management-page {
display: flex;
width: 100%;
height: 100%;
min-height: 100%;
min-width: 0;
flex-direction: column;
overflow: hidden;
}
.shared-chat-management-page .ant-card,
.shared-chat-management-page__card,
.shared-chat-management-page__card > .ant-card-body {
width: 100%;
height: 100%;
min-height: 100%;
min-width: 0;
}
.shared-chat-management-page__card {
flex: 1 1 auto;
}
.shared-chat-management-page .ant-card,
.shared-chat-management-page__card,
.shared-chat-management-page__card > .ant-card-body {
display: flex;
flex-direction: column;
}
.shared-chat-management-page__card > .ant-card-body {
min-height: 0;
overflow: hidden;
}
.shared-chat-management-page__layout {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 20px;
min-height: 0;
overflow: hidden;
}
.shared-chat-management-page__steps {
min-height: 0;
min-width: 0;
border-bottom: 1px solid #eef2f7;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 12px;
}
.shared-chat-management-page__steps .ant-steps {
min-width: 640px;
}
.shared-chat-management-page__content {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.shared-chat-management-page__stage {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: 16px;
}
.shared-chat-management-page__stage-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
flex-direction: column;
gap: 16px;
overflow: auto;
overscroll-behavior: contain;
padding-right: 4px;
padding-bottom: 8px;
-webkit-overflow-scrolling: touch;
}
.shared-chat-management-page__panel {
flex: 0 0 auto;
padding: 0;
}
.shared-chat-management-page__panel .ant-card-body {
padding: 0;
}
.shared-chat-management-page__option-list {
display: grid;
gap: 12px;
}
.shared-chat-management-page__option-card {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
gap: 10px;
border: 1px solid #d7deea;
border-radius: 18px;
background: #fff;
padding: 16px 18px;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.shared-chat-management-page__option-card:hover {
border-color: #8ab4ff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.shared-chat-management-page__option-card--active {
border-color: #1d4ed8;
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.12);
}
.shared-chat-management-page__option-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.shared-chat-management-page__field-grid,
.shared-chat-management-page__result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
}
.shared-chat-management-page__field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
}
.shared-chat-management-page__field > span {
color: #475569;
font-size: 13px;
font-weight: 600;
}
.shared-chat-management-page__field--full {
grid-column: 1 / -1;
}
.shared-chat-management-page__checkbox-row {
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 16px;
background: #f8fafc;
padding: 14px 16px;
}
.shared-chat-management-page__summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
border-radius: 18px;
background: linear-gradient(135deg, #f8fafc, #eef4ff);
padding: 16px 18px;
}
.shared-chat-management-page__summary-value {
margin-top: 6px;
color: #0f172a;
font-size: 16px;
font-weight: 700;
}
.shared-chat-management-page__tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.shared-chat-management-page__actions {
display: flex;
justify-content: space-between;
gap: 12px;
flex: 0 0 auto;
border-top: 1px solid #eef2f7;
padding-top: 16px;
padding-bottom: calc(4px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(255, 255, 255, 0), #fff 18px);
}
@media (max-width: 720px) {
.shared-chat-management-page__card > .ant-card-head {
padding-inline: 14px;
}
.shared-chat-management-page__card > .ant-card-body {
padding: 14px;
}
.shared-chat-management-page__layout {
gap: 14px;
}
.shared-chat-management-page__content {
gap: 14px;
}
.shared-chat-management-page__stage,
.shared-chat-management-page__stage-body {
gap: 14px;
}
.shared-chat-management-page__stage-body {
padding-right: 0;
padding-bottom: 12px;
}
.shared-chat-management-page__field-grid,
.shared-chat-management-page__result-grid,
.shared-chat-management-page__summary {
grid-template-columns: 1fr;
}
.shared-chat-management-page__actions {
flex-direction: column;
padding-top: 14px;
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
.shared-chat-management-page__actions .ant-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,494 @@
import { CheckCircleOutlined, CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Empty, Input, Result, Space, Steps, Tag, Typography } from 'antd';
import { useMemo, useState } from 'react';
import { useChatTypeRegistry } from './chatTypeAccess';
import { useTokenSettingRegistry, type TokenSettingRecord } from './tokenSettingAccess';
import { useTokenAccess } from './tokenAccess';
import { createManagedChatShareRoom, type ManagedChatShareRoom } from './mainChatPanel';
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
import { resolveChatPathForSession } from './isolatedChatRooms';
import { copyTextToClipboard } from '../../utils/clipboard';
import './SharedChatManagementPage.css';
const { Paragraph, Text, Title } = Typography;
type SharedChatDraft = {
tokenSettingId: string;
chatTypeId: string;
name: string;
requestBadgeLabel: string;
seedMessage: string;
allowManageAccess: boolean;
};
const DEFAULT_DRAFT: SharedChatDraft = {
tokenSettingId: '',
chatTypeId: '',
name: '',
requestBadgeLabel: '',
seedMessage: '이 공유채팅방에서 원하는 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.',
allowManageAccess: false,
};
function hasAllowedApp(setting: Pick<TokenSettingRecord, 'allowedAppIds'>, appId: string) {
return setting.allowedAppIds.some((item) => item.trim().toLowerCase() === appId);
}
function resolveAbsoluteUrl(pathname: string) {
if (typeof window === 'undefined') {
return pathname;
}
try {
return new URL(pathname, window.location.origin).toString();
} catch {
return pathname;
}
}
export function SharedChatManagementPage() {
const { message } = App.useApp();
const { hasAccess } = useTokenAccess();
const { tokenSettings, isLoading: isTokenSettingsLoading, errorMessage: tokenSettingsErrorMessage } = useTokenSettingRegistry();
const { chatTypes, isLoading: isChatTypesLoading, errorMessage: chatTypesErrorMessage } = useChatTypeRegistry();
const [currentStep, setCurrentStep] = useState(0);
const [draft, setDraft] = useState<SharedChatDraft>(DEFAULT_DRAFT);
const [createErrorMessage, setCreateErrorMessage] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [createdRoom, setCreatedRoom] = useState<(ManagedChatShareRoom & { shareUrl: string; conversationUrl: string }) | null>(null);
const availableTokenSettings = useMemo(
() => tokenSettings.filter((item) => item.enabled && hasAllowedApp(item, 'chat-live')),
[tokenSettings],
);
const availableChatTypes = useMemo(() => chatTypes.filter((item) => item.enabled), [chatTypes]);
const selectedTokenSetting = useMemo(
() => availableTokenSettings.find((item) => item.id === draft.tokenSettingId) ?? null,
[availableTokenSettings, draft.tokenSettingId],
);
const selectedChatType = useMemo(
() => availableChatTypes.find((item) => item.id === draft.chatTypeId) ?? null,
[availableChatTypes, draft.chatTypeId],
);
const canGrantManageAccess = useMemo(
() =>
Boolean(
selectedTokenSetting &&
(hasAllowedApp(selectedTokenSetting, 'chat-rooms') ||
hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
hasAllowedApp(selectedTokenSetting, 'token-setting') ||
hasAllowedApp(selectedTokenSetting, 'shared-resource')),
),
[selectedTokenSetting],
);
const suggestedShareName = useMemo(
() => (selectedChatType ? `${selectedChatType.name} 공유채팅` : ''),
[selectedChatType],
);
const stepItems = [
{ title: '공유 토큰' },
{ title: '채팅 유형' },
{ title: '채팅방 정보' },
{ title: 'URL 공유' },
];
const openManagedShareWindow = (url: string) => {
openExternalLinkInNewWindow(url, {
onUnsupportedStandalone: (fallbackUrl) => {
void copyTextToClipboard(fallbackUrl)
.then(() => {
message.info('현재 모바일 PWA에서는 preview 앱 열기도 막혀 URL을 복사했습니다. 브라우저에서 붙여 열어 주세요.');
})
.catch(() => {
message.info('현재 모바일 PWA에서는 새 창과 preview 앱 열기가 막힐 수 있습니다. URL 복사 후 브라우저에서 열어 주세요.');
});
},
});
};
const handleMoveNext = () => {
if (currentStep === 0 && !selectedTokenSetting) {
message.warning('공유 토큰 설정을 먼저 선택하세요.');
return;
}
if (currentStep === 1 && !selectedChatType) {
message.warning('채팅 유형을 먼저 선택하세요.');
return;
}
if (currentStep === 2) {
if (!draft.name.trim()) {
message.warning('공유 이름을 입력하세요.');
return;
}
if (!draft.seedMessage.trim()) {
message.warning('공유 시작 문구를 입력하세요.');
return;
}
}
setCurrentStep((previous) => Math.min(previous + 1, stepItems.length - 1));
};
const handleCreateRoom = async () => {
if (!selectedTokenSetting || !selectedChatType) {
message.warning('공유 토큰과 채팅 유형을 먼저 선택하세요.');
return;
}
const name = draft.name.trim();
const seedMessage = draft.seedMessage.trim();
if (!name || !seedMessage) {
message.warning('공유 이름과 공유 시작 문구를 입력하세요.');
return;
}
setIsCreating(true);
setCreateErrorMessage('');
try {
const created = await createManagedChatShareRoom({
tokenSettingId: selectedTokenSetting.id,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
name,
requestBadgeLabel: draft.requestBadgeLabel.trim() || null,
seedMessage,
allowManageAccess: draft.allowManageAccess && canGrantManageAccess,
});
const shareUrl = resolveAbsoluteUrl(created.sharePath);
const conversationUrl = resolveAbsoluteUrl(`${resolveChatPathForSession(created.sessionId)}?sessionId=${encodeURIComponent(created.sessionId)}`);
setCreatedRoom({
...created,
shareUrl,
conversationUrl,
});
setCurrentStep(3);
message.success('공유채팅방을 생성했습니다.');
} catch (error) {
setCreateErrorMessage(error instanceof Error ? error.message : '공유채팅방 생성에 실패했습니다.');
} finally {
setIsCreating(false);
}
};
const handleReset = () => {
setDraft(DEFAULT_DRAFT);
setCurrentStep(0);
setCreateErrorMessage('');
setCreatedRoom(null);
};
if (!hasAccess) {
return (
<Card title="공유채팅 생성" className="shared-chat-management-page">
<Alert
showIcon
type="warning"
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공유채팅을 생성하세요."
/>
</Card>
);
}
return (
<div className="shared-chat-management-page">
<Card
title="공유채팅 생성"
className="shared-chat-management-page__card"
extra={
createdRoom ? (
<Button icon={<PlusOutlined />} onClick={handleReset}>
</Button>
) : null
}
>
<div className="shared-chat-management-page__layout">
<div className="shared-chat-management-page__steps">
<Steps current={currentStep} items={stepItems} responsive={false} />
</div>
<div className="shared-chat-management-page__content">
<div className="shared-chat-management-page__stage">
{tokenSettingsErrorMessage ? <Alert showIcon type="error" message={tokenSettingsErrorMessage} /> : null}
{chatTypesErrorMessage ? <Alert showIcon type="error" message={chatTypesErrorMessage} /> : null}
{createErrorMessage ? <Alert showIcon type="error" message={createErrorMessage} /> : null}
<div className="shared-chat-management-page__stage-body">
{currentStep === 0 ? (
<Card bordered={false} className="shared-chat-management-page__panel">
<Title level={5}>1. </Title>
<Paragraph type="secondary">
`chat-live` . URL의 .
</Paragraph>
{availableTokenSettings.length > 0 ? (
<div className="shared-chat-management-page__option-list">
{availableTokenSettings.map((item) => {
const active = item.id === draft.tokenSettingId;
return (
<button
key={item.id}
type="button"
className={`shared-chat-management-page__option-card${active ? ' shared-chat-management-page__option-card--active' : ''}`}
onClick={() => {
setDraft((previous) => ({
...previous,
tokenSettingId: item.id,
allowManageAccess:
previous.allowManageAccess &&
(hasAllowedApp(item, 'chat-rooms') ||
hasAllowedApp(item, 'chat-room-settings') ||
hasAllowedApp(item, 'token-setting') ||
hasAllowedApp(item, 'shared-resource')),
}));
}}
>
<div className="shared-chat-management-page__option-head">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag>{item.id}</Tag>
<Tag color="blue">{`기본 ${item.defaultExpiresInMinutes > 0 ? `${item.defaultExpiresInMinutes}` : '무제한'}`}</Tag>
</Space>
</div>
<Text type="secondary">{item.description || '설명 없음'}</Text>
<div className="shared-chat-management-page__tag-row">
{item.allowedAppIds.map((appId) => (
<Tag key={`${item.id}-${appId}`}>{appId}</Tag>
))}
</div>
</button>
);
})}
</div>
) : (
<Empty
description={
isTokenSettingsLoading ? '공유 토큰 설정을 불러오는 중입니다.' : '공유채팅에 사용할 토큰 설정이 없습니다.'
}
/>
)}
</Card>
) : null}
{currentStep === 1 ? (
<Card bordered={false} className="shared-chat-management-page__panel">
<Title level={5}>2. </Title>
<Paragraph type="secondary">
URL에 .
</Paragraph>
{availableChatTypes.length > 0 ? (
<div className="shared-chat-management-page__option-list">
{availableChatTypes.map((item) => {
const active = item.id === draft.chatTypeId;
return (
<button
key={item.id}
type="button"
className={`shared-chat-management-page__option-card${active ? ' shared-chat-management-page__option-card--active' : ''}`}
onClick={() => {
setDraft((previous) => ({
...previous,
chatTypeId: item.id,
}));
}}
>
<div className="shared-chat-management-page__option-head">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Tag>{item.id}</Tag>
</Space>
</div>
<Text type="secondary">{item.description || '설명 없음'}</Text>
<div className="shared-chat-management-page__tag-row">
{item.permissions.map((permission) => (
<Tag key={`${item.id}-${permission}`}>{permission}</Tag>
))}
</div>
</button>
);
})}
</div>
) : (
<Empty description={isChatTypesLoading ? '채팅 유형을 불러오는 중입니다.' : '사용 가능한 채팅 유형이 없습니다.'} />
)}
</Card>
) : null}
{currentStep === 2 ? (
<Card bordered={false} className="shared-chat-management-page__panel">
<Title level={5}>3. </Title>
<div className="shared-chat-management-page__field-grid">
<label className="shared-chat-management-page__field">
<span> </span>
<Input
value={draft.name}
maxLength={160}
placeholder={suggestedShareName || '예: 공유 문구 검토방'}
onChange={(event) => setDraft((previous) => ({ ...previous, name: event.target.value }))}
/>
</label>
<label className="shared-chat-management-page__field">
<span> </span>
<Input value={selectedChatType?.name ?? ''} readOnly />
</label>
<label className="shared-chat-management-page__field">
<span> </span>
<Input
value={draft.requestBadgeLabel}
maxLength={120}
placeholder="선택 입력"
onChange={(event) => setDraft((previous) => ({ ...previous, requestBadgeLabel: event.target.value }))}
/>
</label>
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
<span> </span>
<Input.TextArea
rows={6}
value={draft.seedMessage}
placeholder="공유채팅방에 처음 보일 안내나 첫 질문을 입력하세요."
onChange={(event) => setDraft((previous) => ({ ...previous, seedMessage: event.target.value }))}
/>
</label>
</div>
<div className="shared-chat-management-page__checkbox-row">
<Checkbox
checked={draft.allowManageAccess && canGrantManageAccess}
disabled={!canGrantManageAccess}
onChange={(event) => setDraft((previous) => ({ ...previous, allowManageAccess: event.target.checked }))}
>
</Checkbox>
<Text type="secondary">
{canGrantManageAccess
? '선택 시 채팅방 설정 또는 연결된 관리 화면 접근이 열립니다.'
: '선택한 토큰 설정에는 노출 가능한 관리 앱 권한이 없습니다.'}
</Text>
</div>
<div className="shared-chat-management-page__summary">
<div>
<Text type="secondary"> </Text>
<div className="shared-chat-management-page__summary-value">{selectedTokenSetting?.name ?? '-'}</div>
</div>
<div>
<Text type="secondary"> </Text>
<div className="shared-chat-management-page__summary-value">{draft.name.trim() || '-'}</div>
</div>
<div>
<Text type="secondary"> </Text>
<div className="shared-chat-management-page__summary-value">
{selectedTokenSetting
? selectedTokenSetting.defaultExpiresInMinutes > 0
? `${selectedTokenSetting.defaultExpiresInMinutes}`
: '무제한'
: '-'}
</div>
</div>
<div>
<Text type="secondary"> </Text>
<div className="shared-chat-management-page__summary-value">
</div>
</div>
</div>
</Card>
) : null}
{currentStep === 3 ? (
<Card bordered={false} className="shared-chat-management-page__panel">
{createdRoom ? (
<Result
status="success"
icon={<CheckCircleOutlined />}
title="공유채팅방 URL이 준비되었습니다."
subTitle="이 URL로 외부 사용자가 같은 채팅방 흐름에 이어서 질문할 수 있습니다."
extra={
<Space wrap>
<Button
type="primary"
icon={<CopyOutlined />}
onClick={async () => {
await copyTextToClipboard(createdRoom.shareUrl);
message.success('공유 URL을 복사했습니다.');
}}
>
URL
</Button>
<Button
icon={<LinkOutlined />}
onClick={() => {
openManagedShareWindow(createdRoom.shareUrl);
}}
>
</Button>
<Button
onClick={() => {
openManagedShareWindow(createdRoom.conversationUrl);
}}
>
</Button>
</Space>
}
/>
) : (
<Alert showIcon type="info" message="생성된 공유채팅방이 없습니다. 이전 단계에서 생성을 완료하세요." />
)}
{createdRoom ? (
<div className="shared-chat-management-page__result-grid">
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
<span> </span>
<Input value={createdRoom.name} readOnly />
</label>
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
<span> URL</span>
<Input value={createdRoom.shareUrl} readOnly />
</label>
<label className="shared-chat-management-page__field shared-chat-management-page__field--full">
<span> </span>
<Input value={createdRoom.conversationUrl} readOnly />
</label>
<div className="shared-chat-management-page__field shared-chat-management-page__field--full">
<span> </span>
<div className="shared-chat-management-page__tag-row">
{createdRoom.permissions.map((permission) => (
<Tag key={permission}>{permission}</Tag>
))}
</div>
</div>
<label className="shared-chat-management-page__field">
<span> </span>
<Input value="공유 후 채팅방 설정에서 변경" readOnly />
</label>
</div>
) : null}
</Card>
) : null}
</div>
</div>
<div className="shared-chat-management-page__actions">
<Button onClick={() => setCurrentStep((previous) => Math.max(previous - 1, 0))} disabled={currentStep === 0 || isCreating}>
</Button>
{currentStep < 2 ? (
<Button type="primary" onClick={handleMoveNext}>
</Button>
) : currentStep === 2 ? (
<Button type="primary" loading={isCreating} onClick={() => void handleCreateRoom()}>
</Button>
) : null}
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,444 @@
@import './ManagementPage.shared.css';
.shared-resource-management-page,
.shared-resource-management-page__card {
width: 100%;
min-width: 0;
}
.shared-resource-management-page {
flex: 1 1 auto;
min-height: 0;
}
.shared-resource-management-page__summary-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: linear-gradient(180deg, #fcfdff 0%, #f8fbff 100%);
margin-bottom: 6px;
}
.shared-resource-management-page__summary-intro {
display: flex;
flex-direction: column;
min-width: 0;
}
.shared-resource-management-page__summary-intro .ant-typography {
margin-bottom: 0;
line-height: 1.2;
}
.shared-resource-management-page__summary-pills {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.shared-resource-management-page__summary-pill {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 6px;
min-width: 0;
padding: 4px 8px;
border: 1px solid #dbe5f0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
}
.shared-resource-management-page__summary-label {
color: #475569;
font-size: 11px;
white-space: nowrap;
}
.shared-resource-management-page__summary-value {
font-size: 14px;
font-weight: 700;
letter-spacing: -0.02em;
}
.shared-resource-management-page__item-stats,
.shared-resource-management-page__detail-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.shared-resource-management-page__table {
flex: 1;
min-height: 0;
}
.shared-resource-management-page__bulk-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fcfdff;
margin-bottom: 8px;
}
.shared-resource-management-page__table .ant-table-wrapper,
.shared-resource-management-page__table .ant-spin-nested-loading,
.shared-resource-management-page__table .ant-spin-container,
.shared-resource-management-page__table .ant-table,
.shared-resource-management-page__table .ant-table-container {
height: 100%;
}
.shared-resource-management-page__table .ant-table-body {
scrollbar-gutter: stable;
}
.shared-resource-management-page__table .ant-table-thead > tr > th {
padding-top: 7px;
padding-bottom: 7px;
font-size: 12px;
}
.shared-resource-management-page__table .ant-table-tbody > tr > td {
padding-top: 6px;
padding-bottom: 6px;
vertical-align: top;
}
.shared-resource-management-page__table-row {
cursor: default;
}
.shared-resource-management-page__table-row--active > td {
background: #f0f7ff !important;
}
.shared-resource-management-page__table-primary {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.shared-resource-management-page__table-title-row {
display: flex;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.shared-resource-management-page__table-title-row .ant-typography {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.shared-resource-management-page__table-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-content: flex-start;
}
.shared-resource-management-page__table-tags .ant-tag {
margin-inline-end: 0;
margin-bottom: 0;
padding-inline: 5px;
font-size: 11px;
line-height: 17px;
}
.shared-resource-management-page__table-metrics {
display: flex;
flex-direction: column;
gap: 2px;
}
.shared-resource-management-page__table-metrics .ant-typography {
margin-bottom: 0;
font-size: 12px;
}
.shared-resource-management-page__table-warning {
display: block;
margin-top: -1px;
font-size: 12px;
}
.shared-resource-management-page__status-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.shared-resource-management-page__detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.shared-resource-management-page__detail-block {
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-radius: 16px;
background: #f8fafc;
}
.shared-resource-management-page__detail-block .ant-typography:last-child {
margin-bottom: 0;
}
.shared-resource-management-page__detail-stack {
display: flex;
flex-direction: column;
gap: 12px;
}
.shared-resource-management-page__compact-panel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid #dbe5f0;
border-radius: 14px;
background: linear-gradient(180deg, #fcfdff 0%, #f5f9ff 100%);
}
.shared-resource-management-page__compact-panel-main {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.shared-resource-management-page__compact-panel-main .ant-typography {
margin-bottom: 0;
}
.shared-resource-management-page__compact-panel-side {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.shared-resource-management-page__compact-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 12px;
}
.shared-resource-management-page__compact-grid .ant-form-item {
margin-bottom: 0;
}
.shared-resource-management-page__compact-grid--summary {
align-items: stretch;
}
.shared-resource-management-page__field-span-2 {
grid-column: span 2;
min-width: 0;
}
.shared-resource-management-page__field-span-3 {
grid-column: 1 / -1;
min-width: 0;
}
.shared-resource-management-page__inline-option-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fbfdff;
}
.shared-resource-management-page__inline-option-item {
margin-bottom: 0;
}
.shared-resource-management-page__inline-option-item .ant-form-item-control-input {
min-height: auto;
}
.shared-resource-management-page__inline-option-item .ant-checkbox-wrapper {
font-weight: 600;
}
.shared-resource-management-page__detail-tabs {
display: flex;
flex: 1 1 auto;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.shared-resource-management-page__detail-tabs > .ant-tabs-nav {
margin-bottom: 10px;
}
.shared-resource-management-page__detail-tabs > .ant-tabs-nav-wrap {
padding-bottom: 2px;
}
.shared-resource-management-page__detail-tabs > .ant-tabs-content-holder,
.shared-resource-management-page__detail-tabs > .ant-tabs-content-holder > .ant-tabs-content,
.shared-resource-management-page__detail-tabs > .ant-tabs-content-holder > .ant-tabs-content > .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
.shared-resource-management-page__detail-tabs > .ant-tabs-content-holder {
overflow: hidden;
}
.shared-resource-management-page__section-scroll {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
min-height: 0;
height: 100%;
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 0 2px calc(12px + env(safe-area-inset-bottom, 0px));
}
.shared-resource-management-page__activity-card .ant-card-body {
padding-top: 12px;
}
.shared-resource-management-page__permission-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.shared-resource-management-page__permission-card {
display: block;
padding: 12px 14px;
border: 1px solid #dbe5f0;
border-radius: 14px;
background: #f8fafc;
}
.shared-resource-management-page__permission-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.shared-resource-management-page__permission-card-copy {
display: flex;
flex-direction: column;
gap: 2px;
}
.shared-resource-management-page__permission-card-copy .ant-typography {
margin-bottom: 0;
}
.shared-resource-management-page__qr-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.shared-resource-management-page__qr-code-wrap {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
border: 1px solid #dbe5f0;
border-radius: 18px;
background: #fff;
}
.shared-resource-management-page__qr-url {
width: 100%;
margin-bottom: 0;
}
.shared-resource-management-page__qr-url.ant-typography {
text-align: left;
}
@media (max-width: 1100px) {
.shared-resource-management-page__summary-strip {
align-items: flex-start;
flex-direction: column;
}
.shared-resource-management-page__summary-pills {
width: 100%;
justify-content: flex-start;
}
.shared-resource-management-page__detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shared-resource-management-page__compact-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shared-resource-management-page__field-span-3 {
grid-column: 1 / -1;
}
}
@media (max-width: 720px) {
.shared-resource-management-page__summary-pills {
width: 100%;
justify-content: flex-start;
}
.shared-resource-management-page__summary-pill {
flex: 1 1 calc(50% - 4px);
min-width: 132px;
}
.shared-resource-management-page__detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.shared-resource-management-page__compact-panel,
.shared-resource-management-page__inline-option-row,
.shared-resource-management-page__bulk-toolbar {
flex-direction: column;
align-items: stretch;
}
.shared-resource-management-page__compact-panel-side {
justify-content: flex-start;
}
.shared-resource-management-page__compact-grid,
.shared-resource-management-page__permission-grid {
grid-template-columns: minmax(0, 1fr);
}
.shared-resource-management-page__field-span-2,
.shared-resource-management-page__field-span-3 {
grid-column: auto;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
@import './mainChatPanel/styles/MainChatPanel.layout.css';
@import './mainChatPanel/styles/MainChatPanel.conversation.css';
@import './mainChatPanel/styles/MainChatPanel.preview-runtime.css';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
@import './ManagementPage.shared.css';
.token-setting-management-page__stats {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.token-setting-management-page__quota-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.token-setting-management-page__app-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
}
.token-setting-management-page__app-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
border: 1px solid #e5e7eb;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98));
}
.token-setting-management-page__app-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.token-setting-management-page__app-card-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.token-setting-management-page__helper {
margin-top: -2px;
}
.token-setting-management-page__detail-tabs,
.token-setting-management-page__detail-tabs .ant-tabs-content-holder,
.token-setting-management-page__detail-tabs .ant-tabs-content,
.token-setting-management-page__detail-tabs .ant-tabs-tabpane {
min-height: 0;
height: 100%;
}
.token-setting-management-page__detail-tabs {
flex: 1 1 auto;
overflow: hidden;
}
.token-setting-management-page__detail-tabs .ant-tabs-nav {
margin-bottom: 10px;
}
.token-setting-management-page__detail-tabs .ant-tabs-nav-wrap {
padding-bottom: 2px;
}
.token-setting-management-page__detail-tabs .ant-tabs-tab {
border-radius: 999px;
padding-inline: 14px;
}
.token-setting-management-page__detail-tabs .ant-tabs-content-holder {
overflow: hidden;
}
.token-setting-management-page__section-scroll {
height: 100%;
min-height: 0;
overflow: auto;
padding: 2px 2px calc(10px + env(safe-area-inset-bottom, 0px)) 0;
}
.token-setting-management-page__form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
padding: 12px 0 calc(4px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #e5e7eb;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), #ffffff 36%);
}
.token-setting-management-page__form-actions .ant-space {
flex-wrap: wrap;
}
@media (max-width: 900px) {
.token-setting-management-page__quota-grid,
.token-setting-management-page__app-grid {
grid-template-columns: minmax(0, 1fr);
}
.token-setting-management-page__detail-tabs .ant-tabs-nav {
margin-bottom: 8px;
}
.token-setting-management-page__detail-tabs .ant-tabs-nav-list {
gap: 4px;
}
.token-setting-management-page__detail-tabs .ant-tabs-tab {
padding-inline: 12px;
}
.token-setting-management-page__form-actions {
align-items: stretch;
}
.token-setting-management-page__form-actions .ant-space {
width: 100%;
}
.token-setting-management-page__form-actions .ant-space-item {
flex: 1 1 calc(50% - 4px);
}
.token-setting-management-page__form-actions .ant-btn {
width: 100%;
}
}

View File

@@ -0,0 +1,740 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Checkbox, Descriptions, Empty, Form, Input, InputNumber, List, Modal, Space, Switch, Tabs, Tag, Typography } from 'antd';
import { useEffect, useMemo, useRef, useState } from 'react';
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
import { confirmWithKeyboard } from './modalKeyboard';
import {
deleteTokenSetting,
type TokenSettingRecord,
upsertTokenSetting,
useTokenSettingRegistry,
} from './tokenSettingAccess';
import { useTokenAccess } from './tokenAccess';
import './TokenSettingManagementPage.css';
const { Paragraph, Text, Title } = Typography;
type TokenSettingFormValue = {
originalId?: string;
id: string;
name: string;
description: string;
defaultExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
enabled: boolean;
};
type AppOption = {
value: string;
label: string;
description: string;
category: '관리' | 'Play';
};
type SharedTokenSettingPreview = {
id: string;
name: string;
defaultExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
};
type SharedTokenSettingAccess = {
shareToken: string;
canManage: boolean;
};
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
];
const PLAY_APP_OPTIONS: AppOption[] = getReadyPlayAppEntries().map((entry) => ({
value: entry.id,
label: entry.name,
description: entry.searchDescription ?? `${entry.name} 앱 실행`,
category: 'Play',
}));
const APP_OPTIONS: AppOption[] = [...MANAGEMENT_APP_OPTIONS, ...PLAY_APP_OPTIONS];
const APP_OPTION_LABEL_MAP = new Map(APP_OPTIONS.map((item) => [item.value, item.label] as const));
const EMPTY_FORM_VALUE: TokenSettingFormValue = {
id: '',
name: '',
description: '',
defaultExpiresInMinutes: 60,
maxTokensPer30Days: 0,
maxTokensPer7Days: 100_000,
maxTokensPer5Hours: 100_000,
oneTimeTokenLimit: 0,
allowedAppIds: [],
enabled: true,
};
function normalizeSettingId(value: string | null | undefined) {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '');
}
function toFormValue(setting: TokenSettingRecord | null): TokenSettingFormValue {
if (!setting) {
return EMPTY_FORM_VALUE;
}
return {
originalId: setting.id,
id: setting.id,
name: setting.name,
description: setting.description,
defaultExpiresInMinutes: setting.defaultExpiresInMinutes,
maxTokensPer30Days: setting.maxTokensPer30Days,
maxTokensPer7Days: setting.maxTokensPer7Days,
maxTokensPer5Hours: setting.maxTokensPer5Hours,
oneTimeTokenLimit: setting.oneTimeTokenLimit,
allowedAppIds: setting.allowedAppIds,
enabled: setting.enabled,
};
}
function formatDuration(minutes: number) {
if (minutes <= 0) {
return '무제한';
}
if (minutes % (60 * 24) === 0) {
return `${minutes / (60 * 24)}`;
}
if (minutes % 60 === 0) {
return `${minutes / 60}시간`;
}
return `${minutes}`;
}
function formatTokenLimit(value: number) {
if (value <= 0) {
return '무제한';
}
return `${value.toLocaleString('ko-KR')} 토큰`;
}
function formatUnlimitedNumberInput(value: string | number | null | undefined, unit: string) {
if (value === null || value === undefined || value === '') {
return '';
}
const normalized = Number(String(value).replace(/[^\d.-]/g, ''));
if (!Number.isFinite(normalized)) {
return String(value);
}
if (normalized <= 0) {
return '무제한';
}
return `${normalized.toLocaleString('ko-KR')} ${unit}`;
}
function parseUnlimitedNumberInput(value: string | undefined) {
if (!value) {
return '';
}
if (value.includes('무제한')) {
return '0';
}
return value.replace(/[^\d]/g, '');
}
function formatQuotaSummary(setting: TokenSettingRecord) {
return [`7일 ${formatTokenLimit(setting.maxTokensPer7Days)}`, `5시간 ${formatTokenLimit(setting.maxTokensPer5Hours)}`].join(' / ');
}
function resolveAppLabels(appIds: string[]) {
return appIds.map((item) => APP_OPTION_LABEL_MAP.get(item) ?? item);
}
export function TokenSettingManagementPage({
sharedPreviewTokenSetting = null,
sharedAccess = null,
}: {
sharedPreviewTokenSetting?: SharedTokenSettingPreview | null;
sharedAccess?: SharedTokenSettingAccess | null;
}) {
const { message } = App.useApp();
const { hasAccess } = useTokenAccess();
const isSharedManageMode = !hasAccess && Boolean(sharedAccess?.canManage && sharedAccess.shareToken);
const isSharedPreviewMode = !hasAccess && Boolean(sharedPreviewTokenSetting) && !isSharedManageMode;
const sharedPreviewRecord = useMemo<TokenSettingRecord | null>(
() =>
sharedPreviewTokenSetting
? {
id: sharedPreviewTokenSetting.id,
name: sharedPreviewTokenSetting.name,
description: '',
defaultExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
maxExpiresInMinutes: sharedPreviewTokenSetting.defaultExpiresInMinutes,
maxTokensPer30Days: sharedPreviewTokenSetting.maxTokensPer30Days,
maxTokensPer7Days: sharedPreviewTokenSetting.maxTokensPer7Days,
maxTokensPer5Hours: sharedPreviewTokenSetting.maxTokensPer5Hours,
oneTimeTokenLimit: sharedPreviewTokenSetting.oneTimeTokenLimit,
allowedAppIds: sharedPreviewTokenSetting.allowedAppIds,
enabled: true,
updatedAt: '',
}
: null,
[sharedPreviewTokenSetting],
);
const { tokenSettings, setTokenSettings, isLoading, errorMessage } = useTokenSettingRegistry(
hasAccess || isSharedManageMode,
isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined,
);
const [selectedTokenSettingId, setSelectedTokenSettingId] = useState<string | null>(
sharedPreviewTokenSetting?.id ?? tokenSettings[0]?.id ?? null,
);
const [detailMode, setDetailMode] = useState<'list' | 'detail'>(sharedPreviewTokenSetting ? 'detail' : 'list');
const [isCreating, setIsCreating] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveErrorMessage, setSaveErrorMessage] = useState('');
const [saveSuccessMessage, setSaveSuccessMessage] = useState('');
const [activeDetailTab, setActiveDetailTab] = useState<'basic' | 'quota' | 'apps'>('basic');
const [form] = Form.useForm<TokenSettingFormValue>();
const [modalApi, modalContextHolder] = Modal.useModal();
const lastHydratedFormKeyRef = useRef('');
const selectedTokenSetting = useMemo(
() => tokenSettings.find((item) => item.id === selectedTokenSettingId) ?? null,
[selectedTokenSettingId, tokenSettings],
);
const effectiveSelectedTokenSetting =
selectedTokenSetting ?? (isSharedPreviewMode || isSharedManageMode ? sharedPreviewRecord : null);
useEffect(() => {
if (isSharedPreviewMode || isSharedManageMode) {
setSelectedTokenSettingId(sharedPreviewTokenSetting?.id ?? null);
if (detailMode !== 'detail') {
setDetailMode('detail');
}
return;
}
if (selectedTokenSettingId && tokenSettings.some((item) => item.id === selectedTokenSettingId)) {
return;
}
setSelectedTokenSettingId(tokenSettings[0]?.id ?? null);
}, [detailMode, isSharedManageMode, isSharedPreviewMode, selectedTokenSettingId, sharedPreviewTokenSetting?.id, tokenSettings]);
useEffect(() => {
if (detailMode !== 'detail') {
lastHydratedFormKeyRef.current = '';
return;
}
const nextFormKey = isCreating ? '__create__' : effectiveSelectedTokenSetting?.id ?? '__empty__';
if (lastHydratedFormKeyRef.current === nextFormKey) {
return;
}
lastHydratedFormKeyRef.current = nextFormKey;
form.resetFields();
form.setFieldsValue(toFormValue(isCreating ? null : effectiveSelectedTokenSetting));
}, [detailMode, effectiveSelectedTokenSetting?.id, form, isCreating]);
const openCreateForm = () => {
setIsCreating(true);
setSelectedTokenSettingId(null);
setDetailMode('detail');
setSaveErrorMessage('');
setSaveSuccessMessage('');
setActiveDetailTab('basic');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
};
const openDetail = (tokenSettingId: string) => {
setIsCreating(false);
setSelectedTokenSettingId(tokenSettingId);
setDetailMode('detail');
setSaveErrorMessage('');
setSaveSuccessMessage('');
setActiveDetailTab('basic');
};
const closeDetail = () => {
setIsCreating(false);
setDetailMode('list');
setSaveErrorMessage('');
setSaveSuccessMessage('');
};
const handleDelete = async () => {
if (!selectedTokenSetting) {
return;
}
const confirmed = await confirmWithKeyboard(modalApi, {
title: `"${selectedTokenSetting.name}" 토큰 설정을 삭제할까요?`,
okText: '삭제',
cancelText: '취소',
okButtonProps: { danger: true },
});
if (!confirmed) {
return;
}
const nextTokenSettings = deleteTokenSetting(tokenSettings, selectedTokenSetting.id);
setIsSaving(true);
setSaveErrorMessage('');
setSaveSuccessMessage('');
try {
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
setSelectedTokenSettingId(savedTokenSettings[0]?.id ?? null);
setIsCreating(false);
setDetailMode('list');
form.resetFields();
form.setFieldsValue(EMPTY_FORM_VALUE);
message.success('토큰 설정을 삭제했습니다.');
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 삭제에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
if (!hasAccess) {
if (!isSharedPreviewMode && !isSharedManageMode) {
return (
<>
{modalContextHolder}
<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' : ''}`}>
{modalContextHolder}
{detailMode === 'list' ? (
<Card
title="토큰 설정"
className="chat-type-management-page__card"
extra={
isSharedPreviewMode || isSharedManageMode ? null : (
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
</Button>
)
}
>
<div className="chat-type-management-page__list">
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
<div className="chat-type-management-page__list-header">
<Title level={5}> </Title>
<Text type="secondary">{isLoading ? '불러오는 중' : `${tokenSettings.length}`}</Text>
</div>
<Paragraph type="secondary" className="token-setting-management-page__helper">
ID를 , , 7/5 .
</Paragraph>
{tokenSettings.length > 0 ? (
<List
dataSource={tokenSettings}
renderItem={(item) => (
<List.Item
className={
item.id === selectedTokenSettingId
? 'chat-type-management-page__item chat-type-management-page__item--active'
: 'chat-type-management-page__item'
}
onClick={() => openDetail(item.id)}
actions={[
<Button
key="edit"
type="text"
icon={<EditOutlined />}
disabled={isSaving}
onClick={(event) => {
event.stopPropagation();
openDetail(item.id);
}}
/>,
]}
>
<div className="chat-type-management-page__item-main">
<Space size={[8, 8]} wrap>
<Text strong>{item.name}</Text>
<Text type="secondary">{item.id}</Text>
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
</Space>
<div className="token-setting-management-page__stats">
<Tag>{`유효시간 ${formatDuration(item.defaultExpiresInMinutes)}`}</Tag>
<Tag>{formatQuotaSummary(item)}</Tag>
<Tag>{`${item.allowedAppIds.length}`}</Tag>
</div>
<div className="chat-type-management-page__item-description">
{item.description || '설명 없음'}
</div>
<Space size={[6, 6]} wrap>
{resolveAppLabels(item.allowedAppIds).map((label) => (
<Tag key={`${item.id}-${label}`}>{label}</Tag>
))}
</Space>
</div>
</List.Item>
)}
/>
) : (
<Empty description="등록된 토큰 설정이 없습니다." />
)}
</div>
</Card>
) : (
<Card
title={isCreating ? '토큰 설정 등록' : '토큰 설정 상세'}
className="chat-type-management-page__card"
extra={
isSharedPreviewMode ? (
<Tag color="blue"> </Tag>
) : isSharedManageMode ? (
<Tag color="cyan"> </Tag>
) : (
<Space size={6} className="chat-type-management-page__header-actions" wrap>
<Button
type="primary"
shape="circle"
icon={<SaveOutlined />}
loading={isSaving}
aria-label={isCreating ? '등록' : '수정 저장'}
onClick={() => {
void form.submit();
}}
/>
<Button shape="circle" icon={<PlusOutlined />} disabled={isSaving} aria-label="새 입력" onClick={openCreateForm} />
{!isCreating && selectedTokenSetting ? (
<Button
danger
shape="circle"
icon={<DeleteOutlined />}
loading={isSaving}
aria-label="삭제"
onClick={() => void handleDelete()}
/>
) : null}
<Button shape="circle" icon={<UnorderedListOutlined />} aria-label="목록 가기" onClick={closeDetail} />
</Space>
)
}
>
<div className="chat-type-management-page__editor">
{isSharedPreviewMode ? (
<Alert
showIcon
type="info"
message="현재 공유 링크에 연결된 토큰 설정입니다."
description="허용된 앱과 한도를 이 화면에서 바로 확인할 수 있습니다."
/>
) : null}
{isSharedManageMode ? (
<Alert
showIcon
type="info"
message="현재 공유 링크에 연결된 토큰 설정을 관리 중입니다."
description="이 공유 링크에 연결된 설정 1건만 수정할 수 있습니다."
/>
) : null}
{(!isSharedPreviewMode || isSharedManageMode) && errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
{saveSuccessMessage ? <Alert showIcon type="success" message={saveSuccessMessage} /> : null}
<Form
className="chat-type-management-page__editor-form"
disabled={isSharedPreviewMode}
layout="vertical"
form={form}
initialValues={EMPTY_FORM_VALUE}
scrollToFirstError
onFinishFailed={() => {
setSaveSuccessMessage('');
setSaveErrorMessage('필수 입력값과 권한 앱 선택을 확인해 주세요.');
}}
onFinish={async (values) => {
const nextTokenSettings = upsertTokenSetting(tokenSettings, values);
const isNewSetting = isCreating;
setIsSaving(true);
setSaveErrorMessage('');
setSaveSuccessMessage('');
try {
const savedTokenSettings = await setTokenSettings(nextTokenSettings);
const normalizedSavedId = normalizeSettingId(values.id);
const savedTokenSetting =
savedTokenSettings.find((item) => item.id === normalizedSavedId) ??
savedTokenSettings.find((item) => item.id === normalizeSettingId(values.originalId));
setIsCreating(false);
setSelectedTokenSettingId(savedTokenSetting?.id ?? null);
setDetailMode('detail');
const nextSuccessMessage = isNewSetting ? '토큰 설정을 등록했습니다.' : '토큰 설정을 저장했습니다.';
setSaveSuccessMessage(nextSuccessMessage);
message.success(nextSuccessMessage);
} catch (error) {
setSaveErrorMessage(error instanceof Error ? error.message : '토큰 설정 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
}}
>
<Form.Item name="originalId" hidden>
<Input />
</Form.Item>
<div className="chat-type-management-page__editor-scroll">
<Tabs
activeKey={activeDetailTab}
onChange={(key) => setActiveDetailTab(key as 'basic' | 'quota' | 'apps')}
className="token-setting-management-page__detail-tabs"
items={[
{
key: 'basic',
label: '기본 정보',
children: (
<div className="token-setting-management-page__section-scroll">
{isSharedPreviewMode && sharedPreviewRecord ? (
<Descriptions column={1} bordered size="small" className="token-setting-management-page__summary-descriptions">
<Descriptions.Item label="설정 ID">{sharedPreviewRecord.id}</Descriptions.Item>
<Descriptions.Item label="설정명">{sharedPreviewRecord.name}</Descriptions.Item>
<Descriptions.Item label="유효시간">{formatDuration(sharedPreviewRecord.defaultExpiresInMinutes)}</Descriptions.Item>
<Descriptions.Item label="앱 개수">{`${sharedPreviewRecord.allowedAppIds.length}`}</Descriptions.Item>
</Descriptions>
) : (
<div className="chat-type-management-page__meta-grid">
<Form.Item
className="chat-type-management-page__meta-item"
label="설정 ID"
name="id"
extra={isSharedManageMode ? '공유 링크 관리 모드에서는 설정 ID를 변경할 수 없습니다.' : undefined}
rules={[
{ required: true, message: '설정 ID를 입력하세요.' },
{
validator: async (_rule, value) => {
const normalized = normalizeSettingId(value);
if (!normalized) {
throw new Error('영문, 숫자, `-`, `_`, `.` 조합으로 입력하세요.');
}
},
},
]}
>
<Input placeholder="예: photoprism-basic" disabled={isSharedManageMode} />
</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>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="설정명"
name="name"
rules={[{ required: true, message: '설정명을 입력하세요.' }]}
>
<Input placeholder="예: PhotoPrism 읽기 전용" />
</Form.Item>
<Form.Item
className="chat-type-management-page__meta-item chat-type-management-page__meta-item--name"
label="설명"
name="description"
>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 10 }}
placeholder="이 설정으로 발급할 토큰의 용도와 제한을 적어 두세요."
/>
</Form.Item>
</div>
)}
</div>
),
},
{
key: 'quota',
label: '만료·한도',
children: (
<div className="token-setting-management-page__section-scroll">
<div className="token-setting-management-page__quota-grid">
<Form.Item
label="유효시간(분)"
name="defaultExpiresInMinutes"
extra="0이면 만료 없이 계속 사용할 수 있습니다."
rules={[{ required: true, message: '기본 유효시간을 입력하세요.' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
formatter={(value) => formatUnlimitedNumberInput(value, '분')}
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
/>
</Form.Item>
<Form.Item
label="7일 사용 가능 토큰"
name="maxTokensPer7Days"
extra="최근 7일 동안 누적 사용할 수 있는 총 토큰입니다."
rules={[{ required: true, message: '7일 토큰 한도를 입력하세요.' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
formatter={(value) => formatUnlimitedNumberInput(value, '토큰')}
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
/>
</Form.Item>
<Form.Item
label="5시간 사용 가능 토큰"
name="maxTokensPer5Hours"
extra="최근 5시간 동안 누적 사용할 수 있는 총 토큰입니다."
rules={[{ required: true, message: '5시간 토큰 한도를 입력하세요.' }]}
>
<InputNumber
min={0}
style={{ width: '100%' }}
formatter={(value) => formatUnlimitedNumberInput(value, '토큰')}
parser={((value: string | undefined) => Number(parseUnlimitedNumberInput(value) || 0)) as unknown as (displayValue: string | undefined) => 0}
/>
</Form.Item>
</div>
</div>
),
},
{
key: 'apps',
label: `앱 권한 ${APP_OPTIONS.length}`,
children: (
<div className="token-setting-management-page__section-scroll">
<Form.Item
label="실행 가능 앱"
name="allowedAppIds"
rules={[
{
validator: async (_rule, value) => {
if (Array.isArray(value) && value.length > 0) {
return;
}
throw new Error('최소 1개 이상의 앱 권한을 선택하세요.');
},
},
]}
>
<Checkbox.Group style={{ width: '100%' }}>
<div className="token-setting-management-page__app-grid">
{APP_OPTIONS.map((option) => (
<div key={option.value} className="token-setting-management-page__app-card">
<div className="token-setting-management-page__app-card-header">
<div className="token-setting-management-page__app-card-title">
<Text strong>{option.label}</Text>
<Text type="secondary">{option.value}</Text>
</div>
<Tag>{option.category}</Tag>
</div>
<Text type="secondary">{option.description}</Text>
<Checkbox value={option.value}> </Checkbox>
</div>
))}
</div>
</Checkbox.Group>
</Form.Item>
</div>
),
},
]}
/>
</div>
{isSharedPreviewMode ? (
<div className="token-setting-management-page__form-actions">
<Text type="secondary"> 1 .</Text>
</div>
) : isSharedManageMode ? (
<div className="token-setting-management-page__form-actions">
<Space size={8} wrap>
<Button
type="primary"
icon={<SaveOutlined />}
loading={isSaving}
onClick={() => {
void form.submit();
}}
>
</Button>
</Space>
<Text type="secondary"> 1 .</Text>
</div>
) : (
<div className="token-setting-management-page__form-actions">
<Space size={8} wrap>
<Button
type="primary"
icon={<SaveOutlined />}
loading={isSaving}
onClick={() => {
void form.submit();
}}
>
{isCreating ? '등록' : '저장'}
</Button>
<Button icon={<PlusOutlined />} disabled={isSaving} onClick={openCreateForm}>
</Button>
{!isCreating && selectedTokenSetting ? (
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
</Button>
) : null}
<Button icon={<UnorderedListOutlined />} onClick={closeDetail}>
</Button>
</Space>
<Text type="secondary"> .</Text>
</div>
)}
</Form>
</div>
</Card>
)}
</div>
);
}

View File

@@ -495,6 +495,11 @@ function resolveAppConfigFallbackBaseUrl() {
const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl(); const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl(); const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
type AppConfigRequestOptions = {
shareToken?: string | null;
skipAutomationNotifications?: boolean;
};
class AppConfigApiError extends Error { class AppConfigApiError extends Error {
status: number; status: number;
@@ -505,12 +510,18 @@ class AppConfigApiError extends Error {
} }
} }
async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> { async function requestAppConfigOnce<T>(
baseUrl: string,
path: string,
init?: RequestInit,
options?: AppConfigRequestOptions,
): Promise<T> {
const headers = appendClientIdHeader(init?.headers); const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init.body !== null; const hasBody = init?.body !== undefined && init.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET'; const method = init?.method?.toUpperCase() ?? 'GET';
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS);
const shareToken = options?.shareToken?.trim() ?? '';
if (hasBody && !headers.has('Content-Type')) { if (hasBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json'); headers.set('Content-Type', 'application/json');
@@ -527,6 +538,10 @@ async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: Req
headers.set('X-App-Domain', appDomain); headers.set('X-App-Domain', appDomain);
} }
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
let response: Response; let response: Response;
try { try {
@@ -566,9 +581,9 @@ async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: Req
return response.json() as Promise<T>; return response.json() as Promise<T>;
} }
async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T> { async function requestAppConfig<T>(path: string, init?: RequestInit, options?: AppConfigRequestOptions): Promise<T> {
try { try {
return await requestAppConfigOnce<T>(APP_CONFIG_API_BASE_URL, path, init); return await requestAppConfigOnce<T>(APP_CONFIG_API_BASE_URL, path, init, options);
} catch (error) { } catch (error) {
const shouldRetryWithFallback = const shouldRetryWithFallback =
APP_CONFIG_FALLBACK_BASE_URL && APP_CONFIG_FALLBACK_BASE_URL &&
@@ -581,15 +596,15 @@ async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T>
throw error; throw error;
} }
return requestAppConfigOnce<T>(APP_CONFIG_FALLBACK_BASE_URL, path, init); return requestAppConfigOnce<T>(APP_CONFIG_FALLBACK_BASE_URL, path, init, options);
} }
} }
export async function fetchAppConfigFromServer() { export async function fetchAppConfigFromServer(options?: AppConfigRequestOptions) {
try { try {
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH); const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH, undefined, options);
const config = normalizeConfig(response.config); const config = normalizeConfig(response.config);
const preference = await fetchAutomationNotificationPreferenceFromServer(); const preference = options?.skipAutomationNotifications ? null : await fetchAutomationNotificationPreferenceFromServer();
return mergeAutomationNotificationSettings(config, preference); return mergeAutomationNotificationSettings(config, preference);
} catch { } catch {
return null; return null;
@@ -610,11 +625,11 @@ async function fetchAutomationNotificationPreferenceFromServer() {
} }
} }
export async function saveAppConfigToServer(config: AppConfig) { export async function saveAppConfigToServer(config: AppConfig, options?: AppConfigRequestOptions) {
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH, { const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ config }), body: JSON.stringify({ config }),
}); }, options);
return normalizeConfig(response.config); return normalizeConfig(response.config);
} }
@@ -635,8 +650,8 @@ export async function saveAutomationNotificationPreferenceToServer(config: AppCo
return mergeAutomationNotificationSettings(config, response.automation); return mergeAutomationNotificationSettings(config, response.automation);
} }
export async function syncAppConfigFromServer() { export async function syncAppConfigFromServer(options?: AppConfigRequestOptions) {
const config = await fetchAppConfigFromServer(); const config = await fetchAppConfigFromServer(options);
if (!config) { if (!config) {
return false; return false;
@@ -685,8 +700,12 @@ export function setStoredAppConfig(config: AppConfig) {
const raw = JSON.stringify(normalized); const raw = JSON.stringify(normalized);
cachedConfig = normalized; cachedConfig = normalized;
cachedRawConfig = raw; cachedRawConfig = raw;
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage; try {
storage.setItem(APP_CONFIG_STORAGE_KEY, raw); const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
storage.setItem(APP_CONFIG_STORAGE_KEY, raw);
} catch {
// Ignore storage failures and keep the in-memory config for the current session.
}
emitConfigChange(); emitConfigChange();
} }

View File

@@ -0,0 +1,65 @@
import { useSyncExternalStore } from 'react';
export type ChatActionContextSnapshot = {
sourceAppId: string | null;
featureTitle: string | null;
selectionSummary: string | null;
selectionIds: string[];
};
let snapshot: ChatActionContextSnapshot = {
sourceAppId: null,
featureTitle: null,
selectionSummary: null,
selectionIds: [],
};
const listeners = new Set<() => void>();
function emit() {
listeners.forEach((listener) => listener());
}
export function publishChatActionContext(next: Partial<ChatActionContextSnapshot>) {
snapshot = {
sourceAppId: typeof next.sourceAppId === 'string' ? next.sourceAppId.trim() || null : snapshot.sourceAppId,
featureTitle: typeof next.featureTitle === 'string' ? next.featureTitle.trim() || null : snapshot.featureTitle,
selectionSummary:
typeof next.selectionSummary === 'string' ? next.selectionSummary.trim() || null : snapshot.selectionSummary,
selectionIds: Array.isArray(next.selectionIds)
? next.selectionIds.map((item) => String(item).trim()).filter(Boolean)
: snapshot.selectionIds,
};
emit();
}
export function clearChatActionContext(sourceAppId?: string | null) {
if (sourceAppId && snapshot.sourceAppId && snapshot.sourceAppId !== sourceAppId) {
return;
}
snapshot = {
sourceAppId: null,
featureTitle: null,
selectionSummary: null,
selectionIds: [],
};
emit();
}
export function getChatActionContextSnapshot() {
return snapshot;
}
export function useChatActionContextSnapshot() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
() => snapshot,
() => snapshot,
);
}

View File

@@ -16,11 +16,22 @@ export type ChatTypeDefaultContextSelection = {
updatedAt: string; updatedAt: string;
}; };
export type ChatRoomCodexParticipant = {
id: string;
name: string;
model: string;
prompt: string;
chatTypeId: string | null;
defaultContextIds: string[];
role: 'default' | 'moderator' | 'conversation' | 'reviewer';
};
export type ChatRoomContextSettings = { export type ChatRoomContextSettings = {
sessionId: string; sessionId: string;
defaultContextIds: string[]; defaultContextIds: string[];
customContextTitle: string; customContextTitle: string;
customContextContent: string; customContextContent: string;
codexParticipants: ChatRoomCodexParticipant[];
updatedAt: string; updatedAt: string;
}; };
@@ -84,6 +95,48 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
return Array.from(new Set((defaultContextIds ?? []).map((item) => normalizeText(item)).filter(Boolean))); return Array.from(new Set((defaultContextIds ?? []).map((item) => normalizeText(item)).filter(Boolean)));
} }
function sanitizeCodexParticipants(items: Partial<ChatRoomCodexParticipant>[] | null | undefined) {
return Array.from(
new Map(
(items ?? [])
.map((item, index) => {
const name = normalizeText(item.name);
const model = normalizeText(item.model);
const prompt = normalizeText(item.prompt);
const chatTypeId = normalizeText(item.chatTypeId) || null;
const defaultContextIds = normalizeDefaultContextIds(item.defaultContextIds);
const role =
normalizeText(item.role) === 'moderator'
? 'moderator'
: normalizeText(item.role) === 'conversation'
? 'conversation'
: normalizeText(item.role) === 'reviewer'
? 'reviewer'
: 'default';
const id = normalizeText(item.id) || `codex-participant-${index + 1}`;
if (!name || !model) {
return null;
}
return [
id,
{
id,
name,
model,
prompt,
chatTypeId,
defaultContextIds,
role,
} satisfies ChatRoomCodexParticipant,
] as const;
})
.filter((item): item is readonly [string, ChatRoomCodexParticipant] => Boolean(item)),
).values(),
);
}
function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) { function sanitizeDefaultContexts(items: Partial<ChatDefaultContextRecord>[] | null | undefined) {
const byId = new Map<string, ChatDefaultContextRecord>(); const byId = new Map<string, ChatDefaultContextRecord>();
@@ -169,13 +222,15 @@ function sanitizeRoomContexts(items: Partial<ChatRoomContextSettings>[] | null |
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds), defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
customContextTitle: normalizeText(item.customContextTitle), customContextTitle: normalizeText(item.customContextTitle),
customContextContent: normalizeText(item.customContextContent), customContextContent: normalizeText(item.customContextContent),
codexParticipants: sanitizeCodexParticipants(item.codexParticipants),
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(), updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
}; };
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent); const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0; const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
const hasCodexParticipants = nextRecord.codexParticipants.length > 0;
if (!hasCustomContext && !hasDefaultOverrides) { if (!hasCustomContext && !hasDefaultOverrides && !hasCodexParticipants) {
return; return;
} }
@@ -564,6 +619,7 @@ export function upsertChatRoomContextSettings(
defaultContextIds: normalizeDefaultContextIds(input.defaultContextIds), defaultContextIds: normalizeDefaultContextIds(input.defaultContextIds),
customContextTitle: normalizeText(input.customContextTitle), customContextTitle: normalizeText(input.customContextTitle),
customContextContent: normalizeText(input.customContextContent), customContextContent: normalizeText(input.customContextContent),
codexParticipants: sanitizeCodexParticipants(input.codexParticipants),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };

View File

@@ -2,12 +2,29 @@ import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity'; import { appendClientIdHeader } from './clientIdentity';
export type ChatPermissionRole = 'guest' | 'token-user'; export type ChatPermissionRole = 'guest' | 'token-user';
export type ChatTypeExecutionMode = 'default' | 'summary-free-talking' | 'dispatcher-workers';
export type ChatTypeReviewPolicy = 'self' | 'reviewer';
export type ChatTypeResourceReportPolicy = 'none' | 'if-generated' | 'always';
export type ChatTypeParticipantBinding =
| 'manual'
| 'first-moderator-rest-conversation'
| 'first-moderator-rest-conversation-last-reviewer';
export type ChatTypeExecutionPolicy = {
mode: ChatTypeExecutionMode;
participantBinding: ChatTypeParticipantBinding;
reviewPolicy: ChatTypeReviewPolicy;
resourceReportPolicy: ChatTypeResourceReportPolicy;
allowModeratorIntervention: boolean;
finalSummaryRequired: boolean;
};
export type ChatTypeRecord = { export type ChatTypeRecord = {
id: string; id: string;
name: string; name: string;
sortOrder: number; sortOrder: number;
description: string; description: string;
executionPolicy: ChatTypeExecutionPolicy;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled: boolean; enabled: boolean;
updatedAt: string; updatedAt: string;
@@ -22,6 +39,7 @@ export type ChatTypeInput = {
name: string; name: string;
sortOrder?: number; sortOrder?: number;
description?: string; description?: string;
executionPolicy?: Partial<ChatTypeExecutionPolicy>;
permissions: ChatPermissionRole[]; permissions: ChatPermissionRole[];
enabled?: boolean; enabled?: boolean;
}; };
@@ -53,6 +71,80 @@ function normalizePermissions(permissions: ChatPermissionRole[] | null | undefin
return nextPermissions.length > 0 ? nextPermissions : (['token-user'] as ChatPermissionRole[]); return nextPermissions.length > 0 ? nextPermissions : (['token-user'] as ChatPermissionRole[]);
} }
export function createDefaultChatTypeExecutionPolicy(
mode: ChatTypeExecutionMode = 'default',
): ChatTypeExecutionPolicy {
if (mode === 'summary-free-talking') {
return {
mode,
participantBinding: 'first-moderator-rest-conversation',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: false,
finalSummaryRequired: true,
};
}
if (mode === 'dispatcher-workers') {
return {
mode,
participantBinding: 'first-moderator-rest-conversation',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: true,
};
}
return {
mode,
participantBinding: 'manual',
reviewPolicy: 'self',
resourceReportPolicy: 'if-generated',
allowModeratorIntervention: true,
finalSummaryRequired: false,
};
}
function normalizeExecutionMode(value: string | null | undefined): ChatTypeExecutionMode {
if (value === 'summary-free-talking' || value === 'dispatcher-workers') {
return value;
}
return 'default';
}
function normalizeExecutionPolicy(
value: Partial<ChatTypeExecutionPolicy> | ChatTypeExecutionPolicy | null | undefined,
): ChatTypeExecutionPolicy {
const mode = normalizeExecutionMode(value?.mode);
const defaults = createDefaultChatTypeExecutionPolicy(mode);
const participantBinding =
value?.participantBinding === 'first-moderator-rest-conversation' ||
value?.participantBinding === 'first-moderator-rest-conversation-last-reviewer' ||
value?.participantBinding === 'manual'
? value.participantBinding
: defaults.participantBinding;
const reviewPolicy = value?.reviewPolicy === 'reviewer' ? 'reviewer' : defaults.reviewPolicy;
const resourceReportPolicy =
value?.resourceReportPolicy === 'none' || value?.resourceReportPolicy === 'always'
? value.resourceReportPolicy
: defaults.resourceReportPolicy;
return {
mode,
participantBinding,
reviewPolicy,
resourceReportPolicy,
allowModeratorIntervention:
typeof value?.allowModeratorIntervention === 'boolean'
? value.allowModeratorIntervention
: defaults.allowModeratorIntervention,
finalSummaryRequired:
typeof value?.finalSummaryRequired === 'boolean' ? value.finalSummaryRequired : defaults.finalSummaryRequired,
};
}
function normalizeSortOrder(value: number | null | undefined) { function normalizeSortOrder(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) { if (typeof value !== 'number' || !Number.isFinite(value)) {
return null; return null;
@@ -82,6 +174,7 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): NormalizedChatTypeC
name, name,
sortOrder: normalizeSortOrder(record.sortOrder), sortOrder: normalizeSortOrder(record.sortOrder),
description: normalizeText(record.description), description: normalizeText(record.description),
executionPolicy: normalizeExecutionPolicy(record.executionPolicy),
permissions: normalizePermissions(record.permissions), permissions: normalizePermissions(record.permissions),
enabled: record.enabled !== false, enabled: record.enabled !== false,
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(), updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
@@ -276,6 +369,7 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
name: input.name, name: input.name,
sortOrder: input.sortOrder, sortOrder: input.sortOrder,
description: input.description, description: input.description,
executionPolicy: normalizeExecutionPolicy(input.executionPolicy),
permissions: input.permissions, permissions: input.permissions,
enabled: input.enabled, enabled: input.enabled,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View File

@@ -7,10 +7,15 @@ export type DefaultChatTypeRecord = {
updatedAt: string; updatedAt: string;
}; };
export const CODEX_LIVE_DEFAULT_CHAT_TYPE_ID = 'codex-live-default';
export const CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME = '기본처리';
export const CODEX_LIVE_DEFAULT_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- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type=\"resource\"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type=\"html\"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인, 화면 테스트, 최종 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request'; export const GENERAL_REQUEST_CHAT_TYPE_ID = 'general-request';
export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청'; export const GENERAL_REQUEST_CHAT_TYPE_NAME = '일반 요청';
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION = 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- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` 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- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.'; '## 기본 처리\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- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type=\"resource\"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type=\"html\"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인, 화면 테스트, 최종 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- `https://test.sm-home.cloud/`는 운영 비교나 프록시 점검이 꼭 필요할 때만 보조적으로 확인합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.';
export const MD_CONTEXT_MANAGED_CHAT_TYPE_ID = 'md-context-managed'; export const MD_CONTEXT_MANAGED_CHAT_TYPE_ID = 'md-context-managed';
export const MD_CONTEXT_MANAGED_CHAT_TYPE_NAME = 'MD 기준 관리'; export const MD_CONTEXT_MANAGED_CHAT_TYPE_NAME = 'MD 기준 관리';
@@ -43,6 +48,14 @@ 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는 사용하지 않습니다.'; '## 기본 원칙\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[] = [ export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [
{
id: CODEX_LIVE_DEFAULT_CHAT_TYPE_ID,
name: CODEX_LIVE_DEFAULT_CHAT_TYPE_NAME,
description: CODEX_LIVE_DEFAULT_CHAT_TYPE_DESCRIPTION,
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-05-25T00:00:00.000Z',
},
{ {
id: GENERAL_REQUEST_CHAT_TYPE_ID, id: GENERAL_REQUEST_CHAT_TYPE_ID,
name: GENERAL_REQUEST_CHAT_TYPE_NAME, name: GENERAL_REQUEST_CHAT_TYPE_NAME,

View File

@@ -1,8 +1,10 @@
import { Button, Empty, Input, List, Spin, Typography } from 'antd'; import { Button, Empty, Input, List, Spin, Typography } from 'antd';
import type { ChatConversationSummary } from '../../mainChatPanel/types'; import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { shouldShowConversationForMode } from '../../isolatedChatRooms';
const { Text } = Typography; const { Text } = Typography;
type ConversationListPaneProps = { type ConversationListPaneProps = {
items: ChatConversationSummary[]; items: ChatConversationSummary[];
isLoading: boolean; isLoading: boolean;
@@ -24,13 +26,15 @@ export function ConversationListPane({
onSelectSession, onSelectSession,
onCreateConversation, onCreateConversation,
}: ConversationListPaneProps) { }: ConversationListPaneProps) {
const visibleItems = items.filter((item) => shouldShowConversationForMode(item.sessionId, 'live'));
return ( return (
<section className="chat-v2__pane chat-v2__pane--list"> <section className="chat-v2__pane chat-v2__pane--list">
<div className="chat-v2__pane-header"> <div className="chat-v2__pane-header">
<div> <div>
<Text strong> </Text> <Text strong> </Text>
<br /> <br />
<Text type="secondary">{items.length} </Text> <Text type="secondary">{visibleItems.length} </Text>
</div> </div>
<Button type="primary" onClick={onCreateConversation}> <Button type="primary" onClick={onCreateConversation}>
@@ -54,14 +58,14 @@ export function ConversationListPane({
<div className="chat-v2__state"> <div className="chat-v2__state">
<Text type="danger">{errorMessage}</Text> <Text type="danger">{errorMessage}</Text>
</div> </div>
) : items.length === 0 ? ( ) : visibleItems.length === 0 ? (
<div className="chat-v2__state"> <div className="chat-v2__state">
<Empty description="대화가 없습니다." /> <Empty description="대화가 없습니다." />
</div> </div>
) : ( ) : (
<List <List
className="chat-v2__conversation-list" className="chat-v2__conversation-list"
dataSource={items} dataSource={visibleItems}
renderItem={(item) => ( renderItem={(item) => (
<List.Item> <List.Item>
<button <button

View File

@@ -21,6 +21,14 @@ const GENERAL_REQUEST_OPTION = [
}, },
]; ];
const GENERAL_CODEX_MODEL_OPTION = [
{
value: 'gpt-5.4',
label: 'GPT-5.4',
description: '기본 모델',
},
];
export function ConversationRoomPane({ export function ConversationRoomPane({
sessionId, sessionId,
messages, messages,
@@ -29,20 +37,25 @@ export function ConversationRoomPane({
loadingLabel, loadingLabel,
errorMessage, errorMessage,
}: ConversationRoomPaneProps) { }: ConversationRoomPaneProps) {
const normalizedSessionId = typeof sessionId === 'string' ? sessionId : '';
const normalizedMessages = Array.isArray(messages) ? messages : [];
const normalizedRequests = Array.isArray(requests) ? requests : [];
const normalizedLoadingLabel = typeof loadingLabel === 'string' ? loadingLabel : '';
const normalizedErrorMessage = typeof errorMessage === 'string' ? errorMessage : '';
const viewportRef = useRef<HTMLDivElement | null>(null); const viewportRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<TextAreaRef | null>(null); const composerRef = useRef<TextAreaRef | null>(null);
const requestStateMap = useMemo( const requestStateMap = useMemo(
() => new Map(requests.map((request) => [request.requestId, request])), () => new Map(normalizedRequests.map((request) => [request.requestId, request])),
[requests], [normalizedRequests],
); );
const activeSystemStatus = errorMessage.trim() || (sessionId ? null : '대화방을 선택해 주세요.'); const activeSystemStatus = normalizedErrorMessage.trim() || (normalizedSessionId ? null : '대화방을 선택해 주세요.');
return ( return (
<ChatConversationView <ChatConversationView
sessionId={sessionId} sessionId={normalizedSessionId}
viewportRef={viewportRef} viewportRef={viewportRef}
composerRef={composerRef} composerRef={composerRef}
visibleMessages={messages} visibleMessages={normalizedMessages}
activeSystemStatus={activeSystemStatus} activeSystemStatus={activeSystemStatus}
isSystemStatusPending={false} isSystemStatusPending={false}
showScrollToBottom={false} showScrollToBottom={false}
@@ -50,20 +63,24 @@ export function ConversationRoomPane({
draft="" draft=""
draftVersion={0} draftVersion={0}
composerAttachments={[]} composerAttachments={[]}
lastReadResponseMessageId={null}
requestStateMap={requestStateMap} requestStateMap={requestStateMap}
isConversationLoading={isLoading} isConversationLoading={isLoading}
conversationLoadingLabel={loadingLabel} conversationLoadingLabel={normalizedLoadingLabel}
hasOlderMessages={false} hasOlderMessages={false}
isLoadingOlderMessages={false} isLoadingOlderMessages={false}
isPullToLoadArmed={false} isPullToLoadArmed={false}
pullToLoadDistance={0} pullToLoadDistance={0}
selectedChatTypeId="general-request" selectedChatTypeId="general-request"
selectedCodexModel="gpt-5.4"
queuedRequests={[]} queuedRequests={[]}
chatTypeOptions={GENERAL_REQUEST_OPTION} chatTypeOptions={GENERAL_REQUEST_OPTION}
codexModelOptions={GENERAL_CODEX_MODEL_OPTION}
previewItems={[]} previewItems={[]}
isResourceStripOpen={false} isResourceStripOpen={false}
isComposerDisabled={true} isComposerDisabled={true}
isMobileViewport={false} isMobileViewport={false}
isIpadLikeViewport={false}
isChatTypeSelectionLocked={true} isChatTypeSelectionLocked={true}
isComposerAttachmentUploading={false} isComposerAttachmentUploading={false}
isSendWithoutContextEnabled={false} isSendWithoutContextEnabled={false}
@@ -75,21 +92,27 @@ export function ConversationRoomPane({
onPickComposerFiles={async () => ({ items: [] })} onPickComposerFiles={async () => ({ items: [] })}
onRemoveComposerAttachment={() => {}} onRemoveComposerAttachment={() => {}}
onSelectChatType={() => {}} onSelectChatType={() => {}}
onSend={() => {}} onSelectCodexModel={() => {}}
onSendImmediate={() => {}} onSend={() => 'blocked'}
onSendImmediate={() => 'blocked'}
onToggleSendWithoutContext={() => {}} onToggleSendWithoutContext={() => {}}
isImmediateSendPinned={false} isImmediateSendPinned={false}
onToggleImmediateSendPinned={() => {}} onToggleImmediateSendPinned={() => {}}
onClearDraft={() => {}} onClearDraft={() => {}}
onScrollToBottom={() => {}} onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}} onToggleResourceStrip={() => {}}
onLoadOlderMessages={() => {}}
onOpenPreview={() => {}} onOpenPreview={() => {}}
onCopyMessage={() => {}} onCopyMessage={() => {}}
onRetryMessage={() => {}} onRetryMessage={() => {}}
onRetryFailedRequest={() => {}}
onCancelMessage={() => {}} onCancelMessage={() => {}}
onDeleteRequest={() => {}} onDeleteRequest={() => {}}
onCompleteManualRequestBadge={() => {}}
onRemoveQueuedRequest={() => {}} onRemoveQueuedRequest={() => {}}
onPromoteQueuedRequest={() => {}}
onSubmitPrompt={async () => false} onSubmitPrompt={async () => false}
onSubmitChildRequest={async () => false}
/> />
); );
} }

View File

@@ -1,11 +1,16 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types'; import type { ChatConversationSummary } from '../../mainChatPanel/types';
export const CHAT_CONVERSATIONS_UPDATED_EVENT = 'work-server.chat-conversations-updated'; export const CHAT_CONVERSATIONS_UPDATED_EVENT = 'work-server.chat-conversations-updated';
export const CHAT_CONVERSATION_CLEARED_EVENT = 'work-server.chat-conversation-cleared';
type ChatConversationsUpdatedDetail = { type ChatConversationsUpdatedDetail = {
items: ChatConversationSummary[]; items: ChatConversationSummary[];
}; };
type ChatConversationClearedDetail = {
item: ChatConversationSummary;
};
export function emitChatConversationsUpdated(items: ChatConversationSummary[]) { export function emitChatConversationsUpdated(items: ChatConversationSummary[]) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
@@ -32,3 +37,30 @@ export function readChatConversationsUpdatedEvent(
return detail as ChatConversationsUpdatedDetail; return detail as ChatConversationsUpdatedDetail;
} }
export function emitChatConversationCleared(item: ChatConversationSummary) {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(
new CustomEvent<ChatConversationClearedDetail>(CHAT_CONVERSATION_CLEARED_EVENT, {
detail: { item },
}),
);
}
export function readChatConversationClearedEvent(
event: Event,
): ChatConversationClearedDetail | null {
if (!(event instanceof CustomEvent)) {
return null;
}
const detail = event.detail;
if (!detail || typeof detail !== 'object' || !('item' in (detail as Record<string, unknown>))) {
return null;
}
return detail as ChatConversationClearedDetail;
}

View File

@@ -8,7 +8,6 @@ import {
fetchChatRuntimeJobDetail, fetchChatRuntimeJobDetail,
fetchChatRuntimeSnapshot, fetchChatRuntimeSnapshot,
markChatConversationResponsesRead, markChatConversationResponsesRead,
renameChatConversationRoom,
updateChatConversationRoom, updateChatConversationRoom,
uploadChatComposerFile, uploadChatComposerFile,
} from '../../mainChatPanel'; } from '../../mainChatPanel';
@@ -36,28 +35,33 @@ export type ChatGateway = {
createConversation: (args: { createConversation: (args: {
sessionId: string; sessionId: string;
title: string; title: string;
draftText?: string | null;
requestBadgeLabel?: string | null; requestBadgeLabel?: string | null;
codexModel?: string | null;
chatTypeId?: string | null; chatTypeId?: string | null;
lastChatTypeId?: string | null; lastChatTypeId?: string | null;
generalSectionName?: string | null; generalSectionName?: string | null;
contextLabel?: string; contextLabel?: string;
contextDescription?: string; contextDescription?: string;
notifyOffline?: boolean; notifyOffline?: boolean;
roomScope?: Record<string, unknown> | null;
}) => Promise<ChatConversationSummary>; }) => Promise<ChatConversationSummary>;
renameConversation: (sessionId: string, title: string) => Promise<ChatConversationSummary>;
updateConversation: ( updateConversation: (
sessionId: string, sessionId: string,
payload: Partial< payload: Partial<
Pick< Pick<
ChatConversationSummary, ChatConversationSummary,
| 'title' | 'title'
| 'draftText'
| 'requestBadgeLabel' | 'requestBadgeLabel'
| 'codexModel'
| 'chatTypeId' | 'chatTypeId'
| 'lastChatTypeId' | 'lastChatTypeId'
| 'generalSectionName' | 'generalSectionName'
| 'contextLabel' | 'contextLabel'
| 'contextDescription' | 'contextDescription'
| 'notifyOffline' | 'notifyOffline'
| 'roomScope'
> >
>, >,
) => Promise<ChatConversationSummary>; ) => Promise<ChatConversationSummary>;
@@ -76,7 +80,6 @@ export const chatGateway: ChatGateway = {
listConversations: fetchChatConversations, listConversations: fetchChatConversations,
getConversationDetail: fetchChatConversationDetail, getConversationDetail: fetchChatConversationDetail,
createConversation: createChatConversationRoom, createConversation: createChatConversationRoom,
renameConversation: renameChatConversationRoom,
updateConversation: updateChatConversationRoom, updateConversation: updateChatConversationRoom,
clearConversation: clearChatConversationRoom, clearConversation: clearChatConversationRoom,
deleteConversation: async (sessionId) => { deleteConversation: async (sessionId) => {

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mergeConversationItemsPreservingRequestedSession } from './conversationListMerge.js';
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle.js';
function createConversationSummary(overrides) {
return {
sessionId: overrides.sessionId,
clientId: overrides.clientId ?? null,
isDraftOnly: overrides.isDraftOnly,
draftText: overrides.draftText ?? '',
title: overrides.title,
requestBadgeLabel: overrides.requestBadgeLabel ?? null,
codexModel: overrides.codexModel ?? null,
chatTypeId: overrides.chatTypeId ?? null,
lastChatTypeId: overrides.lastChatTypeId ?? null,
generalSectionName: overrides.generalSectionName ?? null,
contextLabel: overrides.contextLabel ?? null,
contextDescription: overrides.contextDescription ?? null,
roomScope: overrides.roomScope ?? null,
notifyOffline: overrides.notifyOffline ?? true,
hasUnreadResponse: overrides.hasUnreadResponse ?? false,
currentRequestId: overrides.currentRequestId ?? null,
currentJobStatus: overrides.currentJobStatus ?? null,
currentJobMessage: overrides.currentJobMessage ?? null,
currentQueueSize: overrides.currentQueueSize ?? 0,
currentStatusUpdatedAt: overrides.currentStatusUpdatedAt ?? null,
isPendingWork: overrides.isPendingWork ?? false,
pendingWorkReason: overrides.pendingWorkReason ?? null,
lastRequestPreview: overrides.lastRequestPreview ?? '',
lastMessagePreview: overrides.lastMessagePreview ?? '',
lastResponsePreview: overrides.lastResponsePreview ?? '',
createdAt: overrides.createdAt ?? '2026-05-18T00:00:00.000Z',
updatedAt: overrides.updatedAt ?? '2026-05-18T00:00:00.000Z',
lastMessageAt: overrides.lastMessageAt ?? null,
};
}
test('resolveMergedConversationTitle keeps an explicit title when the incoming title is the default placeholder', () => {
assert.equal(resolveMergedConversationTitle('채팅 관리 / 유형 권한 관리', '새 대화'), '채팅 관리 / 유형 권한 관리');
});
test('mergeConversationItemsPreservingRequestedSession does not overwrite an explicit title with the default placeholder', () => {
const previousItems = [
createConversationSummary({
sessionId: 'session-1',
title: '채팅 관리 / 유형 권한 관리',
updatedAt: '2026-05-18T00:00:05.000Z',
}),
];
const nextItems = [
createConversationSummary({
sessionId: 'session-1',
title: '새 대화',
updatedAt: '2026-05-18T00:00:10.000Z',
}),
];
const [mergedItem] = mergeConversationItemsPreservingRequestedSession(nextItems, previousItems, 'session-1');
assert.ok(mergedItem);
assert.equal(mergedItem.title, '채팅 관리 / 유형 권한 관리');
});

View File

@@ -1,5 +1,9 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types'; import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread'; import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
import {
resolveConversationUnreadMergeState,
resolveStoredConversationUnreadState,
} from '../../mainChatPanel/conversationUnread';
function shouldPreserveRequestMetadata( function shouldPreserveRequestMetadata(
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>, previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
@@ -56,13 +60,16 @@ function mergeConversationSummaries(existing: ChatConversationSummary, incoming:
...preferred, ...preferred,
clientId: preferred.clientId ?? fallback.clientId, clientId: preferred.clientId ?? fallback.clientId,
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly, isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
title: preferred.title.trim() || fallback.title.trim(), draftText: preferred.draftText ?? fallback.draftText ?? '',
title: resolveMergedConversationTitle(fallback.title, preferred.title),
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null, requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
codexModel: preferred.codexModel?.trim() || fallback.codexModel?.trim() || null,
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null, chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null, lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null, generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null, contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null, contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline, notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming), hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null, currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
@@ -82,6 +89,14 @@ function mergeConversationSummaries(existing: ChatConversationSummary, incoming:
}; };
} }
function shouldPreserveMissingConversation(item: ChatConversationSummary | null | undefined) {
if (!item) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
}
function sortChatConversationSummaries(items: ChatConversationSummary[]) { function sortChatConversationSummaries(items: ChatConversationSummary[]) {
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => { const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
const sessionId = item.sessionId.trim(); const sessionId = item.sessionId.trim();
@@ -125,7 +140,10 @@ export function mergeConversationItemsPreservingRequestedSession(
const previousItem = previousBySessionId.get(item.sessionId); const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) { if (!previousItem) {
return item; return {
...item,
hasUnreadResponse: resolveStoredConversationUnreadState(item),
};
} }
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item); const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
@@ -140,12 +158,13 @@ export function mergeConversationItemsPreservingRequestedSession(
return { return {
...item, ...item,
title: preserveRequestMetadata title: resolveMergedConversationTitle(previousItem.title, item.title, {
? previousItem.title.trim() || item.title.trim() preservePrevious: preserveRequestMetadata,
: item.title.trim() || previousItem.title.trim(), }),
requestBadgeLabel: preserveRequestMetadata requestBadgeLabel: preserveRequestMetadata
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null ? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null, : item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
codexModel: item.codexModel?.trim() || previousItem.codexModel?.trim() || null,
chatTypeId, chatTypeId,
lastChatTypeId, lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null, generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
@@ -154,32 +173,14 @@ export function mergeConversationItemsPreservingRequestedSession(
lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(), lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(),
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(), lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(), lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
currentRequestId: // For sessions that still exist in the server list, trust the server's
item.currentRequestId?.trim() || // current job state instead of reviving stale local queued/started flags.
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) || currentRequestId: item.currentRequestId?.trim() || null,
null, currentJobStatus: item.currentJobStatus ?? null,
currentJobStatus: currentJobMessage: item.currentJobMessage?.trim() || null,
item.currentJobStatus ??
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentJobStatus
: null),
currentJobMessage:
item.currentJobMessage?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
null,
currentQueueSize: currentQueueSize:
item.currentQueueSize > 0 item.currentJobStatus === 'queued' ? Math.max(1, item.currentQueueSize) : Math.max(0, item.currentQueueSize),
? item.currentQueueSize currentStatusUpdatedAt: item.currentStatusUpdatedAt || null,
: item.currentJobStatus === 'queued'
? Math.max(1, previousItem.currentQueueSize)
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
? Math.max(1, previousItem.currentQueueSize)
: item.currentQueueSize,
currentStatusUpdatedAt:
item.currentStatusUpdatedAt ||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentStatusUpdatedAt
: null),
}; };
}); });
const normalizedRequestedSessionId = requestedSessionId.trim(); const normalizedRequestedSessionId = requestedSessionId.trim();
@@ -205,6 +206,10 @@ export function mergeConversationItemsPreservingRequestedSession(
const preservedRequestedSession = const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null; previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!shouldPreserveMissingConversation(preservedRequestedSession)) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
if (!preservedRequestedSession) { if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]); return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
} }

View File

@@ -1,7 +1,8 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types'; import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatPromptContextRef } from '../../mainChatPanel/types';
import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey'; import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey';
import { shouldSkipContextConfirmForSessionToday } from '../../mainChatPanel/contextConfirmPreference';
export type ComposerFilePickResult = { export type ComposerFilePickResult = {
items: { items: {
@@ -19,7 +20,9 @@ type PendingChatRequest = {
mode: 'queue' | 'direct'; mode: 'queue' | 'direct';
origin?: 'composer' | 'prompt'; origin?: 'composer' | 'prompt';
parentRequestId?: string | null; parentRequestId?: string | null;
promptContextRef?: ChatPromptContextRef | null;
omitPromptHistory?: boolean; omitPromptHistory?: boolean;
codexModel: string;
chatTypeId: string; chatTypeId: string;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
@@ -37,10 +40,13 @@ type PendingChatRequest = {
}; };
type PendingContextConfirm = { type PendingContextConfirm = {
sessionId: string;
mode: 'queue' | 'direct'; mode: 'queue' | 'direct';
text: string; text: string;
origin?: 'composer' | 'prompt'; origin?: 'composer' | 'prompt';
parentRequestId?: string | null; parentRequestId?: string | null;
promptContextRef?: ChatPromptContextRef | null;
codexModel: string;
chatTypeId: string; chatTypeId: string;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
@@ -87,6 +93,7 @@ type UseConversationComposerControllerOptions = {
getDraft: () => string; getDraft: () => string;
composerAttachments: ChatComposerAttachment[]; composerAttachments: ChatComposerAttachment[];
isComposerAttachmentUploading: boolean; isComposerAttachmentUploading: boolean;
selectedCodexModel: string;
selectedChatType: SelectedChatType; selectedChatType: SelectedChatType;
socketRef: { current: WebSocket | null }; socketRef: { current: WebSocket | null };
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null }; composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
@@ -128,13 +135,23 @@ type UseConversationComposerControllerOptions = {
ensureSessionReady?: (sessionId: string) => Promise<boolean>; ensureSessionReady?: (sessionId: string) => Promise<boolean>;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void; sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
scrollViewportToBottom: () => void; scrollViewportToBottom: () => void;
releaseAutoScrollSuspension: () => void;
}; };
type SendMessageOptions = { type SendMessageOptions = {
sessionId?: string;
mode: 'queue' | 'direct'; mode: 'queue' | 'direct';
draftText?: string; draftText?: string;
}; };
export type SendMessageResult = 'sent' | 'pending' | 'blocked';
const COMPOSER_SUBMISSION_DEDUP_WINDOW_MS = 1200;
type RecentComposerSubmission = {
key: string;
submittedAt: number;
};
function createClientRequestId() { function createClientRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `client-${crypto.randomUUID()}`; return `client-${crypto.randomUUID()}`;
@@ -149,6 +166,7 @@ export function useConversationComposerController({
getDraft, getDraft,
composerAttachments, composerAttachments,
isComposerAttachmentUploading, isComposerAttachmentUploading,
selectedCodexModel,
selectedChatType, selectedChatType,
socketRef, socketRef,
composerRef, composerRef,
@@ -176,10 +194,47 @@ export function useConversationComposerController({
ensureSessionReady, ensureSessionReady,
sendChatRequest, sendChatRequest,
scrollViewportToBottom, scrollViewportToBottom,
releaseAutoScrollSuspension,
}: UseConversationComposerControllerOptions) { }: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] })); const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0); const activeComposerUploadCountRef = useRef(0);
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>()); const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
const activeComposerSubmissionKeyRef = useRef<string | null>(null);
const recentComposerSubmissionRef = useRef<RecentComposerSubmission | null>(null);
const isSocketOpen = useCallback(() => {
return Boolean(socketRef.current && socketRef.current.readyState === WebSocket.OPEN);
}, [socketRef]);
const buildComposerSubmissionKey = useCallback(
({
sessionId,
mode,
text,
codexModel,
chatTypeId,
parentRequestId,
omitPromptHistory,
}: {
sessionId: string;
mode: 'queue' | 'direct';
text: string;
codexModel: string;
chatTypeId: string;
parentRequestId?: string | null;
omitPromptHistory?: boolean;
}) =>
JSON.stringify({
sessionId: sessionId.trim(),
mode,
text: text.trim(),
codexModel: codexModel.trim(),
chatTypeId: chatTypeId.trim(),
parentRequestId: parentRequestId?.trim() || null,
omitPromptHistory: omitPromptHistory === true,
}),
[],
);
const handleComposerFilesPicked = useCallback( const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => { async (files: File[]): Promise<ComposerFilePickResult> => {
@@ -286,18 +341,47 @@ export function useConversationComposerController({
const focusComposerAfterSend = useCallback(() => { const focusComposerAfterSend = useCallback(() => {
window.setTimeout(() => { window.setTimeout(() => {
composerRef.current?.focus({ cursor: 'end' }); composerRef.current?.focus({ cursor: 'end' });
scrollViewportToBottom();
}, 0); }, 0);
}, [composerRef, scrollViewportToBottom]); }, [composerRef]);
const scheduleViewportBottomSyncAfterSend = useCallback(() => {
releaseAutoScrollSuspension();
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
window.requestAnimationFrame(() => {
scrollViewportToBottom();
window.requestAnimationFrame(() => {
scrollViewportToBottom();
});
});
}, [releaseAutoScrollSuspension, scrollViewportToBottom, setShowScrollToBottom, shouldStickToBottomRef]);
const handleExecuteSendError = useCallback(
(error: unknown) => {
const reason =
error instanceof Error && error.message.trim()
? error.message.trim()
: '요청 전송 중 오류가 발생했습니다.';
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setMessages((previous) => [...previous.slice(-39), createLocalMessage(reason)]);
},
[createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages],
);
const executeSendMessage = useCallback( const executeSendMessage = useCallback(
async (request: PendingContextConfirm) => { async (request: PendingContextConfirm) => {
const { const {
sessionId,
mode, mode,
text, text,
origin, origin,
parentRequestId, parentRequestId,
promptContextRef,
chatTypeId, chatTypeId,
codexModel,
chatTypeLabel, chatTypeLabel,
chatTypeDescription, chatTypeDescription,
chatTypeBaseDescription, chatTypeBaseDescription,
@@ -307,176 +391,280 @@ export function useConversationComposerController({
customContextContent, customContextContent,
omitPromptHistory, omitPromptHistory,
} = request; } = request;
const requestChatTypeId = chatTypeId.trim();
const requestChatTypeLabel = chatTypeLabel.trim() || requestChatTypeId || '기본 요청';
const targetSessionId = sessionId.trim() || activeSessionId.trim();
const submissionKey = buildComposerSubmissionKey({
mode,
text,
codexModel,
chatTypeId: requestChatTypeId,
parentRequestId,
omitPromptHistory,
sessionId: targetSessionId,
});
if (ensureSessionReady) { if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) {
setActiveSystemStatus('새 채팅방 준비 중...'); return false;
setIsSystemStatusPending(true); }
const isSessionReady = await ensureSessionReady(activeSessionId);
if (!isSessionReady) { if (origin !== 'prompt') {
setActiveSystemStatus(null); const recentComposerSubmission = recentComposerSubmissionRef.current;
setIsSystemStatusPending(false); if (
recentComposerSubmission &&
recentComposerSubmission.key === submissionKey &&
Date.now() - recentComposerSubmission.submittedAt < COMPOSER_SUBMISSION_DEDUP_WINDOW_MS
) {
return false; return false;
} }
} }
const requestId = createClientRequestId(); if (origin !== 'prompt') {
const outgoingRequest: PendingChatRequest = { activeComposerSubmissionKeyRef.current = submissionKey;
sessionId: activeSessionId, recentComposerSubmissionRef.current = {
requestId, key: submissionKey,
text, submittedAt: Date.now(),
mode, };
origin: origin ?? 'composer', }
parentRequestId: parentRequestId?.trim() || null,
omitPromptHistory: omitPromptHistory === true, const shouldOptimisticallyClearComposer = origin !== 'prompt';
chatTypeId, const previousDraft = shouldOptimisticallyClearComposer ? getDraft() : '';
chatTypeLabel, const previousAttachments = shouldOptimisticallyClearComposer ? composerAttachments : [];
chatTypeDescription, let composerRestoreNeeded = shouldOptimisticallyClearComposer;
chatTypeBaseDescription,
defaultContextIds, const restoreComposerOnFailure = () => {
defaultContexts, if (!composerRestoreNeeded) {
customContextTitle, return;
customContextContent, }
retryCount: 0,
failed: false, composerRestoreNeeded = false;
if (!getDraft().trim()) {
setDraft(previousDraft);
}
setComposerAttachments((current) => {
if (current.length > 0 || previousAttachments.length === 0) {
return current;
}
return previousAttachments;
});
}; };
if (origin === 'prompt') {
promptRequestIdsRef?.current.add(requestId);
}
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 요청을 접수했습니다.',
'# 진행: 순서를 기다리기 전에 요청 내용을 정리하고 있습니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: false,
createdAt: queuedAt,
updatedAt: queuedAt,
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
setActiveSystemStatus('대기열 등록 중...');
setIsSystemStatusPending(true);
} else {
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'accepted',
statusMessage: '요청을 접수했습니다.',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
setActiveSystemStatus('즉시 응답 준비 중...');
setIsSystemStatusPending(true);
}
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return;
}
try { try {
sendChatRequest(socketRef.current, outgoingRequest); if (shouldOptimisticallyClearComposer) {
} catch { setDraft('');
setActiveSystemStatus('전송 재시도 중...'); setComposerAttachments([]);
pendingRequestsRef.current = [ focusComposerAfterSend();
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId), }
outgoingRequest,
]; if (!targetSessionId) {
if (mode === 'direct') { restoreComposerOnFailure();
updatePendingMessageStatus(requestId, 'retrying', 0); setActiveSystemStatus(null);
setIsSystemStatusPending(false);
return false;
}
if (ensureSessionReady) {
setActiveSystemStatus('새 채팅방 준비 중...');
setIsSystemStatusPending(true);
const isSessionReady = await ensureSessionReady(targetSessionId);
if (!isSessionReady) {
restoreComposerOnFailure();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
return false;
}
}
if (!isSocketOpen()) {
restoreComposerOnFailure();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'),
]);
return false;
}
const requestId = createClientRequestId();
const outgoingRequest: PendingChatRequest = {
sessionId: targetSessionId,
requestId,
text,
mode,
origin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
promptContextRef: promptContextRef ?? null,
omitPromptHistory: omitPromptHistory === true,
codexModel,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
retryCount: 0,
failed: false,
};
if (origin === 'prompt') {
promptRequestIdsRef?.current.add(requestId);
}
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 요청을 접수했습니다.',
'# 진행: 순서를 기다리기 전에 요청 내용을 정리하고 있습니다.',
]);
upsertRequestItem({
sessionId: targetSessionId,
requestId,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: false,
createdAt: queuedAt,
updatedAt: queuedAt,
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(targetSessionId, text, queuedAt, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
scheduleViewportBottomSyncAfterSend();
setActiveSystemStatus('대기열 등록 중...');
setIsSystemStatusPending(true);
} else {
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
]);
upsertRequestItem({
sessionId: targetSessionId,
requestId,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'accepted',
statusMessage: '요청을 접수했습니다.',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(targetSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
scheduleViewportBottomSyncAfterSend();
setActiveSystemStatus('즉시 응답 준비 중...');
setIsSystemStatusPending(true);
}
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
composerRestoreNeeded = false;
return true;
}
try {
sendChatRequest(socketRef.current, outgoingRequest);
} catch {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
}
composerRestoreNeeded = false;
return true;
} catch (error) {
restoreComposerOnFailure();
throw error;
} finally {
if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) {
activeComposerSubmissionKeyRef.current = null;
} }
} }
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return true;
}, },
[ [
activeSessionId, activeSessionId,
buildComposerSubmissionKey,
composerAttachments,
createActivityLogPlaceholder, createActivityLogPlaceholder,
createChatMessage, createChatMessage,
createLocalMessage,
ensureSessionReady, ensureSessionReady,
focusComposerAfterSend, focusComposerAfterSend,
getDraft,
isSocketOpen,
pendingRequestsRef, pendingRequestsRef,
promptRequestIdsRef, promptRequestIdsRef,
scheduleViewportBottomSyncAfterSend,
sendChatRequest, sendChatRequest,
setActiveSystemStatus, setActiveSystemStatus,
setComposerAttachments, setComposerAttachments,
@@ -493,15 +681,15 @@ export function useConversationComposerController({
); );
const sendMessage = useCallback( const sendMessage = useCallback(
({ mode, draftText }: SendMessageOptions) => { ({ sessionId, mode, draftText }: SendMessageOptions): SendMessageResult => {
if (isComposerAttachmentUploading) { if (isComposerAttachmentUploading) {
return; return 'blocked';
} }
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim(); const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
if (!trimmed) { if (!trimmed) {
return; return 'blocked';
} }
if (!selectedChatType) { if (!selectedChatType) {
@@ -509,7 +697,15 @@ export function useConversationComposerController({
...previous.slice(-39), ...previous.slice(-39),
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'), createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
]); ]);
return; return 'blocked';
}
if (!isSocketOpen()) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'),
]);
return 'blocked';
} }
const recentContext = summarizeRecentContext( const recentContext = summarizeRecentContext(
@@ -519,9 +715,34 @@ export function useConversationComposerController({
); );
if (recentContext.omittedCount > 0) { if (recentContext.omittedCount > 0) {
setPendingContextConfirm({ const targetSessionId = sessionId?.trim() || activeSessionId.trim();
const nextRequest = {
sessionId: targetSessionId,
mode, mode,
text: trimmed, text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeBaseDescription: selectedChatType.baseDescription,
defaultContextIds: selectedChatType.defaultContextIds,
defaultContexts: selectedChatType.defaultContexts,
customContextTitle: selectedChatType.customContextTitle,
customContextContent: selectedChatType.customContextContent,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
} satisfies PendingContextConfirm;
if (shouldSkipContextConfirmForSessionToday(targetSessionId)) {
void executeSendMessage(nextRequest).catch(handleExecuteSendError);
return 'sent';
}
setPendingContextConfirm({
sessionId: targetSessionId,
mode,
text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id, chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name, chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description, chatTypeDescription: selectedChatType.description,
@@ -533,12 +754,14 @@ export function useConversationComposerController({
includedContextCount: recentContext.includedCount, includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount, omittedContextCount: recentContext.omittedCount,
}); });
return; return 'pending';
} }
executeSendMessage({ void executeSendMessage({
sessionId: sessionId?.trim() || activeSessionId.trim(),
mode, mode,
text: trimmed, text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id, chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name, chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description, chatTypeDescription: selectedChatType.description,
@@ -549,7 +772,8 @@ export function useConversationComposerController({
customContextContent: selectedChatType.customContextContent, customContextContent: selectedChatType.customContextContent,
includedContextCount: 0, includedContextCount: 0,
omittedContextCount: 0, omittedContextCount: 0,
}); }).catch(handleExecuteSendError);
return 'sent';
}, },
[ [
appConfigChat.maxContextChars, appConfigChat.maxContextChars,
@@ -558,8 +782,12 @@ export function useConversationComposerController({
composerAttachments, composerAttachments,
createLocalMessage, createLocalMessage,
getDraft, getDraft,
handleExecuteSendError,
executeSendMessage, executeSendMessage,
isSocketOpen,
isComposerAttachmentUploading, isComposerAttachmentUploading,
selectedCodexModel,
activeSessionId,
messagesRef, messagesRef,
selectedChatType, selectedChatType,
setMessages, setMessages,

View File

@@ -7,6 +7,7 @@ import type {
ChatConversationRequest, ChatConversationRequest,
ChatConversationSummary, ChatConversationSummary,
ChatMessage, ChatMessage,
ChatPromptContextRef,
} from '../../mainChatPanel/types'; } from '../../mainChatPanel/types';
type PendingChatRequest = { type PendingChatRequest = {
@@ -14,7 +15,9 @@ type PendingChatRequest = {
requestId: string; requestId: string;
text: string; text: string;
mode: 'queue' | 'direct'; mode: 'queue' | 'direct';
promptContextRef?: ChatPromptContextRef | null;
omitPromptHistory?: boolean; omitPromptHistory?: boolean;
codexModel: string;
chatTypeId: string; chatTypeId: string;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
@@ -33,9 +36,9 @@ type PendingChatRequest = {
type UseConversationRoomActionsControllerOptions = { type UseConversationRoomActionsControllerOptions = {
activeSessionId: string; activeSessionId: string;
requestedSessionId: string; requestedSessionId: string;
handledRequestedSessionIdRef: { current: string };
isClosingConversationRef: { current: boolean };
conversationItems: ChatConversationSummary[]; conversationItems: ChatConversationSummary[];
activeConversation: ChatConversationSummary | null;
editingConversationTitle: string;
isMobileViewport: boolean; isMobileViewport: boolean;
pendingRequestsRef: { current: PendingChatRequest[] }; pendingRequestsRef: { current: PendingChatRequest[] };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> }; sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
@@ -54,9 +57,7 @@ type UseConversationRoomActionsControllerOptions = {
setIsResourceStripOpen: (value: boolean) => void; setIsResourceStripOpen: (value: boolean) => void;
setIsConversationPaneClosed: (value: boolean) => void; setIsConversationPaneClosed: (value: boolean) => void;
setIsMobileConversationView: (value: boolean) => void; setIsMobileConversationView: (value: boolean) => void;
setRenamingConversationSessionId: (value: string | null | ((current: string | null) => string | null)) => void; setPreserveEmptyConversationSelection: (value: boolean) => void;
setEditingConversationTitle: (value: string) => void;
setIsEditingConversationTitle: (value: boolean) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void; updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void; sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
createLocalMessage: (text: string) => ChatMessage; createLocalMessage: (text: string) => ChatMessage;
@@ -69,9 +70,9 @@ type UseConversationRoomActionsControllerOptions = {
export function useConversationRoomActionsController({ export function useConversationRoomActionsController({
activeSessionId, activeSessionId,
requestedSessionId, requestedSessionId,
handledRequestedSessionIdRef,
isClosingConversationRef,
conversationItems, conversationItems,
activeConversation,
editingConversationTitle,
isMobileViewport, isMobileViewport,
pendingRequestsRef, pendingRequestsRef,
sessionMessageCacheRef, sessionMessageCacheRef,
@@ -90,9 +91,7 @@ export function useConversationRoomActionsController({
setIsResourceStripOpen, setIsResourceStripOpen,
setIsConversationPaneClosed, setIsConversationPaneClosed,
setIsMobileConversationView, setIsMobileConversationView,
setRenamingConversationSessionId, setPreserveEmptyConversationSelection,
setEditingConversationTitle,
setIsEditingConversationTitle,
updatePendingMessageStatus, updatePendingMessageStatus,
sendChatRequest, sendChatRequest,
createLocalMessage, createLocalMessage,
@@ -266,62 +265,6 @@ export function useConversationRoomActionsController({
], ],
); );
const handleRenameConversation = useCallback(async () => {
if (!activeConversation) {
return;
}
const sessionId = activeConversation.sessionId;
const previousTitle = activeConversation.title;
const trimmedTitle = editingConversationTitle.trim();
if (!trimmedTitle || trimmedTitle === previousTitle) {
setIsEditingConversationTitle(false);
setEditingConversationTitle(previousTitle);
return;
}
setRenamingConversationSessionId(sessionId);
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: trimmedTitle } : entry)),
);
setEditingConversationTitle(trimmedTitle);
setIsEditingConversationTitle(false);
try {
const item = activeConversation.isDraftOnly
? await chatGateway.createConversation({
sessionId,
title: trimmedTitle,
chatTypeId: activeConversation.chatTypeId,
lastChatTypeId: activeConversation.lastChatTypeId,
generalSectionName: activeConversation.generalSectionName,
contextLabel: activeConversation.contextLabel ?? undefined,
contextDescription: activeConversation.contextDescription ?? undefined,
notifyOffline: activeConversation.notifyOffline,
})
: await chatGateway.renameConversation(sessionId, trimmedTitle);
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
setEditingConversationTitle(item.title);
} catch (error) {
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: previousTitle } : entry)),
);
setEditingConversationTitle(previousTitle);
messageApi.error(error instanceof Error ? error.message : '채팅방 이름 변경에 실패했습니다.');
} finally {
setRenamingConversationSessionId((current) => (current === sessionId ? null : current));
}
}, [
activeConversation,
editingConversationTitle,
messageApi,
setConversationItems,
setEditingConversationTitle,
setIsEditingConversationTitle,
setRenamingConversationSessionId,
]);
const handleDeleteConversation = useCallback( const handleDeleteConversation = useCallback(
async (sessionId: string) => { async (sessionId: string) => {
try { try {
@@ -340,6 +283,9 @@ export function useConversationRoomActionsController({
setConversationItems(remaining); setConversationItems(remaining);
if (sessionId === activeSessionId) { if (sessionId === activeSessionId) {
isClosingConversationRef.current = true;
handledRequestedSessionIdRef.current = '';
setPreserveEmptyConversationSelection(true);
replaceChatSessionInUrl(''); replaceChatSessionInUrl('');
chatConnectionGateway.resetLastReceivedEventId(''); chatConnectionGateway.resetLastReceivedEventId('');
setActiveSessionId(''); setActiveSessionId('');
@@ -353,9 +299,10 @@ export function useConversationRoomActionsController({
setActiveSystemStatus(null); setActiveSystemStatus(null);
setIsSystemStatusPending(false); setIsSystemStatusPending(false);
setIsResourceStripOpen(false); setIsResourceStripOpen(false);
setIsConversationPaneClosed(false); setIsConversationPaneClosed(true);
setIsMobileConversationView(!isMobileViewport); setIsMobileConversationView(false);
} else if (requestedSessionId === sessionId) { } else if (requestedSessionId === sessionId) {
handledRequestedSessionIdRef.current = '';
replaceChatSessionInUrl(activeSessionId); replaceChatSessionInUrl(activeSessionId);
} }
} catch (error) { } catch (error) {
@@ -365,6 +312,8 @@ export function useConversationRoomActionsController({
[ [
activeSessionId, activeSessionId,
conversationItems, conversationItems,
handledRequestedSessionIdRef,
isClosingConversationRef,
isMobileViewport, isMobileViewport,
messageApi, messageApi,
replaceChatSessionInUrl, replaceChatSessionInUrl,
@@ -382,6 +331,7 @@ export function useConversationRoomActionsController({
setIsPreviewModalOpen, setIsPreviewModalOpen,
setIsResourceStripOpen, setIsResourceStripOpen,
setIsSystemStatusPending, setIsSystemStatusPending,
setPreserveEmptyConversationSelection,
setMessages, setMessages,
setRequestItems, setRequestItems,
], ],
@@ -434,7 +384,6 @@ export function useConversationRoomActionsController({
handleClearConversation, handleClearConversation,
deleteStoredRequest, deleteStoredRequest,
handleDeleteConversation, handleDeleteConversation,
handleRenameConversation,
removeQueuedComposerRequest, removeQueuedComposerRequest,
retryPendingRequest, retryPendingRequest,
}; };

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'; import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils'; import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel'; import { sortChatConversationSummaries } from '../../mainChatPanel';
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
import type { import type {
ChatConversationRequest, ChatConversationRequest,
@@ -8,8 +9,8 @@ import type {
ChatMessage, ChatMessage,
} from '../../mainChatPanel/types'; } from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8; const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 10;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8; const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 10;
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800]; const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
function mergeConversationRequests( function mergeConversationRequests(
@@ -75,6 +76,7 @@ type UseConversationRoomDataOptions = {
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void; queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
viewportRef: MutableRefObject<HTMLDivElement | null>; viewportRef: MutableRefObject<HTMLDivElement | null>;
onMissingConversation?: (sessionId: string) => void; onMissingConversation?: (sessionId: string) => void;
shouldPreserveMissingConversation?: (sessionId: string) => boolean;
}; };
function isMissingConversationError(error: unknown) { function isMissingConversationError(error: unknown) {
@@ -110,6 +112,7 @@ export function useConversationRoomData({
queueViewportPrependRestore, queueViewportPrependRestore,
viewportRef, viewportRef,
onMissingConversation, onMissingConversation,
shouldPreserveMissingConversation,
}: UseConversationRoomDataOptions) { }: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef(''); const previousSessionIdRef = useRef('');
@@ -194,13 +197,21 @@ export function useConversationRoomData({
if (!isCancelled && response.item.sessionId === requestedSessionId) { if (!isCancelled && response.item.sessionId === requestedSessionId) {
setConversationItems((previous) => { setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId); const previousItem = previous.find((item) => item.sessionId === response.item.sessionId) ?? null;
const exists = previousItem != null;
const mergedItem = previousItem
? {
...response.item,
title: resolveMergedConversationTitle(previousItem.title, response.item.title),
}
: response.item;
if (!exists) { if (!exists) {
return sortChatConversationSummaries([response.item, ...previous]); return sortChatConversationSummaries([mergedItem, ...previous]);
} }
return sortChatConversationSummaries( return sortChatConversationSummaries(
previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)), previous.map((item) => (item.sessionId === response.item.sessionId ? mergedItem : item)),
); );
}); });
@@ -224,6 +235,14 @@ export function useConversationRoomData({
} catch (error) { } catch (error) {
if (!isCancelled) { if (!isCancelled) {
if (cachedMessages.length === 0 && isMissingConversationError(error)) { if (cachedMessages.length === 0 && isMissingConversationError(error)) {
if (shouldPreserveMissingConversation?.(requestedSessionId)) {
setMessages([]);
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
setConversationLoadingLabel('새 채팅방을 준비하는 중입니다.');
return;
}
sessionMessageCacheRef.current.delete(requestedSessionId); sessionMessageCacheRef.current.delete(requestedSessionId);
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId)); setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setMessages([]); setMessages([]);
@@ -275,6 +294,7 @@ export function useConversationRoomData({
setMessages, setMessages,
setOldestLoadedMessageId, setOldestLoadedMessageId,
onMissingConversation, onMissingConversation,
shouldPreserveMissingConversation,
setRequestItems, setRequestItems,
]); ]);

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types'; import type { ChatComposerAttachment } from '../../mainChatPanel/types';
type PreviewItem = { type PreviewItem = {
id: string; id: string;
@@ -22,7 +22,6 @@ type UseConversationViewControllerOptions = {
setDraft: React.Dispatch<React.SetStateAction<string>>; setDraft: React.Dispatch<React.SetStateAction<string>>;
setIsResourceStripOpen: (value: boolean) => void; setIsResourceStripOpen: (value: boolean) => void;
setIsSystemStatusPending: (value: boolean) => void; setIsSystemStatusPending: (value: boolean) => void;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
}; };
export function useConversationViewController({ export function useConversationViewController({
@@ -38,7 +37,6 @@ export function useConversationViewController({
setDraft, setDraft,
setIsResourceStripOpen, setIsResourceStripOpen,
setIsSystemStatusPending, setIsSystemStatusPending,
setMessages,
}: UseConversationViewControllerOptions) { }: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId); const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null); const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
@@ -52,7 +50,8 @@ export function useConversationViewController({
const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null; const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => { useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId; const previousSessionId = previousSessionIdRef.current;
const hasSessionChanged = previousSessionId !== activeSessionId;
if (!hasSessionChanged) { if (!hasSessionChanged) {
return; return;
@@ -60,8 +59,13 @@ export function useConversationViewController({
previousSessionIdRef.current = activeSessionId; previousSessionIdRef.current = activeSessionId;
setMessages([]); // Draft restoration is handled by the panel layer per session. Keep this
setDraft(''); // hook focused on view-only resets so session changes do not wipe a
// restored draft after the panel has reloaded it from storage.
if (previousSessionId.trim() && !activeSessionId.trim()) {
return;
}
setComposerAttachments([]); setComposerAttachments([]);
setCopiedMessageId(null); setCopiedMessageId(null);
setActivePreviewId(null); setActivePreviewId(null);
@@ -78,7 +82,6 @@ export function useConversationViewController({
setDraft, setDraft,
setIsResourceStripOpen, setIsResourceStripOpen,
setIsSystemStatusPending, setIsSystemStatusPending,
setMessages,
]); ]);
useEffect(() => { useEffect(() => {

View File

@@ -49,7 +49,10 @@ export function useConversationViewportController({
const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const systemStatusTimerRef = useRef<number | null>(null); const systemStatusTimerRef = useRef<number | null>(null);
const restoreAutoScrollFrameRef = useRef<number | null>(null); const restoreAutoScrollFrameRef = useRef<number | null>(null);
const showScrollToBottomRef = useRef(false);
const shouldStickToBottomRef = useRef(true); const shouldStickToBottomRef = useRef(true);
const lastViewportScrollTopRef = useRef(0);
const autoScrollSuspendedUntilRef = useRef(0);
const pendingViewportRestoreRef = useRef(false); const pendingViewportRestoreRef = useRef(false);
const pendingPrependRestoreRef = useRef<{ const pendingPrependRestoreRef = useRef<{
previousScrollHeight: number; previousScrollHeight: number;
@@ -71,8 +74,17 @@ export function useConversationViewportController({
} }
}, []); }, []);
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
if (showScrollToBottomRef.current === nextValue) {
return;
}
showScrollToBottomRef.current = nextValue;
setShowScrollToBottom(nextValue);
}, []);
const scrollViewportToBottom = useCallback( const scrollViewportToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => { (behavior: ScrollBehavior = 'auto') => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport) { if (!viewport) {
@@ -87,6 +99,12 @@ export function useConversationViewportController({
[viewportRef], [viewportRef],
); );
const isAutoScrollSuspended = useCallback(() => autoScrollSuspendedUntilRef.current > Date.now(), []);
const releaseAutoScrollSuspension = useCallback(() => {
autoScrollSuspendedUntilRef.current = 0;
}, []);
const scheduleViewportBottomSync = useCallback( const scheduleViewportBottomSync = useCallback(
(frameCount = 6) => { (frameCount = 6) => {
if (restoreAutoScrollFrameRef.current !== null) { if (restoreAutoScrollFrameRef.current !== null) {
@@ -96,12 +114,12 @@ export function useConversationViewportController({
const run = (remainingFrames: number) => { const run = (remainingFrames: number) => {
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => { restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
if (!shouldStickToBottomRef.current || isConversationContentLoading) { if (!shouldStickToBottomRef.current || isConversationContentLoading || isAutoScrollSuspended()) {
restoreAutoScrollFrameRef.current = null; restoreAutoScrollFrameRef.current = null;
return; return;
} }
setShowScrollToBottom(false); syncShowScrollToBottom(false);
scrollViewportToBottom('auto'); scrollViewportToBottom('auto');
if (remainingFrames <= 1) { if (remainingFrames <= 1) {
@@ -115,7 +133,7 @@ export function useConversationViewportController({
run(frameCount); run(frameCount);
}, },
[isConversationContentLoading, scrollViewportToBottom], [isAutoScrollSuspended, isConversationContentLoading, scrollViewportToBottom, syncShowScrollToBottom],
); );
const handleViewportScroll = useCallback(() => { const handleViewportScroll = useCallback(() => {
@@ -127,10 +145,19 @@ export function useConversationViewportController({
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
const isNearBottom = remainingDistance <= 24; const isNearBottom = remainingDistance <= 24;
const isScrollingUp = viewport.scrollTop < lastViewportScrollTopRef.current - 2;
shouldStickToBottomRef.current = isNearBottom; if (isNearBottom) {
setShowScrollToBottom(!isNearBottom); releaseAutoScrollSuspension();
}, [viewportRef]); } else if (isScrollingUp) {
autoScrollSuspendedUntilRef.current = Date.now() + 1600;
}
const shouldStickToBottom = isNearBottom && !isAutoScrollSuspended();
shouldStickToBottomRef.current = shouldStickToBottom;
lastViewportScrollTopRef.current = viewport.scrollTop;
syncShowScrollToBottom(!shouldStickToBottom);
}, [isAutoScrollSuspended, releaseAutoScrollSuspension, syncShowScrollToBottom, viewportRef]);
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => { const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
if (options?.forceStickToBottom) { if (options?.forceStickToBottom) {
@@ -166,15 +193,15 @@ export function useConversationViewportController({
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!shouldStickToBottomRef.current) { if (!shouldStickToBottomRef.current || isAutoScrollSuspended()) {
return; return;
} }
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto'); scrollViewportToBottom('auto');
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]); }, [chatMessageCount, chatMessageSyncKey, isAutoScrollSuspended, scrollViewportToBottom]);
useEffect(() => { useEffect(() => {
if (isConversationContentLoading || !shouldStickToBottomRef.current) { if (isConversationContentLoading || !shouldStickToBottomRef.current || isAutoScrollSuspended()) {
return; return;
} }
@@ -186,7 +213,13 @@ export function useConversationViewportController({
restoreAutoScrollFrameRef.current = null; restoreAutoScrollFrameRef.current = null;
} }
}; };
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]); }, [
activeConversation?.sessionId,
chatMessageSyncKey,
isAutoScrollSuspended,
isConversationContentLoading,
scheduleViewportBottomSync,
]);
useEffect(() => { useEffect(() => {
const pendingPrependRestore = pendingPrependRestoreRef.current; const pendingPrependRestore = pendingPrependRestoreRef.current;
@@ -233,8 +266,9 @@ export function useConversationViewportController({
} }
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) { if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
releaseAutoScrollSuspension();
shouldStickToBottomRef.current = true; shouldStickToBottomRef.current = true;
setShowScrollToBottom(false); syncShowScrollToBottom(false);
scrollViewportToBottom('auto'); scrollViewportToBottom('auto');
return; return;
} }
@@ -254,7 +288,9 @@ export function useConversationViewportController({
chatMessageCount, chatMessageCount,
handleViewportScroll, handleViewportScroll,
isConversationContentLoading, isConversationContentLoading,
releaseAutoScrollSuspension,
scrollViewportToBottom, scrollViewportToBottom,
syncShowScrollToBottom,
viewportRef, viewportRef,
]); ]);
@@ -382,7 +418,7 @@ export function useConversationViewportController({
return clearSystemStatusTimer; return clearSystemStatusTimer;
} }
if (activeConversation?.currentJobStatus && !runtimeSnapshot) { if (activeConversation?.currentJobStatus) {
clearSystemStatusTimer(); clearSystemStatusTimer();
setActiveSystemStatus(mapJobStatusLabel(activeConversation)); setActiveSystemStatus(mapJobStatusLabel(activeConversation));
setIsSystemStatusPending( setIsSystemStatusPending(
@@ -399,12 +435,18 @@ export function useConversationViewportController({
} }
clearSystemStatusTimer(); clearSystemStatusTimer();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
const latestMessage = messages[messages.length - 1]; const latestMessage = messages[messages.length - 1];
if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') { if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') {
if (activeSystemStatus == null && !isSystemStatusPending) {
return clearSystemStatusTimer;
}
systemStatusTimerRef.current = window.setTimeout(() => {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
systemStatusTimerRef.current = null;
}, 450);
return clearSystemStatusTimer; return clearSystemStatusTimer;
} }
@@ -415,6 +457,15 @@ export function useConversationViewportController({
const nextStatus = mapSystemStatusMessage(latestMessage.text); const nextStatus = mapSystemStatusMessage(latestMessage.text);
if (!nextStatus || isTerminalStatus) { if (!nextStatus || isTerminalStatus) {
if (activeSystemStatus == null && !isSystemStatusPending) {
return clearSystemStatusTimer;
}
systemStatusTimerRef.current = window.setTimeout(() => {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
systemStatusTimerRef.current = null;
}, 450);
return clearSystemStatusTimer; return clearSystemStatusTimer;
} }
@@ -429,10 +480,12 @@ export function useConversationViewportController({
clearSystemStatusTimer, clearSystemStatusTimer,
connectionState, connectionState,
isActivityLogMessage, isActivityLogMessage,
isSystemStatusPending,
mapJobStatusLabel, mapJobStatusLabel,
mapSystemStatusMessage, mapSystemStatusMessage,
messages, messages,
runtimeSnapshot, runtimeSnapshot,
activeSystemStatus,
]); ]);
useEffect(() => { useEffect(() => {
@@ -454,7 +507,7 @@ export function useConversationViewportController({
scrollViewportToBottom, scrollViewportToBottom,
setActiveSystemStatus, setActiveSystemStatus,
setIsSystemStatusPending, setIsSystemStatusPending,
setShowScrollToBottom, setShowScrollToBottom: syncShowScrollToBottom,
shouldStickToBottomRef, shouldStickToBottomRef,
showScrollToBottom, showScrollToBottom,
handleViewportTouchEnd, handleViewportTouchEnd,
@@ -463,5 +516,6 @@ export function useConversationViewportController({
isPullToLoadArmed, isPullToLoadArmed,
pullToLoadDistance, pullToLoadDistance,
queueViewportPrependRestore, queueViewportPrependRestore,
releaseAutoScrollSuspension,
}; };
} }

View File

@@ -9,8 +9,6 @@ import {
} from '../data/chatClientEvents'; } from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway'; import { chatGateway } from '../data/chatGateway';
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
type UseUnreadCountsResult = { type UseUnreadCountsResult = {
chatUnreadCount: number; chatUnreadCount: number;
notificationUnreadCount: number; notificationUnreadCount: number;
@@ -150,21 +148,14 @@ export function useUnreadCounts(): UseUnreadCountsResult {
refreshAllUnreadCounts(); refreshAllUnreadCounts();
}; };
const intervalId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
refreshAllUnreadCounts();
}, UNREAD_COUNT_REFRESH_INTERVAL_MS);
window.addEventListener('focus', handleVisibilityOrFocus); window.addEventListener('focus', handleVisibilityOrFocus);
window.addEventListener('online', handleVisibilityOrFocus);
window.addEventListener('pageshow', handleVisibilityOrFocus); window.addEventListener('pageshow', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus); document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => { return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', handleVisibilityOrFocus); window.removeEventListener('focus', handleVisibilityOrFocus);
window.removeEventListener('online', handleVisibilityOrFocus);
window.removeEventListener('pageshow', handleVisibilityOrFocus); window.removeEventListener('pageshow', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus); document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
}; };

View File

@@ -2,7 +2,34 @@ import type { AppPageDescriptor } from '../../store/appStore/types';
import { isPreviewRuntime } from './previewRuntime'; import { isPreviewRuntime } from './previewRuntime';
export const CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id'; export const CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id';
const PREVIEW_CLIENT_ID_STORAGE_KEY = 'work-app.preview-runtime.client-id';
function readStorageValue(storage: Storage | null | undefined, key: string) {
try {
return storage?.getItem(key)?.trim() ?? '';
} catch {
return '';
}
}
function writeStorageValue(storage: Storage | null | undefined, key: string, value: string) {
try {
if (value) {
storage?.setItem(key, value);
} else {
storage?.removeItem(key);
}
} catch {
// Ignore storage failures in restricted preview runtimes.
}
}
function removeStorageValue(storage: Storage | null | undefined, key: string) {
try {
storage?.removeItem(key);
} catch {
// Ignore storage failures in restricted preview runtimes.
}
}
function getClientStorage() { function getClientStorage() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -11,7 +38,7 @@ function getClientStorage() {
if (isPreviewRuntime()) { if (isPreviewRuntime()) {
return { return {
key: PREVIEW_CLIENT_ID_STORAGE_KEY, key: CLIENT_ID_STORAGE_KEY,
primaryStorage: window.localStorage, primaryStorage: window.localStorage,
legacyStorage: window.sessionStorage, legacyStorage: window.sessionStorage,
}; };
@@ -43,17 +70,17 @@ export function getClientId() {
return ''; return '';
} }
const savedClientId = storageConfig.primaryStorage.getItem(storageConfig.key)?.trim() ?? ''; const savedClientId = readStorageValue(storageConfig.primaryStorage, storageConfig.key);
if (savedClientId) { if (savedClientId) {
return savedClientId; return savedClientId;
} }
const legacyClientId = storageConfig.legacyStorage?.getItem(storageConfig.key)?.trim() ?? ''; const legacyClientId = readStorageValue(storageConfig.legacyStorage, storageConfig.key);
if (legacyClientId) { if (legacyClientId) {
storageConfig.primaryStorage.setItem(storageConfig.key, legacyClientId); writeStorageValue(storageConfig.primaryStorage, storageConfig.key, legacyClientId);
storageConfig.legacyStorage?.removeItem(storageConfig.key); removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
return legacyClientId; return legacyClientId;
} }
@@ -67,8 +94,8 @@ export function clearClientId() {
return; return;
} }
storageConfig.primaryStorage.removeItem(storageConfig.key); removeStorageValue(storageConfig.primaryStorage, storageConfig.key);
storageConfig.legacyStorage?.removeItem(storageConfig.key); removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
} }
export function getOrCreateClientId() { export function getOrCreateClientId() {
@@ -85,8 +112,8 @@ export function getOrCreateClientId() {
} }
const nextClientId = generateClientId(); const nextClientId = generateClientId();
storageConfig.primaryStorage.setItem(storageConfig.key, nextClientId); writeStorageValue(storageConfig.primaryStorage, storageConfig.key, nextClientId);
storageConfig.legacyStorage?.removeItem(storageConfig.key); removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
return nextClientId; return nextClientId;
} }

View File

@@ -0,0 +1,60 @@
export type CodexModelOption = {
value: string;
label: string;
description: string;
};
export const DEFAULT_CODEX_MODEL = 'gpt-5.4';
export const CODEX_MODEL_OPTIONS: CodexModelOption[] = [
{
value: 'gpt-5.4',
label: 'GPT-5.4',
description: '기본 균형형 모델',
},
{
value: 'gpt-5.4-mini',
label: 'GPT-5.4 Mini',
description: '빠른 응답 중심',
},
{
value: 'gpt-5.3-codex',
label: 'GPT-5.3 Codex',
description: '코딩 최적화',
},
{
value: 'gpt-5.3-codex-spark',
label: 'GPT-5.3 Codex Spark',
description: '초고속 코딩 응답',
},
{
value: 'gpt-5.2-codex',
label: 'GPT-5.2 Codex',
description: '안정적인 코드 작업',
},
{
value: 'gpt-5.1-codex-mini',
label: 'GPT-5.1 Codex Mini',
description: '가벼운 작업용',
},
{
value: 'gpt-5.1-codex-max',
label: 'GPT-5.1 Codex Max',
description: '깊은 추론 중심',
},
];
export function normalizeCodexModel(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (!normalized) {
return DEFAULT_CODEX_MODEL;
}
return CODEX_MODEL_OPTIONS.some((option) => option.value === normalized) ? normalized : DEFAULT_CODEX_MODEL;
}
export function resolveCodexModelLabel(value: string | null | undefined) {
const normalized = normalizeCodexModel(value);
return CODEX_MODEL_OPTIONS.find((option) => option.value === normalized)?.label ?? normalized;
}

View File

@@ -0,0 +1,397 @@
import { useSyncExternalStore } from 'react';
import {
doesIsolatedChatRoomScopeMatch,
normalizeIsolatedChatRoomScope,
type IsolatedChatRoomScope,
} from './isolatedChatRooms';
const ACTIVE_SCOPE_STORAGE_KEY = 'isolated-chat-room:active-scope';
const SESSION_SCOPE_STORAGE_KEY = 'isolated-chat-room:session-scopes';
const WINDOW_OPEN_STORAGE_KEY = 'isolated-chat-room:window-open';
const MINIMIZED_SCOPE_LIST_STORAGE_KEY = 'isolated-chat-room:minimized-scopes';
const listeners = new Set<() => void>();
let cachedActiveScopeRaw = '';
let cachedActiveScopeValue: IsolatedChatRoomScope | null = null;
let cachedMinimizedScopeListRaw = '';
let cachedMinimizedScopeListValue: MinimizedIsolatedChatRoomEntry[] = [];
export type MinimizedIsolatedChatRoomEntry = {
id: string;
scope: IsolatedChatRoomScope;
minimizedAt: string;
};
function emit() {
listeners.forEach((listener) => listener());
}
function readStorageValue(key: string) {
if (typeof window === 'undefined') {
return '';
}
return window.sessionStorage.getItem(key) ?? '';
}
export function readActiveIsolatedChatRoomScope() {
const rawValue = readStorageValue(ACTIVE_SCOPE_STORAGE_KEY) || 'null';
if (rawValue === cachedActiveScopeRaw) {
return cachedActiveScopeValue;
}
try {
cachedActiveScopeRaw = rawValue;
cachedActiveScopeValue = normalizeIsolatedChatRoomScope(JSON.parse(rawValue));
return cachedActiveScopeValue;
} catch {
cachedActiveScopeRaw = rawValue;
cachedActiveScopeValue = null;
return cachedActiveScopeValue;
}
}
export function writeActiveIsolatedChatRoomScope(scope: IsolatedChatRoomScope | null) {
if (typeof window === 'undefined') {
return;
}
const previousRawValue = readStorageValue(ACTIVE_SCOPE_STORAGE_KEY) || 'null';
if (!scope) {
if (previousRawValue === 'null') {
cachedActiveScopeRaw = 'null';
cachedActiveScopeValue = null;
return;
}
cachedActiveScopeRaw = 'null';
cachedActiveScopeValue = null;
window.sessionStorage.removeItem(ACTIVE_SCOPE_STORAGE_KEY);
emit();
return;
}
const normalizedScope = normalizeIsolatedChatRoomScope(scope);
if (!normalizedScope) {
if (previousRawValue === 'null') {
cachedActiveScopeRaw = 'null';
cachedActiveScopeValue = null;
return;
}
cachedActiveScopeRaw = 'null';
cachedActiveScopeValue = null;
window.sessionStorage.removeItem(ACTIVE_SCOPE_STORAGE_KEY);
emit();
return;
}
const rawValue = JSON.stringify(normalizedScope);
if (rawValue === previousRawValue) {
cachedActiveScopeRaw = rawValue;
cachedActiveScopeValue = normalizedScope;
return;
}
cachedActiveScopeRaw = rawValue;
cachedActiveScopeValue = normalizedScope;
window.sessionStorage.setItem(ACTIVE_SCOPE_STORAGE_KEY, rawValue);
emit();
}
export function readIsolatedChatRoomsWindowOpen() {
if (typeof window === 'undefined') {
return false;
}
return readStorageValue(WINDOW_OPEN_STORAGE_KEY) === '1';
}
export function writeIsolatedChatRoomsWindowOpen(open: boolean) {
if (typeof window === 'undefined') {
return;
}
if (open) {
window.sessionStorage.setItem(WINDOW_OPEN_STORAGE_KEY, '1');
} else {
window.sessionStorage.removeItem(WINDOW_OPEN_STORAGE_KEY);
}
emit();
}
function readSessionScopeMap() {
if (typeof window === 'undefined') {
return {} as Record<string, IsolatedChatRoomScope>;
}
try {
const parsed = JSON.parse(readStorageValue(SESSION_SCOPE_STORAGE_KEY) || '{}') as Record<string, IsolatedChatRoomScope>;
return typeof parsed === 'object' && parsed ? parsed : {};
} catch {
return {};
}
}
function writeSessionScopeMap(map: Record<string, IsolatedChatRoomScope>) {
if (typeof window === 'undefined') {
return;
}
const rawValue = JSON.stringify(map);
const previousRawValue = readStorageValue(SESSION_SCOPE_STORAGE_KEY) || '{}';
if (rawValue === previousRawValue) {
return;
}
window.sessionStorage.setItem(SESSION_SCOPE_STORAGE_KEY, rawValue);
emit();
}
function createMinimizedEntryId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `minimized-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function readMinimizedScopeList() {
if (typeof window === 'undefined') {
return [] as MinimizedIsolatedChatRoomEntry[];
}
const rawValue = readStorageValue(MINIMIZED_SCOPE_LIST_STORAGE_KEY) || '[]';
if (rawValue === cachedMinimizedScopeListRaw) {
return cachedMinimizedScopeListValue;
}
try {
const parsed = JSON.parse(rawValue);
if (!Array.isArray(parsed)) {
cachedMinimizedScopeListRaw = rawValue;
cachedMinimizedScopeListValue = [];
return cachedMinimizedScopeListValue;
}
cachedMinimizedScopeListRaw = rawValue;
cachedMinimizedScopeListValue = parsed
.map((item) => {
const scope = normalizeIsolatedChatRoomScope(item?.scope ?? null);
if (!scope) {
return null;
}
return {
id: String(item?.id ?? '').trim() || createMinimizedEntryId(),
scope,
minimizedAt: String(item?.minimizedAt ?? '').trim() || new Date().toISOString(),
} satisfies MinimizedIsolatedChatRoomEntry;
})
.filter((item): item is MinimizedIsolatedChatRoomEntry => item != null);
return cachedMinimizedScopeListValue;
} catch {
cachedMinimizedScopeListRaw = rawValue;
cachedMinimizedScopeListValue = [];
return cachedMinimizedScopeListValue;
}
}
function writeMinimizedScopeList(entries: MinimizedIsolatedChatRoomEntry[]) {
if (typeof window === 'undefined') {
return;
}
if (entries.length === 0) {
cachedMinimizedScopeListRaw = '[]';
cachedMinimizedScopeListValue = [];
window.sessionStorage.removeItem(MINIMIZED_SCOPE_LIST_STORAGE_KEY);
emit();
return;
}
const rawValue = JSON.stringify(entries);
cachedMinimizedScopeListRaw = rawValue;
cachedMinimizedScopeListValue = entries;
window.sessionStorage.setItem(MINIMIZED_SCOPE_LIST_STORAGE_KEY, rawValue);
emit();
}
export function readIsolatedChatRoomSessionScope(sessionId: string | null | undefined) {
const normalizedSessionId = String(sessionId ?? '').trim();
if (!normalizedSessionId) {
return null;
}
return normalizeIsolatedChatRoomScope(readSessionScopeMap()[normalizedSessionId] ?? null);
}
export function writeIsolatedChatRoomSessionScope(sessionId: string, scope: IsolatedChatRoomScope | null) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const current = readSessionScopeMap();
if (!scope) {
if (!current[normalizedSessionId]) {
return;
}
delete current[normalizedSessionId];
writeSessionScopeMap(current);
return;
}
const normalizedScope = normalizeIsolatedChatRoomScope(scope);
if (!normalizedScope) {
if (!current[normalizedSessionId]) {
return;
}
delete current[normalizedSessionId];
writeSessionScopeMap(current);
return;
}
const previousScope = normalizeIsolatedChatRoomScope(current[normalizedSessionId] ?? null);
if (previousScope && doesIsolatedChatRoomScopeMatch(previousScope, normalizedScope)) {
current[normalizedSessionId] = previousScope;
return;
}
current[normalizedSessionId] = normalizedScope;
writeSessionScopeMap(current);
}
export function pruneIsolatedChatRoomSessionScopes(validSessionIds: string[]) {
if (typeof window === 'undefined') {
return;
}
const validSessionIdSet = new Set(validSessionIds.map((item) => String(item).trim()).filter(Boolean));
const current = readSessionScopeMap();
let didChange = false;
Object.keys(current).forEach((sessionId) => {
if (validSessionIdSet.has(sessionId)) {
return;
}
delete current[sessionId];
didChange = true;
});
if (didChange) {
writeSessionScopeMap(current);
}
}
export function readMinimizedIsolatedChatRoomEntries() {
return readMinimizedScopeList();
}
export function upsertMinimizedIsolatedChatRoomEntry(scope: IsolatedChatRoomScope | null | undefined) {
const normalizedScope = normalizeIsolatedChatRoomScope(scope);
if (!normalizedScope) {
return null;
}
const current = readMinimizedScopeList();
const existing = current.find((item) => doesIsolatedChatRoomScopeMatch(item.scope, normalizedScope)) ?? null;
const nextEntry: MinimizedIsolatedChatRoomEntry = {
id: existing?.id ?? createMinimizedEntryId(),
scope: normalizedScope,
minimizedAt: new Date().toISOString(),
};
const nextEntries = [nextEntry, ...current.filter((item) => item.id !== nextEntry.id)];
writeMinimizedScopeList(nextEntries);
return nextEntry;
}
export function removeMinimizedIsolatedChatRoomEntry(entryId: string | null | undefined) {
const normalizedEntryId = String(entryId ?? '').trim();
if (!normalizedEntryId) {
return;
}
const current = readMinimizedScopeList();
const nextEntries = current.filter((item) => item.id !== normalizedEntryId);
if (nextEntries.length === current.length) {
return;
}
writeMinimizedScopeList(nextEntries);
}
export function removeMinimizedIsolatedChatRoomEntryByScope(scope: IsolatedChatRoomScope | null | undefined) {
const normalizedScope = normalizeIsolatedChatRoomScope(scope);
if (!normalizedScope) {
return;
}
const current = readMinimizedScopeList();
const nextEntries = current.filter((item) => !doesIsolatedChatRoomScopeMatch(item.scope, normalizedScope));
if (nextEntries.length === current.length) {
return;
}
writeMinimizedScopeList(nextEntries);
}
export function useActiveIsolatedChatRoomScope() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
readActiveIsolatedChatRoomScope,
() => null,
);
}
export function useIsolatedChatRoomsWindowOpen() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
readIsolatedChatRoomsWindowOpen,
() => false,
);
}
export function useMinimizedIsolatedChatRoomEntries() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
readMinimizedIsolatedChatRoomEntries,
() => [],
);
}

View File

@@ -0,0 +1,155 @@
export const ISOLATED_CHAT_ROOM_SESSION_PREFIX = 'chat-room-menu-';
export const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
export type IsolatedChatRoomScope = {
topMenu: string;
menuTitle: string;
featureTitle: string;
focusedComponentId: string | null;
pageUrl: string;
selectionSummary?: string | null;
selectionIds?: string[];
errorSummary?: string | null;
sourceAppId?: string | null;
launchedAt: string;
};
export type MainChatPanelMode = 'live' | 'rooms';
export function isIsolatedChatRoomSessionId(sessionId: string | null | undefined) {
return String(sessionId ?? '').trim().startsWith(ISOLATED_CHAT_ROOM_SESSION_PREFIX);
}
export function isManagedChatShareSessionId(sessionId: string | null | undefined) {
return String(sessionId ?? '').trim().startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
}
export function createIsolatedChatRoomSessionId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${ISOLATED_CHAT_ROOM_SESSION_PREFIX}${crypto.randomUUID()}`;
}
return `${ISOLATED_CHAT_ROOM_SESSION_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function shouldShowConversationForMode(sessionId: string, mode: MainChatPanelMode) {
const isIsolatedRoom = isIsolatedChatRoomSessionId(sessionId);
const isManagedShareRoom = isManagedChatShareSessionId(sessionId);
return mode === 'rooms' ? isIsolatedRoom || isManagedShareRoom : !isIsolatedRoom && !isManagedShareRoom;
}
export function resolveChatPathForSession(sessionId: string) {
return isIsolatedChatRoomSessionId(sessionId) || isManagedChatShareSessionId(sessionId) ? '/chat/rooms' : '/chat/live';
}
export function normalizeIsolatedChatRoomScope(
scope: Partial<IsolatedChatRoomScope> | null | undefined,
): IsolatedChatRoomScope | null {
if (!scope) {
return null;
}
const menuTitle = String(scope.menuTitle ?? '').trim();
const featureTitle = String(scope.featureTitle ?? '').trim();
const pageUrl = String(scope.pageUrl ?? '').trim();
if (!menuTitle && !featureTitle && !pageUrl) {
return null;
}
return {
topMenu: String(scope.topMenu ?? '').trim() || 'unknown',
menuTitle: menuTitle || '현재 메뉴',
featureTitle: featureTitle || menuTitle || '현재 기능',
focusedComponentId: String(scope.focusedComponentId ?? '').trim() || null,
pageUrl,
selectionSummary: String(scope.selectionSummary ?? '').trim() || null,
selectionIds: Array.isArray(scope.selectionIds)
? scope.selectionIds.map((item) => String(item).trim()).filter(Boolean)
: [],
errorSummary: String(scope.errorSummary ?? '').trim() || null,
sourceAppId: String(scope.sourceAppId ?? '').trim() || null,
launchedAt: String(scope.launchedAt ?? '').trim() || new Date().toISOString(),
};
}
export function buildIsolatedChatRoomTitle(scope: IsolatedChatRoomScope | null | undefined) {
if (!scope) {
return '격리 채팅방';
}
return `${scope.menuTitle} · ${scope.featureTitle}`.trim();
}
export function buildIsolatedChatRoomRequestBadgeLabel(scope: IsolatedChatRoomScope | null | undefined) {
if (!scope) {
return '격리 요청';
}
return scope.focusedComponentId?.trim() || scope.featureTitle || scope.menuTitle || '격리 요청';
}
export function buildIsolatedChatRoomContextSupplement(scope: IsolatedChatRoomScope | null | undefined) {
if (!scope) {
return '';
}
const lines = [
'## 격리 채팅방 범위',
`- 현재 활성 메뉴: ${scope.menuTitle}`,
`- 현재 기능: ${scope.featureTitle}`,
`- topMenu: ${scope.topMenu || '없음'}`,
`- focusedComponentId: ${scope.focusedComponentId || '없음'}`,
`- pageUrl: ${scope.pageUrl || '없음'}`,
];
if (scope.selectionSummary) {
lines.push(`- 현재 선택: ${scope.selectionSummary}`);
}
if (scope.selectionIds && scope.selectionIds.length > 0) {
lines.push(`- 선택 ID: ${scope.selectionIds.join(', ')}`);
}
if (scope.errorSummary) {
lines.push('');
lines.push('## 최근 참조 에러');
lines.push(scope.errorSummary);
}
return lines.join('\n').trim();
}
function normalizeScopeCompareValue(value: string | null | undefined) {
return String(value ?? '').trim();
}
function normalizeScopeSelectionIds(value: string[] | null | undefined) {
return (value ?? []).map((item) => String(item).trim()).filter(Boolean).sort();
}
export function doesIsolatedChatRoomScopeMatch(
left: IsolatedChatRoomScope | null | undefined,
right: IsolatedChatRoomScope | null | undefined,
) {
const normalizedLeft = normalizeIsolatedChatRoomScope(left);
const normalizedRight = normalizeIsolatedChatRoomScope(right);
if (!normalizedLeft || !normalizedRight) {
return false;
}
const leftSelectionIds = normalizeScopeSelectionIds(normalizedLeft.selectionIds);
const rightSelectionIds = normalizeScopeSelectionIds(normalizedRight.selectionIds);
return (
normalizeScopeCompareValue(normalizedLeft.sourceAppId) === normalizeScopeCompareValue(normalizedRight.sourceAppId) &&
normalizeScopeCompareValue(normalizedLeft.topMenu) === normalizeScopeCompareValue(normalizedRight.topMenu) &&
normalizeScopeCompareValue(normalizedLeft.menuTitle) === normalizeScopeCompareValue(normalizedRight.menuTitle) &&
normalizeScopeCompareValue(normalizedLeft.featureTitle) === normalizeScopeCompareValue(normalizedRight.featureTitle) &&
normalizeScopeCompareValue(normalizedLeft.focusedComponentId) === normalizeScopeCompareValue(normalizedRight.focusedComponentId) &&
normalizeScopeCompareValue(normalizedLeft.pageUrl) === normalizeScopeCompareValue(normalizedRight.pageUrl) &&
leftSelectionIds.length === rightSelectionIds.length &&
leftSelectionIds.every((item, index) => item === rightSelectionIds[index])
);
}

View File

@@ -1,12 +1,23 @@
import { Layout } from 'antd'; import { Layout } from 'antd';
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer'; import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
import { useAppStore } from '../../../store'; import { useAppStore } from '../../../store';
import { useTokenAccess } from '../tokenAccess'; import { useTokenAccess } from '../tokenAccess';
import { syncAppConfigFromServer, useAppConfig } from '../appConfig'; import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
import { getChatActionContextSnapshot } from '../chatActionContextStore';
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2'; import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
import { SystemChatPanel } from '../SystemChatPanel';
import { ScopedChatRoomsWindow, ScopedChatRoomsWindowDock } from '../ScopedChatRoomsWindow';
import {
removeMinimizedIsolatedChatRoomEntryByScope,
useActiveIsolatedChatRoomScope,
useIsolatedChatRoomsWindowOpen,
writeActiveIsolatedChatRoomScope,
writeIsolatedChatRoomsWindowOpen,
} from '../isolatedChatRoomScopeStore';
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts'; import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
import { normalizeIsolatedChatRoomScope } from '../isolatedChatRooms';
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils'; import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
import { MainContent } from '../MainContent'; import { MainContent } from '../MainContent';
import { MainHeader } from '../MainHeader'; import { MainHeader } from '../MainHeader';
@@ -43,6 +54,8 @@ import {
type TopMenuKey, type TopMenuKey,
} from '../routes'; } from '../routes';
const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive';
function parseRoute(pathname: string): { function parseRoute(pathname: string): {
topMenu: TopMenuKey; topMenu: TopMenuKey;
docsMenu: string; docsMenu: string;
@@ -90,6 +103,8 @@ function parseRoute(pathname: string): {
first === 'history' || first === 'history' ||
first === 'automation-type' || first === 'automation-type' ||
first === 'automation-context' || first === 'automation-context' ||
first === 'token-setting' ||
first === 'shared-resource' ||
first === 'server-command') first === 'server-command')
) { ) {
return { return {
@@ -105,11 +120,13 @@ function parseRoute(pathname: string): {
if ( if (
top === 'chat' && top === 'chat' &&
(first === 'live' || (first === 'live' ||
first === 'rooms' ||
first === 'changes' || first === 'changes' ||
first === 'resources' || first === 'resources' ||
first === 'errors' || first === 'errors' ||
first === 'manage' || first === 'manage' ||
first === 'manage-defaults') first === 'manage-defaults' ||
first === 'manage-share')
) { ) {
return { return {
topMenu: 'chat', topMenu: 'chat',
@@ -121,7 +138,7 @@ function parseRoute(pathname: string): {
}; };
} }
if (top === 'play' && (first === 'layout' || first === 'test' || first === 'cbt')) { if (top === 'play' && (first === 'layout' || first === 'draw' || first === 'apps' || first === 'test' || first === 'cbt')) {
return { return {
topMenu: 'play', topMenu: 'play',
docsMenu: DOCS_DEFAULT_FOLDER, docsMenu: DOCS_DEFAULT_FOLDER,
@@ -212,14 +229,22 @@ function resolveSidebarOpenKeys(
} }
if (topMenu === 'plans') { if (topMenu === 'plans') {
return planMenu === 'server-command' ? ['server-group'] : ['plan-group']; if (planMenu === 'server-command') {
return ['server-group'];
}
if (planMenu === 'token-setting' || planMenu === 'shared-resource') {
return ['token-management-group'];
}
return ['plan-group'];
} }
if (chatMenu === 'errors') { if (chatMenu === 'errors') {
return ['app-log-group']; return ['app-log-group'];
} }
return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['codex-live-group']; return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['chat-group'];
} }
export function MainLayout() { export function MainLayout() {
@@ -227,13 +252,18 @@ export function MainLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { setCurrentPage, setFocusedComponentId } = useAppStore(); const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
const { hasAccess } = useTokenAccess(); const { hasAccess } = useTokenAccess();
const activeScopedChatRoomScope = useActiveIsolatedChatRoomScope();
const isScopedChatRoomsWindowOpen = useIsolatedChatRoomsWindowOpen();
const appConfig = useAppConfig(); const appConfig = useAppConfig();
const { openSearch, setOptions: setSearchOptions } = useSearchLayer(); const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
const layoutData = useMainLayoutData(); const layoutData = useMainLayoutData();
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]); const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport()); const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport());
const [isEReaderImmersiveActive, setIsEReaderImmersiveActive] = useState(() =>
typeof document !== 'undefined' ? document.body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS) : false,
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)), resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)),
); );
@@ -250,14 +280,58 @@ export function MainLayout() {
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0); const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData; const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
const { chatUnreadCount } = useUnreadCounts(); const { chatUnreadCount } = useUnreadCounts();
const navigateWithinApp = (path: string, options?: { replace?: boolean }) => { const navigateWithinApp = (path: string, options?: { replace?: boolean; resetSearch?: boolean }) => {
const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, location.search) : path; const baseSearch = options?.resetSearch ? '' : location.search;
navigate(nextPath, options); const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, baseSearch) : path;
navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace });
}; };
const openScopedChatRooms = useCallback(() => {
const actionSnapshot = getChatActionContextSnapshot();
const scope = normalizeIsolatedChatRoomScope({
topMenu: currentPage.topMenu,
menuTitle: currentPage.title,
featureTitle: actionSnapshot.featureTitle ?? focusedComponentId ?? currentPage.title,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
selectionSummary: actionSnapshot.selectionSummary,
selectionIds: actionSnapshot.selectionIds,
sourceAppId: actionSnapshot.sourceAppId,
launchedAt: new Date().toISOString(),
});
writeActiveIsolatedChatRoomScope(scope);
removeMinimizedIsolatedChatRoomEntryByScope(scope);
if (routeState.chatMenu === 'rooms') {
writeIsolatedChatRoomsWindowOpen(false);
return;
}
writeIsolatedChatRoomsWindowOpen(true);
}, [currentPage.title, currentPage.topMenu, focusedComponentId, routeState.chatMenu]);
useEffect(() => { useEffect(() => {
void syncAppConfigFromServer(); void syncAppConfigFromServer();
}, []); }, []);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
}
const body = document.body;
const syncEReaderImmersiveState = () => {
setIsEReaderImmersiveActive(body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS));
};
syncEReaderImmersiveState();
const observer = new MutationObserver(syncEReaderImmersiveState);
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
return () => {
observer.disconnect();
};
}, []);
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia('(max-width: 768px)'); const mediaQuery = window.matchMedia('(max-width: 768px)');
@@ -331,45 +405,50 @@ export function MainLayout() {
setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null)); setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null));
}, [routeState.planMenu, routeState.topMenu]); }, [routeState.planMenu, routeState.topMenu]);
const gestureLayer = useMemo(
() => ({
id: 'main-layout',
enabled:
!isEReaderImmersiveActive &&
!(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
gestures: [
{
id: 'mobile-top-right-pull-alert',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-down-top-right' as const,
onTrigger: () => {
openSearch();
},
},
{
id: 'mobile-middle-right-search-window',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-left-middle-right' as const,
hotZoneSize: 36,
minDistance: 180,
minViewportDistanceRatio: 0.35,
maxHorizontalDrift: 72,
onTrigger: openScopedChatRooms,
},
],
}),
[isEReaderImmersiveActive, isMobileViewport, openScopedChatRooms, openSearch, routeState.docsMenu, routeState.topMenu],
);
useGesturePageState('anyway'); useGesturePageState('anyway');
useGestureLayer({ useGestureLayer(gestureLayer);
id: 'main-layout',
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER),
gestures: [
{
id: 'mobile-top-right-pull-alert',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-down-top-right',
onTrigger: () => {
openSearch();
},
},
{
id: 'mobile-middle-right-search-window',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-left-middle-right',
hotZoneSize: 36,
minDistance: 180,
minViewportDistanceRatio: 0.35,
maxHorizontalDrift: 72,
onTrigger: () => {
openSearch('window');
},
},
],
});
useEffect(() => { useEffect(() => {
const handleWindowKeyDown = (event: KeyboardEvent) => { const handleWindowKeyDown = (event: KeyboardEvent) => {
if (event.repeat || isTypingTarget(event.target)) { if (event.repeat || isTypingTarget(event.target) || isEReaderImmersiveActive) {
return; return;
} }
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) { if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
event.preventDefault(); event.preventDefault();
openSearch('window'); openScopedChatRooms();
return; return;
} }
@@ -384,7 +463,13 @@ export function MainLayout() {
return () => { return () => {
window.removeEventListener('keydown', handleWindowKeyDown); window.removeEventListener('keydown', handleWindowKeyDown);
}; };
}, [appConfig.gestureShortcuts.openSearch, appConfig.gestureShortcuts.openWindowSearch, openSearch]); }, [
appConfig.gestureShortcuts.openSearch,
appConfig.gestureShortcuts.openWindowSearch,
isEReaderImmersiveActive,
openScopedChatRooms,
openSearch,
]);
const selectedDocs = useMemo( const selectedDocs = useMemo(
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu), () => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
@@ -448,6 +533,10 @@ export function MainLayout() {
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]); const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]); const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]); const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
const activePlayAppId = routeState.topMenu === 'play' && routeState.playMenu === 'apps'
? searchParams.get('app')?.trim() ?? ''
: '';
const isPlayAppFullscreen = activePlayAppId.length > 0;
const initialSelectedPlanId = Number(searchParams.get('planId')); const initialSelectedPlanId = Number(searchParams.get('planId'));
const initialSelectedWorkId = searchParams.get('workId'); const initialSelectedWorkId = searchParams.get('workId');
@@ -477,8 +566,8 @@ export function MainLayout() {
}} }}
> >
<Layout className={`app-shell app-shell--docs-api${previewRuntime ? ' app-shell--preview-runtime' : ''}`}> <Layout className={`app-shell app-shell--docs-api${previewRuntime ? ' app-shell--preview-runtime' : ''}`}>
{routeState.topMenu === 'chat' ? null : <ChatRuntimeBridgeV2 />} {routeState.topMenu === 'chat' || isPlayAppFullscreen ? null : <ChatRuntimeBridgeV2 />}
{contentExpanded ? null : ( {contentExpanded || isPlayAppFullscreen ? null : (
<MainHeader <MainHeader
activeTopMenu={routeState.topMenu} activeTopMenu={routeState.topMenu}
sidebarCollapsed={sidebarCollapsed} sidebarCollapsed={sidebarCollapsed}
@@ -494,6 +583,9 @@ export function MainLayout() {
navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder)); navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder));
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu))); setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu)));
}} }}
onOpenSearch={() => {
openSearch();
}}
onOpenPlanQuickFilter={(filter) => { onOpenPlanQuickFilter={(filter) => {
const targetPlanMenu = resolvePlanQuickFilterMenu(filter); const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
setActivePlanQuickFilter(filter); setActivePlanQuickFilter(filter);
@@ -506,7 +598,7 @@ export function MainLayout() {
)} )}
<Layout className="app-shell__body"> <Layout className="app-shell__body">
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : ( {contentExpanded || isPlayAppFullscreen || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
<MainSidebar <MainSidebar
activeTopMenu={routeState.topMenu} activeTopMenu={routeState.topMenu}
hasAccess={hasAccess} hasAccess={hasAccess}
@@ -545,14 +637,14 @@ export function MainLayout() {
} }
}} }}
onSelectChatMenu={(key) => { onSelectChatMenu={(key) => {
navigateWithinApp(buildChatPath(key)); navigateWithinApp(buildChatPath(key), { resetSearch: true });
if (isSidebarOverlayViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
}} }}
onSelectPlayMenu={(key) => { onSelectPlayMenu={(key) => {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key); const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test')); navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key));
if (isSidebarOverlayViewport) { if (isSidebarOverlayViewport) {
setSidebarCollapsed(true); setSidebarCollapsed(true);
} }
@@ -572,6 +664,16 @@ export function MainLayout() {
<Outlet /> <Outlet />
</MainContent> </MainContent>
</Layout> </Layout>
{routeState.chatMenu !== 'rooms' && isScopedChatRoomsWindowOpen ? (
<ScopedChatRoomsWindow
onClose={() => {
writeIsolatedChatRoomsWindowOpen(false);
}}
>
<SystemChatPanel lockOuterScrollOnMobile />
</ScopedChatRoomsWindow>
) : null}
{routeState.chatMenu !== 'rooms' ? <ScopedChatRoomsWindowDock /> : null}
</Layout> </Layout>
</MainLayoutContextProvider> </MainLayoutContextProvider>
); );

View File

@@ -5,6 +5,7 @@ import {
buildChatPath, buildChatPath,
buildDocsPath, buildDocsPath,
buildPlansPath, buildPlansPath,
buildPlayAppPath,
buildPlayPath, buildPlayPath,
buildSavedLayoutPath, buildSavedLayoutPath,
getDocsSectionLabel, getDocsSectionLabel,
@@ -13,6 +14,15 @@ import {
PLAN_SIDEBAR_LABELS, PLAN_SIDEBAR_LABELS,
} from '../routes'; } from '../routes';
import { compactKeywords, scrollToElement } from '../mainView/utils'; import { compactKeywords, scrollToElement } from '../mainView/utils';
import { getReadyPlayAppEntries } from '../../../views/play/apps/apps/appsRegistry';
function getCurrentPathnameWithSearch() {
if (typeof window === 'undefined') {
return '/';
}
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
type BuildSearchOptionsParams = { type BuildSearchOptionsParams = {
componentSamples: LoadedSampleEntry[]; componentSamples: LoadedSampleEntry[];
@@ -176,6 +186,30 @@ export function buildSearchOptions({
}, },
onSelectWindow, onSelectWindow,
} satisfies SearchKeywordOption, } satisfies SearchKeywordOption,
{
id: 'page:plans:token-setting',
label: `토큰관리 / ${PLAN_SIDEBAR_LABELS['token-setting']}`,
group: 'Page',
keywords: ['plans', 'plan', 'token', 'token setting', '토큰', '토큰 설정', '권한 설정', '앱 권한'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('token-setting'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:shared-resource',
label: `토큰관리 / ${PLAN_SIDEBAR_LABELS['shared-resource']}`,
group: 'Page',
keywords: ['plans', 'plan', 'share token', 'shared resource', '공유 리소스', '공유 토큰', '권한 회수', '활동 내역'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlansPath('shared-resource'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{ {
id: 'page:plans:history', id: 'page:plans:history',
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`, label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
@@ -203,6 +237,18 @@ export function buildSearchOptions({
}, },
onSelectWindow, onSelectWindow,
}, },
{
id: 'page:chat:rooms',
label: '시스템 채팅 / 시스템 채팅',
group: 'Page',
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('rooms'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{ {
id: 'page:chat:live', id: 'page:chat:live',
label: 'Codex Live / Codex Live', label: 'Codex Live / Codex Live',
@@ -275,6 +321,18 @@ export function buildSearchOptions({
}, },
onSelectWindow, onSelectWindow,
} satisfies SearchKeywordOption, } satisfies SearchKeywordOption,
{
id: 'page:chat:manage-share',
label: '채팅 관리 / 공유채팅 생성',
group: 'Page',
keywords: ['chat manage', 'shared chat', 'share room', '공유채팅', '공유 채팅', '채팅방 생성', '공유 url'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('manage-share'));
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
...docFolders.map((folder) => ({ ...docFolders.map((folder) => ({
id: `docs-folder:${folder}`, id: `docs-folder:${folder}`,
label: `Docs / ${getDocsSectionLabel(folder)}`, label: `Docs / ${getDocsSectionLabel(folder)}`,
@@ -313,6 +371,19 @@ export function buildSearchOptions({
}, },
onSelectWindow, onSelectWindow,
})), })),
...getReadyPlayAppEntries().map((entry) => ({
id: `page:play:app:${entry.id}`,
label: `Apps / ${entry.name}`,
group: 'Play App',
keywords: compactKeywords([entry.id, entry.name, 'apps', 'app', 'game', '게임', ...(entry.searchKeywords ?? [])]),
description: entry.searchDescription,
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildPlayAppPath(entry.id, 'embedded', getCurrentPathnameWithSearch()));
setFocusedComponentId(null);
},
onSelectWindow,
})),
...componentSamples.map((entry) => ({ ...componentSamples.map((entry) => ({
id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`, id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
label: entry.sampleMeta.title, label: entry.sampleMeta.title,

View File

@@ -1,32 +1,26 @@
import { import {
CheckCircleFilled, CheckCircleFilled,
ClockCircleOutlined,
CloseCircleFilled, CloseCircleFilled,
LoadingOutlined, LoadingOutlined,
MinusCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ChatConversationRequest } from './types'; import type { ChatConversationRequest } from './types';
export type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error'; export type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
export type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'confirmation' | 'execution' | 'result';
export type ActivityChecklistEntry = { export type ActivityChecklistEntry = {
key: string; key: string;
label: string; label: string;
state: ActivityChecklistState; state: ActivityChecklistState;
note: string; };
type ActivityChecklistSourceEntry = {
key: string;
label: string;
checked: boolean;
}; };
function getEntryStateLabel(entry: ActivityChecklistEntry) { function getEntryStateLabel(entry: ActivityChecklistEntry) {
if (entry.state === 'complete') { if (entry.state === 'complete') {
if (entry.key === 'confirmation') {
return '확인 완료';
}
if (entry.key === 'execution' || entry.key === 'result') {
return '회신 완료';
}
return '완료'; return '완료';
} }
@@ -41,142 +35,136 @@ function getEntryStateLabel(entry: ActivityChecklistEntry) {
return '대기'; return '대기';
} }
const CHECKLIST_STAGE_ORDER: ActivityChecklistStageKey[] = ['intake', 'analysis', 'inspection', 'confirmation', 'execution', 'result'];
const CHECKLIST_STAGE_LABELS: Record<ActivityChecklistStageKey, string> = {
intake: '요청 접수',
analysis: '요청 분석',
inspection: '관련 확인',
confirmation: '확인',
execution: '구현·응답 작성',
result: '검증·결과 정리',
};
const CHECKLIST_STAGE_PATTERNS: Record<ActivityChecklistStageKey, RegExp[]> = {
intake: [/요청을 접수/i, /대기열 등록/i, /즉시 실행 대기/i, /요청을 처리합니다/i],
analysis: [/요청 분석/i, /분석/i, /생각 중/i, /의도/i, /문맥/i],
inspection: [
/\bdb\b/i,
/데이터베이스/i,
/\bapi\b/i,
/엔드포인트/i,
/응답/i,
/소스/i,
/코드/i,
/파일/i,
/흐름/i,
/쿼리/i,
/집계/i,
/resource/i,
/리소스/i,
/화면/i,
],
confirmation: [/내부 상태 확인/i, /반영 확인/i, /최종 확인/i, /동작 확인/i, /확인 단계/i, /확인합니다/i],
execution: [/구현/i, /수정/i, /변경/i, /작성/i, /빌드/i, /patch/i, /diff/i, /실시간으로 전송 중/i],
result: [/검증/i, /테스트/i, /캡처/i, /preview/i, /스크린샷/i, /완료/i, /결과/i, /정리/i],
};
function stripActivityPrefix(line: string) {
return line.replace(/^#\s*(||||):\s*/u, '').trim();
}
function sanitizeActivitySummary(value: string) {
const candidates = stripActivityPrefix(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.filter(
(line) =>
!line.startsWith('$ ') &&
!line.startsWith('# 결과:') &&
!line.startsWith('# 출력:') &&
!line.startsWith('# command-runner') &&
!/^\[(stderr|stdout)\]/i.test(line),
);
const summary = candidates[0] ?? '';
if (!summary) {
return '';
}
return summary.length > 160 ? `${summary.slice(0, 157).trimEnd()}...` : summary;
}
function normalizeLines(lines: string[]) { function normalizeLines(lines: string[]) {
return lines.map((line) => String(line ?? '').trim()).filter(Boolean); return lines.map((line) => String(line ?? '').trim()).filter(Boolean);
} }
function getLastStageSummary(lines: string[], stageKey: ActivityChecklistStageKey) { function parseDirectiveChecklistEntries(lines: string[]) {
const patterns = CHECKLIST_STAGE_PATTERNS[stageKey]; const entriesByLabel = new Map<string, ActivityChecklistSourceEntry>();
const labelOrder: string[] = [];
for (let index = lines.length - 1; index >= 0; index -= 1) { lines.forEach((line, index) => {
const candidate = sanitizeActivitySummary(lines[index] ?? ''); const match = line.match(/^(|)\s+(.+)$/u);
if (!candidate) { if (!match) {
continue; return;
} }
if (patterns.some((pattern) => pattern.test(candidate))) { const [, marker, rawLabel] = match;
return candidate; const label = rawLabel.trim();
}
}
return ''; if (!label) {
return;
}
if (!entriesByLabel.has(label)) {
labelOrder.push(label);
}
entriesByLabel.set(label, {
key: `directive-${index}`,
label,
checked: marker === '☑',
});
});
return labelOrder
.map((label) => entriesByLabel.get(label))
.filter((entry): entry is ActivityChecklistSourceEntry => entry != null);
} }
function resolveObservationSummary(lines: string[]) { function extractProgressLabel(line: string) {
const labels = new Set<string>(); if (!line.startsWith('# 진행:')) {
return '';
for (const line of lines) {
const normalized = sanitizeActivitySummary(line);
if (/\bdb\b/i.test(normalized) || /데이터베이스|sql|쿼리|집계/i.test(normalized)) {
labels.add('DB');
}
if (/\bapi\b/i.test(normalized) || /엔드포인트|fetch|호출|응답/i.test(normalized)) {
labels.add('API');
}
if (/소스|코드|파일|tsx|ts|js|css|흐름/i.test(normalized)) {
labels.add('소스');
}
if (/화면|리소스|preview|캡처|스크린샷/i.test(normalized)) {
labels.add('화면');
}
} }
return Array.from(labels).join(' · '); const rawLabel = line.slice('# 진행:'.length).trim();
const commandSeparatorIndex = rawLabel.indexOf(' $ ');
if (commandSeparatorIndex < 0) {
return '';
}
return rawLabel.slice(0, commandSeparatorIndex).trim();
} }
function resolveCurrentStageKey(lines: string[], request?: ChatConversationRequest) { function parseProgressChecklistEntries(lines: string[]) {
if (request?.status === 'completed' || request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') { const entriesByLabel = new Map<string, ActivityChecklistSourceEntry>();
return 'result' as const; const labelOrder: string[] = [];
}
for (let index = lines.length - 1; index >= 0; index -= 1) { lines.forEach((line, index) => {
const candidate = sanitizeActivitySummary(lines[index] ?? ''); const label = extractProgressLabel(line);
if (!candidate) { if (!label) {
continue; return;
} }
for (const stageKey of ['result', 'execution', 'confirmation', 'inspection', 'analysis', 'intake'] as const) { if (!entriesByLabel.has(label)) {
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) { labelOrder.push(label);
return stageKey;
}
} }
}
if (request?.status === 'started') { entriesByLabel.set(label, {
return 'analysis' as const; key: `progress-${index}`,
} label,
checked: false,
});
});
return 'intake' as const; return labelOrder
.map((label) => entriesByLabel.get(label))
.filter((entry): entry is ActivityChecklistSourceEntry => entry != null);
} }
function buildChecklistSourceEntries(lines: string[]) {
const directiveEntries = parseDirectiveChecklistEntries(lines);
if (directiveEntries.length > 0) {
return {
entries: directiveEntries,
hasExplicitCheckedState: true,
};
}
return {
entries: parseProgressChecklistEntries(lines),
hasExplicitCheckedState: false,
};
}
export function buildChatActivityChecklistEntries(lines: string[], request?: ChatConversationRequest) {
const { entries: sourceEntries, hasExplicitCheckedState } = buildChecklistSourceEntries(lines);
if (sourceEntries.length === 0) {
return [];
}
const lastUncheckedIndex = sourceEntries.findIndex((entry) => !entry.checked);
const currentEntryIndex =
request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed'
? Math.max(lastUncheckedIndex, 0)
: lastUncheckedIndex;
const isTerminalComplete = request?.status === 'completed';
const isTerminalError = request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed';
return sourceEntries.map<ActivityChecklistEntry>((entry, index) => {
let state: ActivityChecklistState = 'pending';
if ((hasExplicitCheckedState && entry.checked) || isTerminalComplete) {
state = 'complete';
} else if (!hasExplicitCheckedState && currentEntryIndex > 0 && index < currentEntryIndex) {
state = 'complete';
} else if (isTerminalError && index === currentEntryIndex) {
state = 'error';
} else if (index === currentEntryIndex) {
state = 'current';
}
return {
key: entry.key,
label: entry.label,
state,
};
});
}
function resolveResultNote(request?: ChatConversationRequest) { function resolveResultNote(request?: ChatConversationRequest) {
const normalizedStatusMessage = String(request?.statusMessage ?? '').trim(); const normalizedStatusMessage = String(request?.statusMessage ?? '').trim();
@@ -199,63 +187,6 @@ function resolveResultNote(request?: ChatConversationRequest) {
return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.'; return normalizedStatusMessage || '최종 결과를 정리하는 단계입니다.';
} }
export function buildChatActivityChecklistEntries(lines: string[], request?: ChatConversationRequest) {
const currentStageKey = resolveCurrentStageKey(lines, request);
const currentStageIndex = CHECKLIST_STAGE_ORDER.indexOf(currentStageKey);
const isTerminalComplete = request?.status === 'completed';
const isTerminalError = request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed';
const observationSummary = resolveObservationSummary(lines);
return CHECKLIST_STAGE_ORDER.map<ActivityChecklistEntry>((stageKey, index) => {
const summary = getLastStageSummary(lines, stageKey);
let state: ActivityChecklistState = 'pending';
if (isTerminalComplete) {
state = 'complete';
} else if (isTerminalError && index === currentStageIndex) {
state = 'error';
} else if (index < currentStageIndex) {
state = 'complete';
} else if (index === currentStageIndex) {
state = 'current';
}
let note = summary;
if (!note) {
switch (stageKey) {
case 'intake':
note = request?.status === 'queued' ? '대기열 접수 후 순차 실행을 기다립니다.' : '요청을 접수하고 실행 준비를 시작합니다.';
break;
case 'analysis':
note = '요청 의도와 현재 화면 문맥을 정리합니다.';
break;
case 'inspection':
note = observationSummary ? `${observationSummary} 기준으로 확인합니다.` : 'DB, API, 소스, 화면 중 필요한 대상을 확인합니다.';
break;
case 'confirmation':
note = request?.hasResponse ? '내부 상태와 반영 내용을 한 번 더 확인합니다.' : '현재 상태와 확인 포인트를 정리합니다.';
break;
case 'execution':
note = request?.hasResponse ? '응답 초안 또는 변경 결과를 작성 중입니다.' : '필요한 구현과 응답 작성을 진행합니다.';
break;
case 'result':
note = resolveResultNote(request);
break;
}
} else if (stageKey === 'inspection' && observationSummary && !note.includes('·')) {
note = `${note} (${observationSummary})`;
}
return {
key: stageKey,
label: CHECKLIST_STAGE_LABELS[stageKey],
state,
note,
};
});
}
function renderStateIcon(state: ActivityChecklistState) { function renderStateIcon(state: ActivityChecklistState) {
if (state === 'complete') { if (state === 'complete') {
return <CheckCircleFilled aria-hidden="true" />; return <CheckCircleFilled aria-hidden="true" />;
@@ -268,8 +199,7 @@ function renderStateIcon(state: ActivityChecklistState) {
if (state === 'error') { if (state === 'error') {
return <CloseCircleFilled aria-hidden="true" />; return <CloseCircleFilled aria-hidden="true" />;
} }
return <LoadingOutlined aria-hidden="true" />;
return <ClockCircleOutlined aria-hidden="true" />;
} }
function buildSummaryLabel(entries: ActivityChecklistEntry[]) { function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
@@ -292,12 +222,24 @@ function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
return `${completedCount}/${entries.length} 완료`; return `${completedCount}/${entries.length} 완료`;
} }
function buildChecklistStatusLabel(request?: ChatConversationRequest) {
if (request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') {
return resolveResultNote(request);
}
return '최초 작업지시 기준';
}
export function ChatActivityChecklist({ export function ChatActivityChecklist({
lines, lines,
request, request,
title,
statusLabel,
}: { }: {
lines: string[]; lines: string[];
request?: ChatConversationRequest; request?: ChatConversationRequest;
title?: string;
statusLabel?: string;
}) { }) {
if (lines.length === 0 && !request) { if (lines.length === 0 && !request) {
return null; return null;
@@ -306,16 +248,19 @@ export function ChatActivityChecklist({
const normalizedLines = normalizeLines(lines); const normalizedLines = normalizeLines(lines);
const entries = buildChatActivityChecklistEntries(normalizedLines, request); const entries = buildChatActivityChecklistEntries(normalizedLines, request);
if (entries.length === 0) {
return null;
}
return ( return (
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트"> <section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
<div className="app-chat-activity-checklist__header"> <div className="app-chat-activity-checklist__header">
<div className="app-chat-activity-checklist__title-group"> <div className="app-chat-activity-checklist__title-group">
<span className="app-chat-activity-checklist__title">Plan </span> <span className="app-chat-activity-checklist__title">{title?.trim() || '최초 작업 계획 체크리스트'}</span>
<span className="app-chat-activity-checklist__summary">{buildSummaryLabel(entries)}</span> <span className="app-chat-activity-checklist__summary">{buildSummaryLabel(entries)}</span>
</div> </div>
<span className="app-chat-activity-checklist__legend"> <span className="app-chat-activity-checklist__legend">
<MinusCircleOutlined aria-hidden="true" /> <span>{statusLabel?.trim() || buildChecklistStatusLabel(request)}</span>
<span> </span>
</span> </span>
</div> </div>
<ol className="app-chat-activity-checklist__list"> <ol className="app-chat-activity-checklist__list">
@@ -331,7 +276,6 @@ export function ChatActivityChecklist({
<span className="app-chat-activity-checklist__label">{entry.label}</span> <span className="app-chat-activity-checklist__label">{entry.label}</span>
<span className="app-chat-activity-checklist__state">{getEntryStateLabel(entry)}</span> <span className="app-chat-activity-checklist__state">{getEntryStateLabel(entry)}</span>
</div> </div>
<p className="app-chat-activity-checklist__note">{entry.note}</p>
</div> </div>
</li> </li>
))} ))}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,13 @@ import { Button } from 'antd';
import { openChatExternalLink } from './linkNavigation'; import { openChatExternalLink } from './linkNavigation';
import type { ChatMessagePart } from './types'; import type { ChatMessagePart } from './types';
export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePart, { type: 'link_card' }> }) { export function ChatLinkCardPreview({
target,
onOpen,
}: {
target: Extract<ChatMessagePart, { type: 'link_card' }>;
onOpen?: () => void;
}) {
return ( return (
<section className="app-chat-preview-card app-chat-preview-card--link-card"> <section className="app-chat-preview-card app-chat-preview-card--link-card">
<div className="app-chat-preview-card__header"> <div className="app-chat-preview-card__header">
@@ -23,6 +29,7 @@ export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePar
className="app-chat-preview-card__open-link" className="app-chat-preview-card__open-link"
icon={<ExportOutlined />} icon={<ExportOutlined />}
onClick={(event) => { onClick={(event) => {
onOpen?.();
void openChatExternalLink(target.url, event); void openChatExternalLink(target.url, event);
}} }}
> >
@@ -37,6 +44,7 @@ export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePar
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
onClick={(event) => { onClick={(event) => {
onOpen?.();
openChatExternalLink(target.url, event); openChatExternalLink(target.url, event);
}} }}
> >

View File

@@ -97,7 +97,7 @@ function resolvePreviewErrorMessage(previewError: string) {
} }
function resolvePreviewExtension(target: ChatPreviewTarget) { function resolvePreviewExtension(target: ChatPreviewTarget) {
const raw = target.label || target.url; const raw = target.url || target.label;
const normalized = raw.toLowerCase().split('?')[0] ?? ''; const normalized = raw.toLowerCase().split('?')[0] ?? '';
const match = normalized.match(/\.([a-z0-9]+)$/i); const match = normalized.match(/\.([a-z0-9]+)$/i);
return match?.[1] ?? ''; return match?.[1] ?? '';
@@ -263,7 +263,11 @@ function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, p
normalizedPreview.includes('<head') || normalizedPreview.includes('<head') ||
normalizedPreview.includes('<body'); normalizedPreview.includes('<body');
return looksLikeHtml; if (!looksLikeHtml) {
return false;
}
return normalizedPreview.includes('<div id="root"');
} }
export function ChatPreviewBody({ export function ChatPreviewBody({

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ export type RankedLinkPreviewTarget = {
url: string; url: string;
}; };
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) { export function ChatRankedLinkPreview({ target, onOpen }: { target: RankedLinkPreviewTarget; onOpen?: () => void }) {
return ( return (
<section className="app-chat-preview-card app-chat-preview-card--ranked-link"> <section className="app-chat-preview-card app-chat-preview-card--ranked-link">
<div className="app-chat-preview-card__header"> <div className="app-chat-preview-card__header">
@@ -27,6 +27,7 @@ export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTar
className="app-chat-preview-card__open-link" className="app-chat-preview-card__open-link"
icon={<ExportOutlined />} icon={<ExportOutlined />}
onClick={(event) => { onClick={(event) => {
onOpen?.();
void openChatExternalLink(target.url, event); void openChatExternalLink(target.url, event);
}} }}
> >
@@ -41,6 +42,7 @@ export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTar
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
onClick={(event) => { onClick={(event) => {
onOpen?.();
openChatExternalLink(target.url, event); openChatExternalLink(target.url, event);
}} }}
> >

View File

@@ -111,7 +111,7 @@ var CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id';
var CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:'; var CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
var CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:'; var CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
var CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:'; var CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
var CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.'; var CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요. 여러 Codex를 추가한 즉시 실행은 병렬로 처리됩니다.';
var CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; var CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
var CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]'; var CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
var CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]'; var CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
@@ -599,7 +599,7 @@ function appendActivityEventToMessages(previous, event) {
function createIntroMessage(chatTypeLabel, chatTypeDescription) { function createIntroMessage(chatTypeLabel, chatTypeDescription) {
var _a; var _a;
var normalizedChatTypeLabel = (_a = chatTypeLabel === null || chatTypeLabel === void 0 ? void 0 : chatTypeLabel.trim()) !== null && _a !== void 0 ? _a : ''; var normalizedChatTypeLabel = (_a = chatTypeLabel === null || chatTypeLabel === void 0 ? void 0 : chatTypeLabel.trim()) !== null && _a !== void 0 ? _a : '';
var contextLabelLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' var contextLabelLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' && normalizedChatTypeLabel !== '기본처리'
? "\uC120\uD0DD \uCEE8\uD14D\uC2A4\uD2B8: ".concat(normalizedChatTypeLabel) ? "\uC120\uD0DD \uCEE8\uD14D\uC2A4\uD2B8: ".concat(normalizedChatTypeLabel)
: ''; : '';
var contextDescriptionLine = chatTypeDescription ? "\uAE30\uBCF8 \uBB38\uB9E5: ".concat(chatTypeDescription) : ''; var contextDescriptionLine = chatTypeDescription ? "\uAE30\uBCF8 \uBB38\uB9E5: ".concat(chatTypeDescription) : '';
@@ -609,7 +609,7 @@ function buildOfflineReply(context, input) {
var _a, _b; var _a, _b;
var normalized = input.toLowerCase(); var normalized = input.toLowerCase();
var normalizedChatTypeLabel = (_b = (_a = context.chatTypeLabel) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; var normalizedChatTypeLabel = (_b = (_a = context.chatTypeLabel) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '';
var typeLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' var typeLine = normalizedChatTypeLabel && normalizedChatTypeLabel !== '일반 요청' && normalizedChatTypeLabel !== '기본처리'
? "- \uCEE8\uD14D\uC2A4\uD2B8: ".concat(normalizedChatTypeLabel) ? "- \uCEE8\uD14D\uC2A4\uD2B8: ".concat(normalizedChatTypeLabel)
: ''; : '';
var descriptionLine = context.chatTypeDescription ? "- \uAE30\uBCF8 \uBB38\uB9E5: ".concat(context.chatTypeDescription) : ''; var descriptionLine = context.chatTypeDescription ? "- \uAE30\uBCF8 \uBB38\uB9E5: ".concat(context.chatTypeDescription) : '';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
const CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY = 'codex-live-context-confirm-suppressed-by-session';
type ContextConfirmSuppressionMap = Record<string, string>;
function buildLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function readStoredContextConfirmSuppressionMap(): ContextConfirmSuppressionMap {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {};
}
return Object.fromEntries(
Object.entries(parsed).flatMap(([sessionId, value]) => {
const normalizedSessionId = sessionId.trim();
const normalizedValue = typeof value === 'string' ? value.trim() : '';
return normalizedSessionId && normalizedValue ? [[normalizedSessionId, normalizedValue]] : [];
}),
);
} catch {
return {};
}
}
function writeStoredContextConfirmSuppressionMap(nextMap: ContextConfirmSuppressionMap) {
if (typeof window === 'undefined') {
return;
}
if (Object.keys(nextMap).length === 0) {
window.localStorage.removeItem(CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY);
return;
}
window.localStorage.setItem(CHAT_CONTEXT_CONFIRM_SUPPRESSION_STORAGE_KEY, JSON.stringify(nextMap));
}
export function shouldSkipContextConfirmForSessionToday(sessionId: string, date = new Date()) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return false;
}
const suppressionMap = readStoredContextConfirmSuppressionMap();
return suppressionMap[normalizedSessionId] === buildLocalDateKey(date);
}
export function setSkipContextConfirmForSessionToday(sessionId: string, shouldSkip: boolean, date = new Date()) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
const suppressionMap = readStoredContextConfirmSuppressionMap();
if (shouldSkip) {
suppressionMap[normalizedSessionId] = buildLocalDateKey(date);
} else {
delete suppressionMap[normalizedSessionId];
}
writeStoredContextConfirmSuppressionMap(suppressionMap);
}

View File

@@ -0,0 +1,13 @@
import type { ChatConversationSummary } from './types';
export function normalizeConversationDraftText(value: string | null | undefined) {
return typeof value === 'string' ? value : '';
}
export function updateConversationDraftText(
items: ChatConversationSummary[],
sessionId: string,
draftText: string,
) {
return items.map((item) => (item.sessionId === sessionId ? { ...item, draftText } : item));
}

View File

@@ -0,0 +1,45 @@
const TRANSIENT_CONVERSATION_TITLES = new Set([
'새 대화',
'대화 내용을 불러오는 중입니다.',
'첫 요청을 보내면 대화가 저장됩니다.',
'새 채팅방을 준비하는 중입니다.',
'삭제되었거나 만료된 채팅방입니다.',
]);
function normalizeConversationTitle(value: string | null | undefined) {
return String(value ?? '').trim();
}
function isTransientConversationTitle(value: string) {
return TRANSIENT_CONVERSATION_TITLES.has(value);
}
export function resolveMergedConversationTitle(
previousTitle: string | null | undefined,
nextTitle: string | null | undefined,
options?: { preservePrevious?: boolean },
) {
const normalizedPreviousTitle = normalizeConversationTitle(previousTitle);
const normalizedNextTitle = normalizeConversationTitle(nextTitle);
if (options?.preservePrevious) {
return normalizedPreviousTitle || normalizedNextTitle;
}
if (!normalizedNextTitle) {
return normalizedPreviousTitle;
}
if (!normalizedPreviousTitle) {
return normalizedNextTitle;
}
if (
isTransientConversationTitle(normalizedNextTitle) &&
!isTransientConversationTitle(normalizedPreviousTitle)
) {
return normalizedPreviousTitle;
}
return normalizedNextTitle;
}

View File

@@ -1,5 +1,51 @@
import type { ChatConversationSummary } from './types'; import type { ChatConversationSummary } from './types';
const CHAT_CLEARED_UNREAD_STORAGE_KEY = 'main-chat-panel:cleared-unread-response-at';
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function readClearedUnreadActivityMap() {
if (!canUseLocalStorage()) {
return {} as Record<string, string>;
}
try {
const raw = window.localStorage.getItem(CHAT_CLEARED_UNREAD_STORAGE_KEY);
if (!raw) {
return {} as Record<string, string>;
}
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {} as Record<string, string>;
}
return Object.entries(parsed).reduce<Record<string, string>>((result, [sessionId, value]) => {
if (typeof value === 'string' && value.trim()) {
result[sessionId] = value;
}
return result;
}, {});
} catch {
return {} as Record<string, string>;
}
}
function writeClearedUnreadActivityMap(nextMap: Record<string, string>) {
if (!canUseLocalStorage()) {
return;
}
try {
window.localStorage.setItem(CHAT_CLEARED_UNREAD_STORAGE_KEY, JSON.stringify(nextMap));
} catch {
// Ignore storage write failures and keep the in-memory merge behavior.
}
}
function toConversationActivityTime(value: string | null | undefined) { function toConversationActivityTime(value: string | null | undefined) {
if (!value) { if (!value) {
return 0; return 0;
@@ -9,22 +55,73 @@ function toConversationActivityTime(value: string | null | undefined) {
return Number.isNaN(parsed) ? 0 : parsed; return Number.isNaN(parsed) ? 0 : parsed;
} }
export function resolveConversationUnreadMergeState( export function getConversationActivityTime(
previousItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>, item: Pick<ChatConversationSummary, 'lastMessageAt' | 'updatedAt'>,
nextItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
) { ) {
if (!previousItem.hasUnreadResponse && nextItem.hasUnreadResponse) { const lastMessageActivityTime = toConversationActivityTime(item.lastMessageAt);
const previousActivityTime = Math.max(
toConversationActivityTime(previousItem.lastMessageAt),
toConversationActivityTime(previousItem.updatedAt),
);
const nextActivityTime = Math.max(
toConversationActivityTime(nextItem.lastMessageAt),
toConversationActivityTime(nextItem.updatedAt),
);
// Keep a locally-cleared unread state until a newer response actually arrives. if (lastMessageActivityTime > 0) {
if (nextActivityTime <= previousActivityTime) { return lastMessageActivityTime;
}
return toConversationActivityTime(item.updatedAt);
}
export function rememberConversationUnreadCleared(
item: Pick<ChatConversationSummary, 'sessionId' | 'lastMessageAt' | 'updatedAt'>,
) {
const sessionId = item.sessionId.trim();
const activityTime = getConversationActivityTime(item);
if (!sessionId || activityTime <= 0) {
return;
}
const nextMap = {
...readClearedUnreadActivityMap(),
[sessionId]: new Date(activityTime).toISOString(),
};
writeClearedUnreadActivityMap(nextMap);
}
function getStoredClearedUnreadActivityTime(sessionId: string) {
const storedValue = readClearedUnreadActivityMap()[sessionId];
return toConversationActivityTime(storedValue);
}
export function resolveStoredConversationUnreadState(
item: Pick<ChatConversationSummary, 'sessionId' | 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
) {
if (!item.hasUnreadResponse) {
return false;
}
const nextActivityTime = getConversationActivityTime(item);
const storedClearedActivityTime = getStoredClearedUnreadActivityTime(item.sessionId.trim());
if (storedClearedActivityTime > 0 && nextActivityTime > 0 && nextActivityTime <= storedClearedActivityTime) {
return false;
}
return true;
}
export function resolveConversationUnreadMergeState(
previousItem: Pick<ChatConversationSummary, 'sessionId' | 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
nextItem: Pick<ChatConversationSummary, 'sessionId' | 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
) {
if (!resolveStoredConversationUnreadState(nextItem)) {
return false;
}
if (!previousItem.hasUnreadResponse && nextItem.hasUnreadResponse) {
const previousActivityTime = getConversationActivityTime(previousItem);
const nextActivityTime = getConversationActivityTime(nextItem);
// Keep a locally-cleared unread state until a newer response message actually arrives.
// `updatedAt` can move for read-sync or metadata updates, so `lastMessageAt` must win whenever available.
if (previousActivityTime > 0 && nextActivityTime <= previousActivityTime) {
return false; return false;
} }
} }

View File

@@ -0,0 +1,136 @@
function normalizeExecutorCommandValue(command: string) {
return command
.replace(/^["'`]+|["'`]+$/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export type ExecutorActivityDescriptor = {
kindLabel: string;
focusLabel: string;
detailLabel: string;
message: string;
};
export function normalizeExecutorFocusLabel(focus: string) {
const normalizedFocus = focus.trim();
if (!normalizedFocus) {
return '현재 요청';
}
return normalizedFocus.replace(/\s*(||| )$/u, '').trim() || normalizedFocus;
}
function buildExecutorActivityMessage(kindLabel: string, focusLabel: string, detail: string) {
return `${kindLabel} · ${focusLabel}${detail}`;
}
function buildExecutorActivityDescriptor(
kindLabel: string,
focusLabel: string,
detailLabel: string,
): ExecutorActivityDescriptor {
return {
kindLabel,
focusLabel,
detailLabel,
message: buildExecutorActivityMessage(kindLabel, focusLabel, ` ${detailLabel}`),
};
}
function summarizeByFocusOnly(focusLabel: string) {
const normalizedFocus = focusLabel.toLowerCase();
if (/|||/u.test(focusLabel)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '확인 내용을 정리하는 중');
}
if (//u.test(focusLabel) || /\btest\b/u.test(normalizedFocus)) {
return buildExecutorActivityDescriptor('테스트', focusLabel, '테스트 범위를 정리하는 중');
}
if (/||/u.test(focusLabel)) {
return buildExecutorActivityDescriptor('정리', focusLabel, '결과 문구와 작업 메모를 정리하는 중');
}
if (/||db|api/u.test(focusLabel) || /\b(db|api|query)\b/u.test(normalizedFocus)) {
return buildExecutorActivityDescriptor('조회', focusLabel, '확인에 필요한 데이터를 정리하는 중');
}
return buildExecutorActivityDescriptor('개발', focusLabel, '구현 내용을 정리하는 중');
}
export function describeExecutorCommand(command: string | null, focus: string) {
const focusLabel = normalizeExecutorFocusLabel(focus);
if (!command) {
return summarizeByFocusOnly(focusLabel);
}
const normalizedCommand = normalizeExecutorCommandValue(command);
if (!normalizedCommand) {
return summarizeByFocusOnly(focusLabel);
}
const normalizedLowerCommand = normalizedCommand.toLowerCase();
if (/\bapply_patch\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('개발', focusLabel, '기능을 코드에 반영하는 중');
}
if (/\brg\b|\bgrep\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('분석', focusLabel, '작업에 필요한 코드 위치를 찾는 중');
}
if (/\bsed\b|\bcat\b|\bless\b|\bhead\b|\btail\b|\bnl\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('분석', focusLabel, '작업 범위의 파일 내용을 확인하는 중');
}
if (/\bfind\b|\bls\b|\bstat\b|\bwc\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('분석', focusLabel, '작업과 연결된 파일 구성을 확인하는 중');
}
if (/\b(playwright|cypress)\b.*\bcapture\b|\bcapture:[a-z0-9:-]+\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '화면을 캡처해 반영 상태를 확인하는 중');
}
if (/\bnpm\b.*\b(tsc|type-?check)\b|\btsc\b.*--noemit/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '변경 뒤 타입 오류를 점검하는 중');
}
if (/\bvitest\b|\bjest\b|\bmocha\b|\bplaywright\b|\bcypress\b|\btest\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('테스트', focusLabel, '변경 뒤 동작을 테스트하는 중');
}
if (/\bbuild\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '변경 결과가 빌드되는지 확인하는 중');
}
if (/\bgit\s+diff\b|\bgit\s+status\b|\bgit\s+show\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('정리', focusLabel, '변경 범위를 정리하는 중');
}
if (/\bcurl\b|\bwget\b|\bfetch\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '작업과 연결된 API 응답을 확인하는 중');
}
if (/\bsqlite3\b|\bpsql\b|\bmysql\b|\bselect\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('조회', focusLabel, '작업에 필요한 데이터를 조회하는 중');
}
if (/\bnpm\b.*\bcapture\b|\bnode\b.*\bcapture\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('검증', focusLabel, '캡처 산출물을 생성하는 중');
}
if (/\bnode\b|\bnpm\b|\byarn\b|\bpnpm\b|\bbash\b|\bsh\b/u.test(normalizedLowerCommand)) {
return buildExecutorActivityDescriptor('실행', focusLabel, '작업을 실행 환경에서 확인하는 중');
}
return buildExecutorActivityDescriptor('진행', focusLabel, '작업을 진행하는 중');
}
export function summarizeExecutorCommand(command: string | null, focus: string) {
return describeExecutorCommand(command, focus).message;
}

View File

@@ -6,6 +6,10 @@ export {
clearStoredChatClientConversationState, clearStoredChatClientConversationState,
copyPreviewContent, copyPreviewContent,
copyText, copyText,
createManagedChatShareRoom,
ChatApiError,
createChatShareLink,
sharePreviewLink,
resolvePreviewBodyForCopy, resolvePreviewBodyForCopy,
createActivityLogPlaceholder, createActivityLogPlaceholder,
createChatConversationRoom, createChatConversationRoom,
@@ -14,9 +18,11 @@ export {
createLocalMessage, createLocalMessage,
cancelChatRuntimeJob, cancelChatRuntimeJob,
clearChatConversationRoom, clearChatConversationRoom,
completeChatConversationRequestManualBadge,
deleteChatConversationRequest, deleteChatConversationRequest,
deleteChatConversationRoom, deleteChatConversationRoom,
fetchChatConversationDetail, fetchChatConversationDetail,
fetchChatShareSnapshot,
fetchChatConversations, fetchChatConversations,
fetchChatSourceChanges, fetchChatSourceChanges,
fetchChatRuntimeJobDetail, fetchChatRuntimeJobDetail,
@@ -28,16 +34,22 @@ export {
getChatClientSessionId, getChatClientSessionId,
markChatConversationResponsesRead, markChatConversationResponsesRead,
mergeRecoveredChatMessages, mergeRecoveredChatMessages,
renameChatConversationRoom,
removeChatRuntimeJob, removeChatRuntimeJob,
resetLastReceivedChatEventId, resetLastReceivedChatEventId,
persistChatPromptSelection,
setStoredChatSessionLastTypeId, setStoredChatSessionLastTypeId,
setChatClientSessionId, setChatClientSessionId,
sortChatConversationSummaries, sortChatConversationSummaries,
submitChatPromptSelection,
submitChatShareMessage,
submitChatSharePrompt,
getStoredChatShareAccessPin,
setStoredChatShareAccessPin,
uploadChatComposerFile, uploadChatComposerFile,
upsertChatMessage, upsertChatMessage,
updateChatConversationRoom, updateChatConversationRoom,
} from './chatUtils'; } from './chatUtils';
export type { ManagedChatShareRoom } from './chatUtils';
export { export {
getSharedChatRuntimeSnapshot, getSharedChatRuntimeSnapshot,
setSharedChatRuntimeSnapshot, setSharedChatRuntimeSnapshot,

View File

@@ -1,11 +1,21 @@
import { getRegisteredAccessToken } from '../tokenAccess';
import { buildPreviewRuntimeUrl, resolvePreviewAppOrigin } from '../previewRuntime';
const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at'; const CHAT_EXTERNAL_LINK_OPENED_AT_KEY = 'ai-code-app.chat.external-link-opened-at';
const CHAT_EXTERNAL_LINK_TTL_MS = 15_000; const CHAT_EXTERNAL_LINK_TTL_MS = 15_000;
const EXTERNAL_WINDOW_TARGET_PREFIX = 'ai-code-app.external-window';
const UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS = 600;
type LinkNavigationEvent = { type LinkNavigationEvent = {
preventDefault?: () => void; preventDefault?: () => void;
stopPropagation?: () => void; stopPropagation?: () => void;
}; };
type OpenExternalLinkOptions = {
event?: LinkNavigationEvent;
onUnsupportedStandalone?: (url: string) => void;
};
function canUseSessionStorage() { function canUseSessionStorage() {
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'; return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
} }
@@ -26,6 +36,165 @@ function clearExternalLinkOpenTimestamp() {
window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY); window.sessionStorage.removeItem(CHAT_EXTERNAL_LINK_OPENED_AT_KEY);
} }
function isStandaloneDisplayMode() {
if (typeof window === 'undefined') {
return false;
}
return (
window.matchMedia?.('(display-mode: standalone)').matches === true ||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
);
}
function isAppleMobileStandaloneMode() {
if (typeof window === 'undefined') {
return false;
}
const navigatorValue = window.navigator as Navigator & { standalone?: boolean };
const userAgent = navigatorValue.userAgent ?? '';
const platform = navigatorValue.platform ?? '';
const maxTouchPoints = typeof navigatorValue.maxTouchPoints === 'number' ? navigatorValue.maxTouchPoints : 0;
const isAppleMobileUserAgent = /iPhone|iPad|iPod/iu.test(userAgent);
const isTouchMac = platform === 'MacIntel' && maxTouchPoints > 1;
return isStandaloneDisplayMode() && (isAppleMobileUserAgent || isTouchMac);
}
function buildExternalWindowTarget() {
return `${EXTERNAL_WINDOW_TARGET_PREFIX}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function clickExternalAnchor(url: string, target: string) {
if (typeof document === 'undefined') {
return;
}
const anchor = document.createElement('a');
anchor.href = url;
anchor.target = target;
anchor.rel = 'noopener noreferrer';
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
}
function buildPreviewRuntimeFallbackUrl(url: string) {
if (typeof window === 'undefined') {
return null;
}
try {
const targetUrl = new URL(url, window.location.origin);
const previewOrigin = resolvePreviewAppOrigin();
if (targetUrl.origin !== window.location.origin && targetUrl.origin !== previewOrigin) {
return null;
}
const previewRuntimeUrl = buildPreviewRuntimeUrl(
targetUrl.pathname,
targetUrl.search,
getRegisteredAccessToken(),
null,
'mobile',
);
if (!targetUrl.hash) {
return previewRuntimeUrl;
}
const previewUrl = new URL(previewRuntimeUrl);
previewUrl.hash = targetUrl.hash;
return previewUrl.toString();
} catch {
return null;
}
}
function canFallbackToSameTab(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const targetUrl = new URL(url, window.location.origin);
const previewOrigin = resolvePreviewAppOrigin();
return targetUrl.origin === window.location.origin || targetUrl.origin === previewOrigin;
} catch {
return false;
}
}
function openSameTabFallback(url: string) {
if (typeof window === 'undefined' || !canFallbackToSameTab(url)) {
return false;
}
window.location.assign(url);
return true;
}
function openPreviewRuntimeFallback(url: string) {
if (typeof window === 'undefined') {
return false;
}
const previewRuntimeUrl = buildPreviewRuntimeFallbackUrl(url);
if (!previewRuntimeUrl) {
return false;
}
window.location.assign(previewRuntimeUrl);
return true;
}
function scheduleUnsupportedStandaloneFallback(url: string, callback?: (url: string) => void) {
if (typeof window === 'undefined' || typeof document === 'undefined' || !isAppleMobileStandaloneMode()) {
return;
}
window.setTimeout(() => {
const pageStillVisible = document.visibilityState !== 'hidden';
const pageStillFocused = typeof document.hasFocus !== 'function' || document.hasFocus();
if (pageStillVisible && pageStillFocused) {
if (openPreviewRuntimeFallback(url)) {
return;
}
if (openSameTabFallback(url)) {
return;
}
callback?.(url);
}
}, UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS);
}
function schedulePopupBlockedFallback(url: string, callback?: (url: string) => void) {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
window.setTimeout(() => {
const pageStillVisible = document.visibilityState !== 'hidden';
const pageStillFocused = typeof document.hasFocus !== 'function' || document.hasFocus();
if (!pageStillVisible || !pageStillFocused) {
return;
}
if (openSameTabFallback(url)) {
return;
}
callback?.(url);
}, UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS);
}
export function shouldSkipForegroundResyncAfterExternalLink() { export function shouldSkipForegroundResyncAfterExternalLink() {
if (!canUseSessionStorage()) { if (!canUseSessionStorage()) {
return false; return false;
@@ -42,24 +211,29 @@ export function shouldSkipForegroundResyncAfterExternalLink() {
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS; return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
} }
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) { export function openExternalLinkInNewWindow(url: string, options: OpenExternalLinkOptions = {}) {
event?.preventDefault?.(); options.event?.preventDefault?.();
event?.stopPropagation?.(); options.event?.stopPropagation?.();
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return; return;
} }
persistExternalLinkOpenTimestamp(Date.now()); persistExternalLinkOpenTimestamp(Date.now());
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer'); const target = buildExternalWindowTarget();
const openedWindow = window.open(url, target, 'noopener,noreferrer');
if (openedWindow) { if (openedWindow) {
return; return;
} }
const anchor = document.createElement('a'); clickExternalAnchor(url, target);
anchor.href = url; scheduleUnsupportedStandaloneFallback(url, options.onUnsupportedStandalone);
anchor.target = '_blank'; schedulePopupBlockedFallback(url, options.onUnsupportedStandalone);
anchor.rel = 'noopener noreferrer'; }
anchor.click();
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
openExternalLinkInNewWindow(url, {
event,
});
} }

View File

@@ -2,10 +2,15 @@ import type { ChatMessagePart } from './types';
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i; const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i;
const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i; const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i;
const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\((.+)\)\s*$/;
const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i;
const PROMPT_BLOCK_START_PATTERN = /^\s*\[\[prompt:\s*$/i; const PROMPT_BLOCK_START_PATTERN = /^\s*\[\[prompt:\s*$/i;
const PROMPT_BLOCK_END_PATTERN = /^\s*\]\]\s*$/; const PROMPT_BLOCK_END_PATTERN = /^\s*\]\]\s*$/;
const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i; const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i;
const CODE_BLOCK_END_PATTERN = /^\s*```\s*$/; const CODE_BLOCK_END_PATTERN = /^\s*```\s*$/;
const CODE_FENCE_TOGGLE_PATTERN = /^\s*```/;
const ATTACHMENT_SECTION_TITLE_PATTERN = /^\s*첨부\s*파일\s*:?\s*$/i;
const ATTACHMENT_ENTRY_PATTERN = /^\s*-\s+(.+?)\s*:\s*(.+?)\s*$/;
const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const; const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', '/.codex_chat/'] as const;
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/'; const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/'; const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
@@ -21,6 +26,17 @@ function normalizeText(value: unknown) {
return String(value ?? '').trim(); return String(value ?? '').trim();
} }
function unwrapMarkdownLinkTarget(value: string) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const matched = normalized.match(/^<([\s\S]+)>$/);
return matched?.[1]?.trim() ?? normalized;
}
function normalizeResourceManagerPathSegment(segment: string) { function normalizeResourceManagerPathSegment(segment: string) {
const normalized = normalizeText(segment); const normalized = normalizeText(segment);
@@ -81,7 +97,7 @@ function extractKnownPreviewPath(value: string) {
} }
function normalizeUrl(value: string) { function normalizeUrl(value: string) {
const normalized = normalizeText(value); const normalized = unwrapMarkdownLinkTarget(value);
if (!normalized) { if (!normalized) {
return ''; return '';
@@ -132,6 +148,55 @@ function normalizeUrl(value: string) {
return ''; return '';
} }
function hasKnownFileExtension(url: string) {
const pathname = url.split('?')[0] ?? '';
return /\.[a-z0-9]{1,8}$/i.test(pathname);
}
function isStructuredLinkCardCandidate(url: string) {
const normalized = normalizeUrl(url);
if (!normalized) {
return false;
}
if (isInternalResourceUrl(normalized)) {
return false;
}
return /^https?:\/\//i.test(normalized) && !hasKnownFileExtension(normalized);
}
function buildFallbackLinkTitle(url: string) {
try {
const parsed = new URL(url, 'https://local.invalid');
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
return lastSegment || parsed.hostname || normalizeText(url);
} catch {
return normalizeText(url);
}
}
function normalizeStandaloneTitle(value: string) {
return value
.replace(/^\s*(?:[-*+]\s+|\d+\.\s+)?/, '')
.replace(/[`'"]+/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function resolveStandaloneLinkTitle(keptLines: string[], url: string) {
for (let index = keptLines.length - 1; index >= 0; index -= 1) {
const candidate = normalizeStandaloneTitle(keptLines[index] ?? '');
if (candidate) {
return candidate;
}
}
return buildFallbackLinkTitle(url);
}
function normalizePromptPreview(value: unknown): PromptPreview | null { function normalizePromptPreview(value: unknown): PromptPreview | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null; return null;
@@ -328,6 +393,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
const selectedValues = [ const selectedValues = [
...normalizePromptSelectedValues(record.selectedValues), ...normalizePromptSelectedValues(record.selectedValues),
...(record.selectedValue != null ? [record.selectedValue] : []), ...(record.selectedValue != null ? [record.selectedValue] : []),
...steps.flatMap((step) => step.selectedValues ?? []),
] ]
.map((item) => normalizeText(item)) .map((item) => normalizeText(item))
.filter(Boolean) .filter(Boolean)
@@ -349,7 +415,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null, freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
currentStepKey: normalizeText(record.currentStepKey) || null, currentStepKey: normalizeText(record.currentStepKey) || null,
steps: steps.length > 0 ? steps : undefined, steps: steps.length > 0 ? steps : undefined,
readOnly: record.readOnly === true || selectedValues.length > 0, readOnly: record.readOnly === true || resolvedBy != null,
selectedValues, selectedValues,
resolvedBy, resolvedBy,
resolvedAt: normalizeText(record.resolvedAt) || null, resolvedAt: normalizeText(record.resolvedAt) || null,
@@ -369,11 +435,82 @@ function buildPromptPartFromBlock(rawBody: string) {
return buildPromptPart(promptWrapperMatched?.[1] ?? trimmed); return buildPromptPart(promptWrapperMatched?.[1] ?? trimmed);
} }
export function extractChatMessageParts(text: string) { function extractAttachmentEntryUrl(rawLine: string) {
const matched = rawLine.match(ATTACHMENT_ENTRY_PATTERN);
if (!matched) {
return '';
}
const resolvedUrl = normalizeUrl(matched[2] ?? '');
return resolvedUrl || '';
}
export function extractAttachmentPreviewUrls(text: string) {
const lines = String(text ?? '').split('\n'); const lines = String(text ?? '').split('\n');
const keptLines: string[] = []; const keptLines: string[] = [];
const urls: string[] = [];
const seen = new Set<string>();
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? '';
if (!ATTACHMENT_SECTION_TITLE_PATTERN.test(line)) {
keptLines.push(line);
continue;
}
const attachmentUrls: string[] = [];
let cursor = index + 1;
while (cursor < lines.length) {
const nextLine = lines[cursor] ?? '';
if (!nextLine.trim()) {
cursor += 1;
continue;
}
const attachmentUrl = extractAttachmentEntryUrl(nextLine);
if (!attachmentUrl) {
break;
}
attachmentUrls.push(attachmentUrl);
cursor += 1;
}
if (attachmentUrls.length === 0) {
keptLines.push(line);
continue;
}
attachmentUrls.forEach((url) => {
if (seen.has(url)) {
return;
}
seen.add(url);
urls.push(url);
});
index = cursor - 1;
}
return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
urls,
};
}
export function extractChatMessageParts(text: string) {
const attachmentExtraction = extractAttachmentPreviewUrls(text);
const lines = String(attachmentExtraction.strippedText ?? '').split('\n');
const keptLines: string[] = [];
const parts: ChatMessagePart[] = []; const parts: ChatMessagePart[] = [];
const seenLinkKeys = new Set<string>(); const seenLinkKeys = new Set<string>();
let isInsideCodeFence = false;
const pushPart = (nextPart: ChatMessagePart | null) => { const pushPart = (nextPart: ChatMessagePart | null) => {
if (!nextPart) { if (!nextPart) {
return false; return false;
@@ -423,6 +560,46 @@ export function extractChatMessageParts(text: string) {
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex] ?? ''; const line = lines[lineIndex] ?? '';
if (PROMPT_CODE_BLOCK_START_PATTERN.test(line)) {
const fencedLines = [line];
const jsonBodyLines: string[] = [];
let cursor = lineIndex + 1;
let foundFenceEnd = false;
for (; cursor < lines.length; cursor += 1) {
const nextLine = lines[cursor] ?? '';
fencedLines.push(nextLine);
if (CODE_BLOCK_END_PATTERN.test(nextLine)) {
foundFenceEnd = true;
break;
}
jsonBodyLines.push(nextLine);
}
if (foundFenceEnd && pushPart(buildPromptPartFromBlock(jsonBodyLines.join('\n')))) {
lineIndex = cursor;
continue;
}
keptLines.push(...fencedLines);
lineIndex = foundFenceEnd ? cursor : lines.length;
continue;
}
if (CODE_FENCE_TOGGLE_PATTERN.test(line)) {
keptLines.push(line);
isInsideCodeFence = !isInsideCodeFence;
continue;
}
if (isInsideCodeFence) {
keptLines.push(line);
continue;
}
const promptMatched = line.match(PROMPT_LINE_PATTERN); const promptMatched = line.match(PROMPT_LINE_PATTERN);
if (promptMatched) { if (promptMatched) {
@@ -460,37 +637,29 @@ export function extractChatMessageParts(text: string) {
continue; continue;
} }
if (PROMPT_CODE_BLOCK_START_PATTERN.test(line)) {
const fencedLines = [line];
const jsonBodyLines: string[] = [];
let cursor = lineIndex + 1;
let foundFenceEnd = false;
for (; cursor < lines.length; cursor += 1) {
const nextLine = lines[cursor] ?? '';
fencedLines.push(nextLine);
if (CODE_BLOCK_END_PATTERN.test(nextLine)) {
foundFenceEnd = true;
break;
}
jsonBodyLines.push(nextLine);
}
if (foundFenceEnd && pushPart(buildPromptPartFromBlock(jsonBodyLines.join('\n')))) {
lineIndex = cursor;
continue;
}
keptLines.push(...fencedLines);
lineIndex = foundFenceEnd ? cursor : lines.length;
continue;
}
const matched = line.match(LINK_CARD_LINE_PATTERN); const matched = line.match(LINK_CARD_LINE_PATTERN);
if (!matched) { if (!matched) {
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
if (markdownLinkMatch) {
const [, rawTitle, rawUrl] = markdownLinkMatch;
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
continue;
}
}
}
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
if (standaloneUrlMatch) {
const rawUrl = standaloneUrlMatch[1] ?? '';
if (isStructuredLinkCardCandidate(rawUrl)) {
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
continue;
}
}
}
keptLines.push(line); keptLines.push(line);
continue; continue;
} }
@@ -508,8 +677,32 @@ export function extractChatMessageParts(text: string) {
} }
} }
const strippedWithEmbeddedPrompts = (() => {
const nextLines: string[] = [];
let isInsideCodeFence = false;
keptLines.forEach((line) => {
if (CODE_FENCE_TOGGLE_PATTERN.test(line)) {
nextLines.push(line);
isInsideCodeFence = !isInsideCodeFence;
return;
}
if (isInsideCodeFence) {
nextLines.push(line);
return;
}
nextLines.push(
line.replace(/\[\[prompt:(.+?)\]\]/gi, (fullMatch, rawBody) => (pushPart(buildPromptPart(rawBody)) ? '' : fullMatch)),
);
});
return nextLines.join('\n');
})();
return { return {
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), strippedText: strippedWithEmbeddedPrompts.replace(/\n{3,}/g, '\n\n').trim(),
parts, parts,
}; };
} }

View File

@@ -1,5 +1,5 @@
import { normalizeChatResourceUrl } from './chatResourceUrl'; import { normalizeChatResourceUrl } from './chatResourceUrl';
import { extractChatMessageParts } from './messageParts'; import { extractAttachmentPreviewUrls, extractChatMessageParts } from './messageParts';
import { extractHiddenPreviewUrls } from './previewMarkers'; import { extractHiddenPreviewUrls } from './previewMarkers';
import { classifyPreviewKind, type PreviewKind } from './previewKind'; import { classifyPreviewKind, type PreviewKind } from './previewKind';
import type { ChatMessage } from './types'; import type { ChatMessage } from './types';
@@ -159,7 +159,8 @@ export function extractPreviewItems(messages: ChatMessage[]) {
const orderedMessages = [...messages].reverse(); const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => { orderedMessages.forEach((message) => {
const extractedMessageParts = extractChatMessageParts(message.text); const attachmentExtraction = extractAttachmentPreviewUrls(message.text);
const extractedMessageParts = extractChatMessageParts(attachmentExtraction.strippedText);
const structuredLinkUrls = [ const structuredLinkUrls = [
...(Array.isArray(message.parts) ? message.parts : []), ...(Array.isArray(message.parts) ? message.parts : []),
...extractedMessageParts.parts, ...extractedMessageParts.parts,
@@ -171,6 +172,7 @@ export function extractPreviewItems(messages: ChatMessage[]) {
.map((part) => part.url); .map((part) => part.url);
const matches = [ const matches = [
...extractHiddenPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text),
...attachmentExtraction.urls,
...structuredLinkUrls, ...structuredLinkUrls,
]; ];

View File

@@ -11,5 +11,5 @@ export function resolvePromptPreviewOptionValue<T extends { value: string }>(
return activePreviewOptionValue; return activePreviewOptionValue;
} }
return options.find((option) => selectedValues.includes(option.value))?.value ?? null; return options.find((option) => selectedValues.includes(option.value))?.value ?? options[0]?.value ?? null;
} }

View File

@@ -0,0 +1,7 @@
import type { ChatMessagePart } from './types';
type PromptTarget = Extract<ChatMessagePart, { type: 'prompt' }>;
export function isPromptResolved(target: PromptTarget) {
return target.readOnly === true || target.resolvedBy != null;
}

View File

@@ -1,5 +1,9 @@
import type { ChatConversationSummary, ChatRuntimeSnapshot } from './types'; import type { ChatConversationSummary, ChatRuntimeSnapshot } from './types';
const CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH = 240;
const CONVERSATION_TRIVIAL_MESSAGE_PATTERN =
/^(?:[]{1,}|[]{2,}[~!?.]*|[][]?|ok(?:ay)?|thanks|thx|(?:|)?|(?:)?||||||||+|+|||||||||test|)\s*[~!?.]*$/iu;
function trimConversationRequestBadgeLabel(label: string, maxLength = 18) { function trimConversationRequestBadgeLabel(label: string, maxLength = 18) {
const normalized = label.replace(/\s+/g, ' ').trim(); const normalized = label.replace(/\s+/g, ' ').trim();
@@ -46,6 +50,16 @@ function normalizeConversationPromptFollowupText(text: string | null | undefined
return normalized; return normalized;
} }
function isConversationTrivialMessage(text: string | null | undefined) {
const normalized = normalizeConversationPromptFollowupText(text);
if (!normalized) {
return true;
}
return CONVERSATION_TRIVIAL_MESSAGE_PATTERN.test(normalized);
}
function isConversationPromptFollowupText(text: string | null | undefined) { function isConversationPromptFollowupText(text: string | null | undefined) {
const normalized = String(text ?? '') const normalized = String(text ?? '')
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
@@ -357,25 +371,49 @@ function sanitizeConversationBadgeFallbackText(text: string | null | undefined)
return ''; return '';
} }
return isConversationInquiryRequest(normalized) ? '' : normalized; if (isConversationInquiryRequest(normalized)) {
return '';
}
if (normalized.length <= CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH) {
return normalized;
}
return `${normalized.slice(0, CHAT_LIST_TEXT_SUMMARY_MAX_LENGTH - 3).trimEnd()}...`;
}
function isConversationGenericBadgeFallbackText(text: string) {
return /^(\s*|\s*||\s*|\s*|Codex\s*Live|chat|||\s*|\s*)$/iu.test(
text,
);
}
function sanitizeConversationBadgeContextFallbackText(text: string | null | undefined) {
const normalized = sanitizeConversationBadgeFallbackText(text);
return isConversationGenericBadgeFallbackText(normalized) ? '' : normalized;
}
function sanitizeConversationBadgeTitleFallbackText(text: string | null | undefined) {
const normalized = sanitizeConversationBadgeFallbackText(text);
return /^(\s*|Codex\s*Live)$/iu.test(normalized) ? '' : normalized;
} }
function buildConversationBadgeSourceTexts(item: BadgeSourceItem, runtimeSummary?: string | null) { function buildConversationBadgeSourceTexts(item: BadgeSourceItem, runtimeSummary?: string | null) {
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview); const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview); const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview); const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel); const contextLabel = sanitizeConversationBadgeContextFallbackText(item.contextLabel);
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription); const contextDescription = sanitizeConversationBadgeContextFallbackText(item.contextDescription);
const titleText = sanitizeConversationBadgeFallbackText(item.title); const titleText = sanitizeConversationBadgeTitleFallbackText(item.title);
const runtimeText = normalizeConversationPromptFollowupText(runtimeSummary); const runtimeText = normalizeConversationPromptFollowupText(runtimeSummary);
const isMetaRequest = isConversationBadgeMetaRequest(requestText); const isMetaRequest = isConversationBadgeMetaRequest(requestText);
const isInquiryRequest = isConversationInquiryRequest(requestText); const isInquiryRequest = isConversationInquiryRequest(requestText);
if (isMetaRequest || isInquiryRequest) { if (isMetaRequest || isInquiryRequest) {
return [runtimeText, responseText, messageText, contextDescription, contextLabel, titleText]; return [runtimeText, responseText, messageText, titleText, contextDescription, contextLabel];
} }
return [runtimeText, responseText, requestText, messageText, contextDescription, contextLabel, titleText]; return [runtimeText, responseText, requestText, messageText, titleText, contextDescription, contextLabel];
} }
function inferConversationRequestBadgeLabel( function inferConversationRequestBadgeLabel(
@@ -395,9 +433,9 @@ function inferConversationRequestBadgeLabel(
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview); const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview); const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview); const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel); const contextLabel = sanitizeConversationBadgeContextFallbackText(item.contextLabel);
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription); const contextDescription = sanitizeConversationBadgeContextFallbackText(item.contextDescription);
const titleText = sanitizeConversationBadgeFallbackText(item.title); const titleText = sanitizeConversationBadgeTitleFallbackText(item.title);
const requestActionLabel = findConversationBadgeActionLabel(requestText); const requestActionLabel = findConversationBadgeActionLabel(requestText);
const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts); const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts);
const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts); const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts);
@@ -431,14 +469,14 @@ function inferConversationRequestBadgeLabel(
if (actionLabel) { if (actionLabel) {
const fallbackLabel = compactConversationBadgeLabel( const fallbackLabel = compactConversationBadgeLabel(
contextDescription || titleText || contextLabel || responseText || messageText, requestText || responseText || messageText || titleText || contextDescription || contextLabel,
1, 1,
); );
return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel; return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel;
} }
const fallbackLabel = compactConversationBadgeLabel( const fallbackLabel = compactConversationBadgeLabel(
contextDescription || titleText || contextLabel || responseText || messageText, requestText || responseText || messageText || titleText || contextDescription || contextLabel,
2, 2,
); );
if (fallbackLabel) { if (fallbackLabel) {
@@ -461,6 +499,10 @@ export function resolveConversationRequestBadgeLabelForUserText(args: {
return null; return null;
} }
if (isConversationTrivialMessage(normalizedRequestText)) {
return null;
}
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText); const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText); const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
@@ -474,10 +516,10 @@ export function resolveConversationRequestBadgeLabelForUserText(args: {
if (isConversationInquiryRequest(normalizedRequestText)) { if (isConversationInquiryRequest(normalizedRequestText)) {
const fallbackLabel = compactConversationBadgeLabel( const fallbackLabel = compactConversationBadgeLabel(
sanitizeConversationBadgeFallbackText(args.contextDescription) || sanitizeConversationBadgeTitleFallbackText(args.title) ||
sanitizeConversationBadgeFallbackText(args.contextLabel) || sanitizeConversationBadgeContextFallbackText(args.contextDescription) ||
sanitizeConversationBadgeFallbackText(args.title) || sanitizeConversationBadgeContextFallbackText(args.contextLabel) ||
args.currentMenuLabel?.trim() || sanitizeConversationBadgeTitleFallbackText(args.currentMenuLabel) ||
'', '',
2, 2,
); );
@@ -511,6 +553,10 @@ export function resolveConversationTitleForUserText(args: {
return fallbackTitle || '새 대화'; return fallbackTitle || '새 대화';
} }
if (isConversationTrivialMessage(normalizedRequestText)) {
return fallbackTitle || '새 대화';
}
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText); const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText); const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
const targetLabel = findConversationBadgeTargetLabel(normalizedRequestText); const targetLabel = findConversationBadgeTargetLabel(normalizedRequestText);
@@ -583,6 +629,16 @@ export function resolveConversationScreenTitle(pageTitle?: string | null, topMen
return topMenuLabel?.trim() || null; return topMenuLabel?.trim() || null;
} }
export function shouldPreferRequestTitleOverScreenTitle(pageTitle?: string | null, topMenu?: string | null) {
const screenTitle = resolveConversationScreenTitle(pageTitle, topMenu);
if (!screenTitle) {
return true;
}
return String(topMenu ?? '').trim() === 'chat' && /^Codex Live(?:\s*\/\s*Codex Live)?$/u.test(screenTitle);
}
export function resolveCurrentMenuRequestLabel(pageTitle?: string | null, topMenu?: string | null) { export function resolveCurrentMenuRequestLabel(pageTitle?: string | null, topMenu?: string | null) {
const uniqueSegments = resolvePageTitleSegments(pageTitle); const uniqueSegments = resolvePageTitleSegments(pageTitle);
const preferredPageLabel = uniqueSegments.at(-1) ?? uniqueSegments[0] ?? ''; const preferredPageLabel = uniqueSegments.at(-1) ?? uniqueSegments[0] ?? '';

File diff suppressed because it is too large Load Diff

View File

@@ -86,6 +86,272 @@
} }
} }
.app-chat-message-group {
display: flex;
flex-direction: column;
gap: 12px;
margin: 0 0 18px;
padding: 14px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 22px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.08), transparent 42%);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.app-chat-message-group__header {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.app-chat-message-group__header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.app-chat-message-group__header-meta {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}
.app-chat-message-group__eyebrow {
color: #475569;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.app-chat-message-group__status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: #0f172a;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.app-chat-message-group__status--completed {
color: #166534;
background: rgba(34, 197, 94, 0.14);
}
.app-chat-message-group__status--attention {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
.app-chat-message-group__status--started {
color: #1d4ed8;
background: rgba(59, 130, 246, 0.14);
}
.app-chat-message-group__status--queued,
.app-chat-message-group__status--neutral {
color: #334155;
background: rgba(148, 163, 184, 0.16);
}
.app-chat-message-group__status--failed,
.app-chat-message-group__status--cancelled {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
}
.app-chat-message-group__toggle.ant-btn.ant-btn-text {
padding-inline: 6px;
color: #334155;
font-size: 12px;
font-weight: 600;
}
.app-chat-message-group__header-actions {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-left: auto;
}
.app-chat-message-group__child-action.ant-btn {
border-color: rgba(245, 158, 11, 0.32);
color: #b45309;
background: rgba(255, 251, 235, 0.92);
font-size: 12px;
font-weight: 600;
}
.app-chat-message-group__child-action.ant-btn:hover,
.app-chat-message-group__child-action.ant-btn:focus-visible {
color: #92400e;
border-color: rgba(217, 119, 6, 0.42);
background: rgba(254, 243, 199, 0.98);
}
.app-chat-message-group__toggle.ant-btn.ant-btn-text:hover,
.app-chat-message-group__toggle.ant-btn.ant-btn-text:focus-visible {
color: #0f172a;
background: rgba(15, 23, 42, 0.06);
}
.app-chat-message-group__title {
color: #0f172a;
font-size: 14px;
font-weight: 700;
line-height: 1.45;
}
.app-chat-message-group__detail {
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.app-chat-message-group__child-composer {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
padding: 12px;
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 16px;
background: rgba(255, 251, 235, 0.82);
}
.app-chat-message-group__child-composer .ant-input {
background: rgba(255, 255, 255, 0.92);
}
.app-chat-message-group__child-composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.app-chat-message-group__child-composer-hint {
color: #78716c;
font-size: 11px;
line-height: 1.4;
}
.app-chat-message-group__child-composer-buttons {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
}
.app-chat-message-group__body {
display: flex;
flex-direction: column;
gap: 10px;
}
.app-chat-message-group__activity {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
min-width: 0;
}
.app-chat-message-group__request-tree,
.app-chat-message-group__embedded-request-tree {
width: 100%;
}
.app-chat-message-group__activity-item {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
min-width: 0;
}
.app-chat-message-group__activity-item--child {
position: relative;
margin-left: 18px;
padding-left: 12px;
}
.app-chat-message-group__activity-item--child::before {
content: '';
position: absolute;
top: 10px;
left: -2px;
width: 10px;
height: calc(100% - 18px);
border-top: 2px solid rgba(96, 165, 250, 0.82);
border-left: 2px solid rgba(96, 165, 250, 0.82);
border-top-left-radius: 8px;
pointer-events: none;
}
.app-chat-message-group__section-label {
display: inline-flex;
align-items: center;
gap: 6px;
width: fit-content;
max-width: 100%;
padding: 4px 10px;
border-radius: 999px;
background: rgba(219, 234, 254, 0.88);
color: #1d4ed8;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.app-chat-message-group__section-label::before {
content: '└';
flex: none;
color: #60a5fa;
font-size: 12px;
line-height: 1;
}
.app-chat-message-group__activity-label {
color: #475569;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.app-chat-message-group__activity-stack {
width: 100%;
}
.app-chat-message-group__activity-children {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.app-chat-message-group__body .app-chat-panel__preview-rich .previewer-ui__editor-body,
.app-chat-message-group__body .app-chat-panel__preview-rich--markdown,
.app-chat-message-group__body .app-chat-panel__preview-table-scroll,
.app-chat-message-group__body .app-chat-message__preview-text {
max-height: none;
overflow: visible;
overscroll-behavior: auto;
}
.app-chat-preview-card__body { .app-chat-preview-card__body {
display: flex; display: flex;
min-height: 0; min-height: 0;
@@ -387,7 +653,8 @@
width: auto; width: auto;
min-width: 0; min-width: 0;
height: 26px; height: 26px;
padding-inline: 8px; margin-top: 2px;
padding-inline: 10px;
font-size: 12px; font-size: 12px;
color: #475569; color: #475569;
border-radius: 999px; border-radius: 999px;
@@ -451,11 +718,19 @@
flex: none; flex: none;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: clamp(112px, 18dvh, 160px); min-height: var(--app-chat-panel-composer-height, clamp(112px, 18dvh, 160px));
}
.app-chat-panel__composer-input-shell--autosize {
min-height: auto;
} }
.app-chat-panel--maximized .app-chat-panel__composer-input-shell { .app-chat-panel--maximized .app-chat-panel__composer-input-shell {
min-height: clamp(104px, 16dvh, 148px); min-height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 148px));
}
.app-chat-panel--maximized .app-chat-panel__composer-input-shell--autosize {
min-height: auto;
} }
.app-chat-panel__composer-queue { .app-chat-panel__composer-queue {
@@ -563,23 +838,57 @@
width: 100%; width: 100%;
font-size: 13px; font-size: 13px;
line-height: 1.4; line-height: 1.4;
height: clamp(112px, 18dvh, 160px); height: var(--app-chat-panel-composer-height, clamp(112px, 18dvh, 160px));
min-height: clamp(112px, 18dvh, 160px); min-height: var(--app-chat-panel-composer-height, clamp(112px, 18dvh, 160px));
padding: 10px 52px 8px 14px; padding: 10px 52px 8px 14px;
box-sizing: border-box; box-sizing: border-box;
resize: none; resize: none;
} }
.app-chat-panel__composer-input-shell--autosize .ant-input-textarea,
.app-chat-panel__composer-input-shell--autosize .ant-input-textarea textarea.ant-input,
.app-chat-panel__composer-input-shell--autosize textarea.ant-input {
height: auto;
}
.app-chat-panel--maximized .app-chat-panel__composer textarea.ant-input { .app-chat-panel--maximized .app-chat-panel__composer textarea.ant-input {
height: clamp(104px, 16dvh, 148px); height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 148px));
min-height: clamp(104px, 16dvh, 148px); min-height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 148px));
padding-bottom: max(14px, calc(env(safe-area-inset-bottom, 0px) + 10px)); padding-bottom: max(14px, calc(env(safe-area-inset-bottom, 0px) + 10px));
} }
:root.app-virtual-keyboard-open .app-chat-panel--maximized {
--app-chat-panel-maximized-inset-bottom: 0px;
}
:root.app-virtual-keyboard-open .app-chat-panel--maximized .ant-card-body {
padding-bottom: 0;
}
:root.app-virtual-keyboard-open .app-chat-panel--maximized .app-chat-panel__composer {
padding-bottom: 2px;
}
:root.app-virtual-keyboard-open .app-chat-panel--maximized .app-chat-panel__composer textarea.ant-input {
padding-bottom: 8px;
}
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input { .app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 96px; padding-top: 96px;
} }
.app-chat-panel__composer-input-shell--autosize.app-chat-panel__composer-input-shell--with-queue {
min-height: 120px;
}
.app-chat-panel__composer-input-shell--autosize.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
min-height: 120px;
}
.app-chat-panel__composer-input-shell--with-assist textarea.ant-input {
padding-left: 52px;
}
.app-chat-panel__composer-topline, .app-chat-panel__composer-topline,
.app-chat-panel__composer-actions { .app-chat-panel__composer-actions {
display: flex; display: flex;
@@ -606,6 +915,8 @@
.app-chat-panel__composer-action-buttons { .app-chat-panel__composer-action-buttons {
display: inline-flex; display: inline-flex;
gap: 6px; gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
} }
.app-chat-panel__composer-contextless-toggle.ant-btn { .app-chat-panel__composer-contextless-toggle.ant-btn {
@@ -653,6 +964,29 @@
display: none; display: none;
} }
.app-chat-panel__composer-assist-trigger.ant-btn {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
width: 28px;
min-width: 28px;
height: 28px;
padding-inline: 0;
border-radius: 999px;
color: rgba(71, 85, 105, 0.88);
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.24);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
}
.app-chat-panel__composer-assist-trigger.ant-btn:hover,
.app-chat-panel__composer-assist-trigger.ant-btn:focus-visible {
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.4);
background: rgba(239, 246, 255, 0.98);
}
.app-chat-panel__composer-clear.ant-btn { .app-chat-panel__composer-clear.ant-btn {
position: absolute; position: absolute;
right: 10px; right: 10px;
@@ -692,50 +1026,6 @@
min-height: 0; min-height: 0;
} }
.app-chat-panel__composer-prompt-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
min-height: 0;
}
.app-chat-panel__composer-prompt-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 9px;
border: 1px solid rgba(13, 148, 136, 0.18);
border-radius: 999px;
background: rgba(240, 253, 250, 0.96);
color: #0f172a;
}
.app-chat-panel__composer-prompt-chip-title {
flex: none;
font-size: 11px;
line-height: 1.3;
color: #0f766e;
}
.app-chat-panel__composer-prompt-chip-value {
max-width: min(240px, 40vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 1.3;
color: #0f172a;
}
.app-chat-panel__composer-prompt-chip-meta {
flex: none;
font-size: 10px;
line-height: 1.2;
color: #0f766e;
opacity: 0.82;
}
.app-chat-panel__composer-attachment-chip { .app-chat-panel__composer-attachment-chip {
display: inline-flex; display: inline-flex;
@@ -800,12 +1090,56 @@
min-height: 28px; min-height: 28px;
} }
.app-chat-panel__composer-action-buttons .ant-btn:not(.ant-btn-icon-only) {
padding-inline: 10px;
}
.app-chat-panel__composer-action-buttons .ant-btn-icon-only { .app-chat-panel__composer-action-buttons .ant-btn-icon-only {
width: 28px; width: 28px;
min-width: 28px; min-width: 28px;
padding-inline: 0; padding-inline: 0;
} }
.app-chat-panel__composer-assist-modal-alert {
margin-bottom: 12px;
}
.app-chat-panel__composer-assist-modal-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.app-chat-panel__composer-assist-option {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(248, 250, 252, 0.92);
}
.app-chat-panel__composer-assist-option-main .ant-checkbox-wrapper {
align-items: center;
}
.app-chat-panel__composer-assist-option-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #0f172a;
}
.app-chat-panel__composer-assist-option-description {
margin: 0;
padding-left: 24px;
font-size: 12px;
line-height: 1.5;
color: #475569;
}
.app-chat-panel__composer-type .ant-select-selector { .app-chat-panel__composer-type .ant-select-selector {
padding-block: 2px; padding-block: 2px;
} }
@@ -1378,21 +1712,29 @@
min-height: 34px; min-height: 34px;
} }
.app-chat-panel__composer-action-buttons .ant-btn:not(.ant-btn-icon-only) {
padding-inline: 12px;
}
.app-chat-panel__composer-action-buttons .ant-btn-icon-only { .app-chat-panel__composer-action-buttons .ant-btn-icon-only {
width: 34px; width: 34px;
min-width: 34px; min-width: 34px;
} }
.app-chat-panel__composer textarea.ant-input { .app-chat-panel__composer textarea.ant-input {
height: clamp(104px, 16dvh, 136px); height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 136px));
min-height: clamp(104px, 16dvh, 136px); min-height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 136px));
padding-top: 8px; padding-top: 8px;
padding-bottom: 8px; padding-bottom: 8px;
line-height: 1.5; line-height: 1.5;
} }
.app-chat-panel__composer-input-shell { .app-chat-panel__composer-input-shell {
min-height: clamp(104px, 16dvh, 136px); min-height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 136px));
}
.app-chat-panel__composer-input-shell--autosize {
min-height: auto;
} }
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input { .app-chat-panel__composer-input-shell--with-queue textarea.ant-input {

View File

@@ -0,0 +1,311 @@
.app-chat-panel--rooms-shared.ant-card {
border: 0;
border-radius: 0;
height: 100%;
background: transparent;
box-shadow: none;
}
.app-chat-panel--rooms-shared .ant-card-head {
display: block;
}
.app-chat-panel--rooms-shared .ant-card-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
padding: 0;
border-radius: 14px;
background: linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%);
box-shadow:
inset 0 0 0 1px rgba(196, 210, 226, 0.96),
0 8px 24px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__stack,
.app-chat-panel--rooms-shared .app-chat-panel__stack--chat {
min-height: 0;
height: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main,
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
background: transparent;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main {
min-height: 0;
height: 100%;
overflow: hidden;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
padding: 18px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header {
padding: 8px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.32);
background: linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
backdrop-filter: blur(10px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-copy,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-row {
min-width: 0;
flex-wrap: wrap;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-title {
font-size: 14px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
font-size: 13px;
font-weight: 600;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 36px;
padding-inline: 12px;
border-radius: 999px;
border: 0;
color: #334155;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 245, 249, 0.9) 100%);
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.26),
0 6px 16px rgba(148, 163, 184, 0.12);
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-inline-end: 0;
border-radius: 999px;
color: #2563eb;
background: rgba(219, 234, 254, 0.92);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon .anticon {
font-size: 13px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(219, 234, 254, 0.94) 100%);
box-shadow:
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
0 8px 18px rgba(96, 165, 250, 0.16);
transform: translateY(-1px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover .ant-btn-icon,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible .ant-btn-icon {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:active {
transform: translateY(0);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-width: 34px;
height: 34px;
padding-inline: 0;
border-radius: 999px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:focus-visible {
color: #1d4ed8;
background: rgba(219, 234, 254, 0.86);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:focus-visible {
color: #b91c1c;
background: rgba(254, 226, 226, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.01em;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 5px;
padding: 5px 8px max(1px, env(safe-area-inset-bottom, 0px));
border: 0;
border-radius: 14px;
background: rgba(248, 250, 252, 0.94);
box-shadow:
inset 0 0 0 1px rgba(219, 226, 236, 0.82),
0 10px 28px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
width: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-type--readonly {
flex: 1 1 180px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-actions--shared,
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-utility-buttons {
flex: 0 0 auto;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-action-buttons .ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
padding-inline: 0;
border-radius: 999px;
}
@media (max-width: 760px) {
.app-chat-panel--rooms.app-chat-panel--rooms-shared .ant-card-body {
border-radius: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
width: auto;
gap: 4px;
justify-content: flex-end;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
width: auto;
gap: 0;
justify-content: flex-end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
width: auto;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
width: 34px;
min-width: 34px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 4px;
padding-bottom: max(1px, env(safe-area-inset-bottom, 0px));
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn {
width: 30px;
min-width: 30px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn .ant-btn-icon {
margin-inline-end: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn > span:not(.ant-btn-icon) {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
width: 24px;
height: 24px;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
white-space: normal;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
padding-bottom: max(12px, calc(env(safe-area-inset-bottom, 0px) + 8px));
}
}

View File

@@ -1,5 +1,12 @@
import type { ErrorLogItem } from '../errorLogApi'; import type { ErrorLogItem } from '../errorLogApi';
export type ChatPromptContextRef = {
key: 'prompt_parent_question';
promptTitle: string;
promptDescription?: string | null;
parentQuestionText?: string | null;
};
export type ChatMessagePart = export type ChatMessagePart =
| { | {
type: 'link_card'; type: 'link_card';
@@ -86,15 +93,29 @@ export type ChatComposerAttachment = {
mimeType: string; mimeType: string;
}; };
export type ChatCodexParticipant = {
id: string;
name: string;
model: string;
prompt?: string;
chatTypeId?: string | null;
defaultContextIds?: string[];
role?: 'default' | 'moderator' | 'conversation' | 'reviewer';
};
export type ChatViewContext = { export type ChatViewContext = {
pageId: string; pageId: string;
pageTitle: string; pageTitle: string;
topMenu: string; topMenu: string;
focusedComponentId: string | null; focusedComponentId: string | null;
pageUrl: string; pageUrl: string;
appOrigin?: string;
appDomain?: string;
isStandaloneMode: boolean; isStandaloneMode: boolean;
pageVisibilityState: 'visible' | 'hidden'; pageVisibilityState: 'visible' | 'hidden';
pageFocusState?: 'focused' | 'blurred'; pageFocusState?: 'focused' | 'blurred';
codexModel?: string | null;
codexParticipants?: ChatCodexParticipant[];
chatTypeId: string | null; chatTypeId: string | null;
chatTypeLabel: string; chatTypeLabel: string;
chatTypeDescription: string; chatTypeDescription: string;
@@ -113,13 +134,16 @@ export type ChatConversationSummary = {
sessionId: string; sessionId: string;
clientId: string | null; clientId: string | null;
isDraftOnly?: boolean; isDraftOnly?: boolean;
draftText: string;
title: string; title: string;
requestBadgeLabel?: string | null; requestBadgeLabel?: string | null;
codexModel?: string | null;
chatTypeId: string | null; chatTypeId: string | null;
lastChatTypeId: string | null; lastChatTypeId: string | null;
generalSectionName: string | null; generalSectionName: string | null;
contextLabel: string | null; contextLabel: string | null;
contextDescription: string | null; contextDescription: string | null;
roomScope: Record<string, unknown> | null;
notifyOffline: boolean; notifyOffline: boolean;
hasUnreadResponse: boolean; hasUnreadResponse: boolean;
currentRequestId: string | null; currentRequestId: string | null;
@@ -146,10 +170,23 @@ export type ChatConversationRequestStatus =
| 'cancelled' | 'cancelled'
| 'removed'; | 'removed';
export type ChatConversationRequestUsageSnapshot = {
tokenTotals: {
total: number;
input: number;
output: number;
cached: number;
reasoning: number;
};
totalTokens: number;
};
export type ChatConversationRequest = { export type ChatConversationRequest = {
sessionId: string; sessionId: string;
requestId: string; requestId: string;
requesterClientId?: string | null; requesterClientId?: string | null;
chatTypeId?: string | null;
chatTypeLabel?: string;
requestOrigin?: 'composer' | 'prompt' | null; requestOrigin?: 'composer' | 'prompt' | null;
parentRequestId?: string | null; parentRequestId?: string | null;
status: ChatConversationRequestStatus; status: ChatConversationRequestStatus;
@@ -158,8 +195,12 @@ export type ChatConversationRequest = {
userText: string; userText: string;
responseMessageId: number | null; responseMessageId: number | null;
responseText: string; responseText: string;
usageSnapshot?: ChatConversationRequestUsageSnapshot | null;
totalTokens?: number | null;
hasResponse: boolean; hasResponse: boolean;
canDelete: boolean; canDelete: boolean;
manualPromptCompletedAt?: string | null;
manualVerificationCompletedAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
answeredAt: string | null; answeredAt: string | null;
@@ -281,6 +322,14 @@ export type ErrorReferenceSummary = {
export type MainChatPanelProps = { export type MainChatPanelProps = {
initialView?: 'live' | 'errors'; initialView?: 'live' | 'errors';
lockOuterScrollOnMobile?: boolean; lockOuterScrollOnMobile?: boolean;
panelVariant?: 'codex-live' | 'system-chat';
mode?: 'live' | 'rooms';
roomsPresentation?: 'default' | 'shared';
roomsEntryMode?: 'list' | 'direct';
sharedComposerActionVariant?: 'text' | 'icon';
sharedComposerInputMode?: 'autosize' | 'fixed-scroll';
onRoomsMinimize?: (() => void) | null;
onRoomsClose?: (() => void) | null;
}; };
export type ChatServerEvent = export type ChatServerEvent =
@@ -344,6 +393,12 @@ export type ChatServerEvent =
type: 'chat:activity'; type: 'chat:activity';
payload: ChatActivityEvent; payload: ChatActivityEvent;
} }
| {
eventId: number;
sessionId: string;
type: 'chat:request:update';
payload: ChatConversationRequest;
}
| { | {
eventId: number; eventId: number;
sessionId: string; sessionId: string;

View File

@@ -11,6 +11,7 @@ import {
import { hasRegisteredAccessTokenAccess } from '../tokenAccess'; import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
import type { import type {
ChatActivityEvent, ChatActivityEvent,
ChatConversationRequest,
ChatJobEvent, ChatJobEvent,
ChatMessage, ChatMessage,
ChatRuntimeJobDetail, ChatRuntimeJobDetail,
@@ -32,6 +33,7 @@ type UseChatConnectionOptions = {
onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void; onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void; onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
onActivityEvent?: (event: ChatActivityEvent) => void; onActivityEvent?: (event: ChatActivityEvent) => void;
onRequestEvent?: (request: ChatConversationRequest, sessionId: string) => void;
}; };
type SharedChatConnectionState = { type SharedChatConnectionState = {
@@ -53,6 +55,7 @@ type SharedChatConnection = SharedChatConnectionState & {
onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined; onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined;
onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined; onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined;
onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined; onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined;
onRequestEvent?: ((request: ChatConversationRequest, sessionId: string) => void) | undefined;
lastEventId: number; lastEventId: number;
websocketUrl: string; websocketUrl: string;
subscribers: Set<() => void>; subscribers: Set<() => void>;
@@ -88,6 +91,7 @@ const sharedChatConnection: SharedChatConnection = {
onRuntimeEvent: undefined, onRuntimeEvent: undefined,
onRuntimeDetailEvent: undefined, onRuntimeDetailEvent: undefined,
onActivityEvent: undefined, onActivityEvent: undefined,
onRequestEvent: undefined,
lastEventId: 0, lastEventId: 0,
websocketUrl: '', websocketUrl: '',
subscribers: new Set(), subscribers: new Set(),
@@ -199,6 +203,8 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
? 'blurred' ? 'blurred'
: 'focused'; : 'focused';
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl; const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
const liveAppOrigin = typeof window !== 'undefined' ? window.location.origin : context.appOrigin ?? '';
const liveAppDomain = typeof window !== 'undefined' ? window.location.hostname : context.appDomain ?? '';
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -209,9 +215,13 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
topMenu: context.topMenu, topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId, focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl, pageUrl: livePageUrl,
appOrigin: liveAppOrigin,
appDomain: liveAppDomain,
isStandaloneMode: context.isStandaloneMode, isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: liveVisibilityState, pageVisibilityState: liveVisibilityState,
pageFocusState: liveFocusState, pageFocusState: liveFocusState,
codexModel: context.codexModel ?? null,
codexParticipants: context.codexParticipants ?? [],
chatTypeId: context.chatTypeId, chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel, chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription, chatTypeDescription: context.chatTypeDescription,
@@ -251,6 +261,8 @@ function sendImmediateHiddenContextUpdate() {
} }
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl; const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
const liveAppOrigin = typeof window !== 'undefined' ? window.location.origin : context.appOrigin ?? '';
const liveAppDomain = typeof window !== 'undefined' ? window.location.hostname : context.appDomain ?? '';
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
@@ -261,9 +273,13 @@ function sendImmediateHiddenContextUpdate() {
topMenu: context.topMenu, topMenu: context.topMenu,
focusedComponentId: context.focusedComponentId, focusedComponentId: context.focusedComponentId,
pageUrl: livePageUrl, pageUrl: livePageUrl,
appOrigin: liveAppOrigin,
appDomain: liveAppDomain,
isStandaloneMode: context.isStandaloneMode, isStandaloneMode: context.isStandaloneMode,
pageVisibilityState: 'hidden', pageVisibilityState: 'hidden',
pageFocusState: 'blurred', pageFocusState: 'blurred',
codexModel: context.codexModel ?? null,
codexParticipants: context.codexParticipants ?? [],
chatTypeId: context.chatTypeId, chatTypeId: context.chatTypeId,
chatTypeLabel: context.chatTypeLabel, chatTypeLabel: context.chatTypeLabel,
chatTypeDescription: context.chatTypeDescription, chatTypeDescription: context.chatTypeDescription,
@@ -663,6 +679,7 @@ function connectSharedSocket() {
onRuntimeEvent: sharedChatConnection.onRuntimeEvent, onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent, onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
onActivityEvent: sharedChatConnection.onActivityEvent, onActivityEvent: sharedChatConnection.onActivityEvent,
onRequestEvent: sharedChatConnection.onRequestEvent,
onEventReceived: (eventId) => { onEventReceived: (eventId) => {
sharedChatConnection.lastEventId = eventId; sharedChatConnection.lastEventId = eventId;
persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId); persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId);
@@ -712,6 +729,7 @@ function ensureSharedConnection(options: UseChatConnectionOptions) {
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent; sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent; sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = options.onActivityEvent; sharedChatConnection.onActivityEvent = options.onActivityEvent;
sharedChatConnection.onRequestEvent = options.onRequestEvent;
if (sessionChanged) { if (sessionChanged) {
sharedChatConnection.sessionId = options.sessionId; sharedChatConnection.sessionId = options.sessionId;
@@ -732,6 +750,7 @@ export function useChatConnection({
onRuntimeEvent, onRuntimeEvent,
onRuntimeDetailEvent, onRuntimeDetailEvent,
onActivityEvent, onActivityEvent,
onRequestEvent,
}: UseChatConnectionOptions) { }: UseChatConnectionOptions) {
const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot()); const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot());
@@ -758,6 +777,7 @@ export function useChatConnection({
onRuntimeEvent, onRuntimeEvent,
onRuntimeDetailEvent, onRuntimeDetailEvent,
onActivityEvent, onActivityEvent,
onRequestEvent,
}); });
handleSnapshotChange(); handleSnapshotChange();
@@ -768,22 +788,18 @@ export function useChatConnection({
useEffect(() => { useEffect(() => {
sharedChatConnection.currentContext = currentContext; sharedChatConnection.currentContext = currentContext;
sendContextUpdate(currentContext);
}, [currentContext]);
useEffect(() => {
sharedChatConnection.setMessages = setMessages; sharedChatConnection.setMessages = setMessages;
sharedChatConnection.onMessageEvent = onMessageEvent; sharedChatConnection.onMessageEvent = onMessageEvent;
sharedChatConnection.onJobEvent = onJobEvent; sharedChatConnection.onJobEvent = onJobEvent;
sharedChatConnection.onRuntimeEvent = onRuntimeEvent; sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent; sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
sharedChatConnection.onActivityEvent = onActivityEvent; sharedChatConnection.onActivityEvent = onActivityEvent;
sendContextUpdate(currentContext); sharedChatConnection.onRequestEvent = onRequestEvent;
}, [ }, [onActivityEvent, onJobEvent, onMessageEvent, onRequestEvent, onRuntimeDetailEvent, onRuntimeEvent, setMessages]);
currentContext,
onMessageEvent,
onJobEvent,
onRuntimeEvent,
onRuntimeDetailEvent,
onActivityEvent,
setMessages,
]);
useEffect(() => { useEffect(() => {
sharedChatConnection.pingSubscriberCount += 1; sharedChatConnection.pingSubscriberCount += 1;

View File

@@ -26,16 +26,22 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
history: '이력', history: '이력',
'automation-type': '자동화 유형', 'automation-type': '자동화 유형',
'automation-context': 'Context 유형', 'automation-context': 'Context 유형',
'token-setting': '설정',
'shared-resource': '공유 리소스 관리',
'server-command': 'Command', 'server-command': 'Command',
}; };
export const PLAN_BASE_OPEN_KEYS = ['plan-group', 'server-group', 'codex-live-group', 'app-log-group'] as const; export const PLAN_BASE_OPEN_KEYS = ['plan-group', 'token-management-group', 'server-group', 'chat-group', 'app-log-group'] as const;
export const PLAY_BASE_OPEN_KEYS = ['play-group', 'play-layout-group'] as const; export const PLAY_BASE_OPEN_KEYS = ['play-group', 'play-layout-group', 'play-apps-group', 'play-apps-general-group'] as const;
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const; export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done']; export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
export const PLAY_SIDEBAR_LABELS: Record<Extract<PlaySidebarKey, 'layout'>, string> = { export const PLAY_SIDEBAR_LABELS: Record<Extract<PlaySidebarKey, 'layout' | 'draw' | 'apps' | 'test' | 'cbt'>, string> = {
layout: 'Layout Editor', layout: 'Layout Editor',
draw: 'Layout Draw',
apps: 'Apps',
test: 'Test App',
cbt: 'CBT',
}; };
export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = { export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
@@ -51,6 +57,8 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
history: 'plan-menu-history', history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type', 'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context', 'automation-context': 'plan-menu-automation-context',
'token-setting': 'plan-menu-token-setting',
'shared-resource': 'plan-menu-shared-resource',
'server-command': 'plan-menu-server-command', 'server-command': 'plan-menu-server-command',
}; };
@@ -73,7 +81,13 @@ export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) { export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu); const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
if (selectedPlayMenu === 'layout') { if (
selectedPlayMenu === 'layout' ||
selectedPlayMenu === 'draw' ||
selectedPlayMenu === 'apps' ||
selectedPlayMenu === 'test' ||
selectedPlayMenu === 'cbt'
) {
return PLAY_SIDEBAR_LABELS[selectedPlayMenu]; return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
} }

View File

@@ -45,13 +45,15 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
selectedPlayMenu: selectedPlayMenu:
playSectionParam === 'layout' playSectionParam === 'layout'
? 'layout' ? 'layout'
: playSectionParam === 'test' : playSectionParam === 'apps'
? 'test' ? 'apps'
: playSectionParam === 'test'
? 'test'
: playSectionParam === 'cbt' : playSectionParam === 'cbt'
? 'cbt' ? 'cbt'
: playSectionParam === 'layout-record' && playLayoutIdParam : playSectionParam === 'layout-record' && playLayoutIdParam
? resolveSavedLayoutMenuKey(playLayoutIdParam) ? resolveSavedLayoutMenuKey(playLayoutIdParam)
: 'layout', : 'layout',
initialSelectedPlanId: Number.isFinite(parsedPlanId) ? parsedPlanId : null, initialSelectedPlanId: Number.isFinite(parsedPlanId) ? parsedPlanId : null,
initialSelectedWorkId: params.get('workId'), initialSelectedWorkId: params.get('workId'),
}; };

View File

@@ -1,8 +1,18 @@
import type { SearchKeywordOption } from '../../../components/search'; import type { SearchKeywordOption } from '../../../components/search';
import type { LoadedSampleEntry } from '../../../samples/registry'; import type { LoadedSampleEntry } from '../../../samples/registry';
import { buildPlayAppPath } from '../routes';
import type { ChatSidebarKey, PlanSidebarKey, TopMenuKey } from '../types'; import type { ChatSidebarKey, PlanSidebarKey, TopMenuKey } from '../types';
import { compactKeywords, scrollToElement } from './utils'; import { compactKeywords, scrollToElement } from './utils';
import { PLAN_FILTER_LABELS, PLAN_GROUP_LABEL, PLAN_SIDEBAR_LABELS } from './constants'; import { PLAN_FILTER_LABELS, PLAN_GROUP_LABEL, PLAN_SIDEBAR_LABELS } from './constants';
import { getReadyPlayAppEntries } from '../../../views/play/apps/apps/appsRegistry';
function getCurrentPathnameWithSearch() {
if (typeof window === 'undefined') {
return '/';
}
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
type SearchOptionBuilderParams = { type SearchOptionBuilderParams = {
componentSamples: LoadedSampleEntry[]; componentSamples: LoadedSampleEntry[];
@@ -127,19 +137,43 @@ export function buildMainViewSearchOptions({
}, },
...(hasAccess ...(hasAccess
? [ ? [
{ {
id: 'page:plans:server-command', id: 'page:plans:token-setting',
label: `Servers / ${PLAN_SIDEBAR_LABELS['server-command']}`, label: `토큰관리 / ${PLAN_SIDEBAR_LABELS['token-setting']}`,
group: 'Page', group: 'Page',
keywords: ['plans', 'plan', 'server', 'command', 'server command', '서버', '명령', '재기동'], keywords: ['plans', 'plan', 'token', 'token setting', '토큰', '토큰 설정', '권한 설정', '앱 권한'],
onSelect: () => { onSelect: () => {
setActiveTopMenu('plans'); setActiveTopMenu('plans');
setSelectedPlanMenu('server-command'); setSelectedPlanMenu('token-setting');
setFocusedComponentId(null); setFocusedComponentId(null);
}, },
onSelectWindow, onSelectWindow,
} satisfies SearchKeywordOption, } satisfies SearchKeywordOption,
] {
id: 'page:plans:shared-resource',
label: `토큰관리 / ${PLAN_SIDEBAR_LABELS['shared-resource']}`,
group: 'Page',
keywords: ['plans', 'plan', 'share token', 'shared resource', '공유 리소스', '공유 토큰', '권한 회수', '활동 내역'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('shared-resource');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
{
id: 'page:plans:server-command',
label: `Servers / ${PLAN_SIDEBAR_LABELS['server-command']}`,
group: 'Page',
keywords: ['plans', 'plan', 'server', 'command', 'server command', '서버', '명령', '재기동'],
onSelect: () => {
setActiveTopMenu('plans');
setSelectedPlanMenu('server-command');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption,
]
: []), : []),
{ {
id: 'page:preview:app', id: 'page:preview:app',
@@ -152,6 +186,36 @@ export function buildMainViewSearchOptions({
}, },
onSelectWindow, onSelectWindow,
}, },
...getReadyPlayAppEntries().map((entry) => ({
id: `page:play:app:${entry.id}`,
label: `Apps / ${entry.name}`,
group: 'Play App',
keywords: compactKeywords([entry.id, entry.name, 'apps', 'app', 'game', '게임', ...(entry.searchKeywords ?? [])]),
description: entry.searchDescription,
onSelect: () => {
setActiveTopMenu('play');
setFocusedComponentId(null);
window.history.pushState(
window.history.state,
'',
buildPlayAppPath(entry.id, 'embedded', getCurrentPathnameWithSearch()),
);
window.dispatchEvent(new PopStateEvent('popstate'));
},
onSelectWindow,
})),
{
id: 'page:chat:rooms',
label: '시스템 채팅 / 시스템 채팅',
group: 'Page',
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('rooms');
setFocusedComponentId(null);
},
onSelectWindow,
},
{ {
id: 'page:chat:live', id: 'page:chat:live',
label: 'Codex Live / Codex Live', label: 'Codex Live / Codex Live',
@@ -164,6 +228,18 @@ export function buildMainViewSearchOptions({
}, },
onSelectWindow, onSelectWindow,
}, },
{
id: 'page:chat:changes',
label: 'Codex Live / 변경 이력',
group: 'Page',
keywords: ['codex live', 'changes', 'source', 'diff', '변경', '소스', '채팅 변경', '채팅 diff'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('changes');
setFocusedComponentId(null);
},
onSelectWindow,
},
{ {
id: 'page:chat:resources', id: 'page:chat:resources',
label: '리소스 관리 / 리소스 관리', label: '리소스 관리 / 리소스 관리',
@@ -211,6 +287,18 @@ export function buildMainViewSearchOptions({
setFocusedComponentId(null); setFocusedComponentId(null);
}, },
onSelectWindow, onSelectWindow,
},
{
id: 'page:chat:manage-share',
label: '채팅 관리 / 공유채팅 생성',
group: 'Page',
keywords: ['chat manage', 'shared chat', 'share room', '공유채팅', '공유 채팅', '채팅방 생성', '공유 url'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('manage-share');
setFocusedComponentId(null);
},
onSelectWindow,
} satisfies SearchKeywordOption, } satisfies SearchKeywordOption,
...docFolders.map((folder) => ({ ...docFolders.map((folder) => ({
id: `docs-folder:${folder}`, id: `docs-folder:${folder}`,

View File

@@ -54,3 +54,43 @@ export function renderModalWithEnterConfirm(node: React.ReactNode) {
</div> </div>
); );
} }
type ModalConfirmApi = {
confirm: (config: Record<string, unknown>) => void;
};
type ConfirmWithKeyboardOptions = {
onOk?: (...args: unknown[]) => unknown;
onCancel?: (...args: unknown[]) => unknown;
} & Record<string, unknown>;
export async function confirmWithKeyboard(modalApi: ModalConfirmApi, config: ConfirmWithKeyboardOptions) {
const { onOk, onCancel, getContainer, ...restConfig } = config;
return await new Promise<boolean>((resolve) => {
let settled = false;
const finish = (value: boolean) => {
if (settled) {
return;
}
settled = true;
resolve(value);
};
modalApi.confirm({
...restConfig,
getContainer: getContainer ?? (() => document.body),
modalRender: renderModalWithEnterConfirm,
async onOk(...args: unknown[]) {
await onOk?.(...args);
finish(true);
},
onCancel(...args: unknown[]) {
onCancel?.(...args);
finish(false);
},
});
});
}

View File

@@ -1,4 +1,4 @@
import { appendClientIdHeader } from './clientIdentity'; import { appendClientIdHeader, getOrCreateClientId } from './clientIdentity';
function resolveNotificationApiBaseUrl() { function resolveNotificationApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) { if (import.meta.env.VITE_WORK_SERVER_URL) {
@@ -100,6 +100,10 @@ export type ClientNotificationSendResult = {
}; };
}; };
function normalizeNotificationOriginValue(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function getCurrentAppOrigin() { function getCurrentAppOrigin() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return '';
@@ -116,6 +120,41 @@ function getCurrentAppDomain() {
return window.location.hostname; return window.location.hostname;
} }
function appendNotificationOriginToBody(body: string, data?: Record<string, string>) {
const normalizedBody = String(body ?? '').trim();
const appOrigin = normalizeNotificationOriginValue(data?.appOrigin);
const appDomain = normalizeNotificationOriginValue(data?.appDomain);
const originLabel = appOrigin || appDomain;
if (!originLabel) {
return normalizedBody;
}
const originLine = `origin: ${originLabel}`;
if (normalizedBody.includes(originLine)) {
return normalizedBody;
}
return normalizedBody ? `${normalizedBody}\n${originLine}` : originLine;
}
function withCurrentAppOriginMetadata(data?: Record<string, string>) {
const metadata = { ...(data ?? {}) };
const appOrigin = getCurrentAppOrigin().trim();
const appDomain = getCurrentAppDomain().trim();
if (appOrigin && !normalizeNotificationOriginValue(metadata.appOrigin)) {
metadata.appOrigin = appOrigin;
}
if (appDomain && !normalizeNotificationOriginValue(metadata.appDomain)) {
metadata.appDomain = appDomain;
}
return metadata;
}
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent'; export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
export type NotificationMessageListStatus = 'all' | 'unread'; export type NotificationMessageListStatus = 'all' | 'unread';
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated'; export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
@@ -761,11 +800,14 @@ export async function registerWebPushSubscription(
subscription: WebPushSubscriptionPayload, subscription: WebPushSubscriptionPayload,
deviceId?: string, deviceId?: string,
) { ) {
const clientId = getOrCreateClientId().trim();
return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', { return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
subscription, subscription,
deviceId, deviceId,
clientId: clientId || undefined,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
appOrigin: getCurrentAppOrigin(), appOrigin: getCurrentAppOrigin(),
appDomain: getCurrentAppDomain(), appDomain: getCurrentAppDomain(),
@@ -801,9 +843,10 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
return false; return false;
} }
const notificationData = withCurrentAppOriginMetadata(payload.data);
const notificationOptions = { const notificationOptions = {
body: payload.body, body: appendNotificationOriginToBody(payload.body, notificationData),
data: payload.data ?? {}, data: notificationData,
tag: payload.threadId ?? payload.data?.notificationKey ?? undefined, tag: payload.threadId ?? payload.data?.notificationKey ?? undefined,
badge: '/pwa-192x192.svg', badge: '/pwa-192x192.svg',
icon: '/pwa-192x192.svg', icon: '/pwa-192x192.svg',
@@ -831,8 +874,12 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
} }
export async function sendClientNotification(payload: ClientNotificationPayload) { export async function sendClientNotification(payload: ClientNotificationPayload) {
const notificationData = withCurrentAppOriginMetadata(payload.data);
return request<ClientNotificationSendResult>('/notifications/send', { return request<ClientNotificationSendResult>('/notifications/send', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify({
...payload,
data: notificationData,
}),
}); });
} }

View File

@@ -1,4 +1,4 @@
import { clearClientId, getOrCreateClientId } from './clientIdentity'; import { getClientId } from './clientIdentity';
import { isPreviewRuntime } from './previewRuntime'; import { isPreviewRuntime } from './previewRuntime';
export const NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id'; export const NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
@@ -9,52 +9,82 @@ export type AutomationNotificationPreferenceTarget = {
targetId: string; targetId: string;
}; };
export function getSavedNotificationDeviceId() { function getNotificationIdentityStorage() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return null;
} }
const isPreview = isPreviewRuntime(); const isPreview = isPreviewRuntime();
const storage = isPreview ? window.sessionStorage : window.localStorage;
const deviceIdStorageKey = isPreview ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY; return {
const clientId = getOrCreateClientId(); storage: isPreview ? window.sessionStorage : window.localStorage,
if (clientId) { key: isPreview ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY,
storage.setItem(deviceIdStorageKey, clientId); };
return clientId; }
function generateNotificationDeviceId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `web-${crypto.randomUUID()}`;
} }
const saved = storage.getItem(deviceIdStorageKey); return `web-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function shouldRotateLegacyNotificationDeviceId(savedDeviceId: string) {
const normalizedDeviceId = savedDeviceId.trim();
if (!normalizedDeviceId) {
return false;
}
const currentClientId = getClientId().trim();
return Boolean(currentClientId) && normalizedDeviceId === currentClientId;
}
export function getSavedNotificationDeviceId() {
const storageConfig = getNotificationIdentityStorage();
if (!storageConfig) {
return '';
}
const saved = storageConfig.storage.getItem(storageConfig.key)?.trim() ?? '';
if (saved) { if (saved) {
if (shouldRotateLegacyNotificationDeviceId(saved)) {
const rotated = generateNotificationDeviceId();
storageConfig.storage.setItem(storageConfig.key, rotated);
return rotated;
}
return saved; return saved;
} }
const generated = `web-${Date.now()}`; const generated = generateNotificationDeviceId();
storage.setItem(deviceIdStorageKey, generated); storageConfig.storage.setItem(storageConfig.key, generated);
return generated; return generated;
} }
export function clearNotificationIdentity() { export function clearNotificationIdentity() {
if (typeof window === 'undefined') { const storageConfig = getNotificationIdentityStorage();
if (!storageConfig) {
return; return;
} }
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage; storageConfig.storage.removeItem(storageConfig.key);
const deviceIdStorageKey = isPreviewRuntime() ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY;
storage.removeItem(deviceIdStorageKey);
clearClientId();
} }
export function getAutomationNotificationPreferenceTarget(): AutomationNotificationPreferenceTarget | null { export function getAutomationNotificationPreferenceTarget(): AutomationNotificationPreferenceTarget | null {
const clientId = getSavedNotificationDeviceId(); const deviceId = getSavedNotificationDeviceId();
if (!clientId) { if (!deviceId) {
return null; return null;
} }
return { return {
targetKind: 'client', targetKind: 'client',
targetId: clientId, targetId: deviceId,
}; };
} }

View File

@@ -1,7 +1,9 @@
import { ChatDefaultContextManagementPage } from '../ChatDefaultContextManagementPage'; import { ChatDefaultContextManagementPage } from '../ChatDefaultContextManagementPage';
import { ResourceManagementPage } from '../ResourceManagementPage'; import { ResourceManagementPage } from '../ResourceManagementPage';
import { SharedChatManagementPage } from '../SharedChatManagementPage';
import { ChatTypeManagementPage } from '../ChatTypeManagementPage'; import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
import { MainChatPanel } from '../MainChatPanel'; import { MainChatPanel } from '../MainChatPanel';
import { SystemChatPanel } from '../SystemChatPanel';
import { ChatSourceChangesPage } from '../ChatSourceChangesPage'; import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
import { useMainLayoutContext } from '../layout/MainLayoutContext'; import { useMainLayoutContext } from '../layout/MainLayoutContext';
@@ -14,10 +16,14 @@ export function ChatPage() {
<ChatTypeManagementPage /> <ChatTypeManagementPage />
) : selectedChatMenu === 'manage-defaults' ? ( ) : selectedChatMenu === 'manage-defaults' ? (
<ChatDefaultContextManagementPage /> <ChatDefaultContextManagementPage />
) : selectedChatMenu === 'manage-share' ? (
<SharedChatManagementPage />
) : selectedChatMenu === 'resources' ? ( ) : selectedChatMenu === 'resources' ? (
<ResourceManagementPage /> <ResourceManagementPage />
) : selectedChatMenu === 'changes' ? ( ) : selectedChatMenu === 'changes' ? (
<ChatSourceChangesPage /> <ChatSourceChangesPage />
) : selectedChatMenu === 'rooms' ? (
<SystemChatPanel lockOuterScrollOnMobile />
) : ( ) : (
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile /> <MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
)} )}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage'; import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
import { AutomationContextManagementPage } from '../AutomationContextManagementPage'; import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
import { BoardPage } from '../../../features/board'; import { BoardPage } from '../../../features/board';
import { HistoryPage } from '../../../features/history'; import { HistoryPage } from '../../../features/history';
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard'; import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
@@ -71,6 +73,22 @@ export function PlansPage() {
); );
} }
if (selectedPlanMenu === 'token-setting') {
return (
<div className="app-main-panel">
<TokenSettingManagementPage />
</div>
);
}
if (selectedPlanMenu === 'shared-resource') {
return (
<div className="app-main-panel">
<SharedResourceManagementPage />
</div>
);
}
if (selectedPlanMenu === 'server-command') { if (selectedPlanMenu === 'server-command') {
return ( return (
<div className="app-main-panel"> <div className="app-main-panel">

View File

@@ -1,5 +1,7 @@
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { SampleWidgetsLayout } from '../../../features/layout/widget-sample-gallery'; import { SampleWidgetsLayout } from '../../../features/layout/widget-sample-gallery';
import { LayoutDrawPage } from '../../../features/layout/draw';
import { AppsLibraryView } from '../../../views/play/apps/apps/AppsLibraryView';
import { CbtPlayAppView } from '../../../views/play/apps/cbt/CbtPlayAppView'; import { CbtPlayAppView } from '../../../views/play/apps/cbt/CbtPlayAppView';
import { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView'; import { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView';
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView'; import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
@@ -32,6 +34,8 @@ export function PlayPage() {
/> />
) : null} ) : null}
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null} {selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedPlayMenu === 'draw' ? <LayoutDrawPage /> : null}
{selectedPlayMenu === 'apps' ? <AppsLibraryView /> : null}
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null} {selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
{selectedPlayMenu === 'cbt' && !isWidgetPreview ? <CbtPlayAppView /> : null} {selectedPlayMenu === 'cbt' && !isWidgetPreview ? <CbtPlayAppView /> : null}
{selectedSavedLayoutId && selectedSavedLayout {selectedSavedLayoutId && selectedSavedLayout

View File

@@ -2,11 +2,14 @@ const PREVIEW_RUNTIME_QUERY_KEY = 'appPreviewMode';
const PREVIEW_RUNTIME_PARENT_ORIGIN_KEY = 'previewParentOrigin'; const PREVIEW_RUNTIME_PARENT_ORIGIN_KEY = 'previewParentOrigin';
const PREVIEW_RUNTIME_TOKEN_QUERY_KEY = 'registeredAccessToken'; const PREVIEW_RUNTIME_TOKEN_QUERY_KEY = 'registeredAccessToken';
const PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY = 'previewDeviceMode'; const PREVIEW_RUNTIME_DEVICE_MODE_QUERY_KEY = 'previewDeviceMode';
const PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT = 'sm-home.preview-runtime.console';
const PREVIEW_TARGET_TYPE_QUERY_KEY = 'previewTargetType'; const PREVIEW_TARGET_TYPE_QUERY_KEY = 'previewTargetType';
const PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY = 'previewComponentId'; const PREVIEW_TARGET_COMPONENT_ID_QUERY_KEY = 'previewComponentId';
const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId'; const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId';
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1'; const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset'; const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
const PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS = 1200;
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [ const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
PREVIEW_RUNTIME_QUERY_KEY, PREVIEW_RUNTIME_QUERY_KEY,
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY, PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
@@ -22,6 +25,16 @@ export type PreviewTargetDescriptor =
} }
| null; | null;
export type PreviewRuntimeConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
export type PreviewRuntimeConsoleBridgeMessage = {
source: typeof PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT;
level: PreviewRuntimeConsoleLevel;
args: string[];
timestamp: string;
href: string;
};
export function isPreviewRuntime() { export function isPreviewRuntime() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return false; return false;
@@ -116,8 +129,17 @@ async function clearPreviewRuntimeServiceWorkersAndCaches() {
return changed; return changed;
} }
async function withTimeout<T>(task: Promise<T>, timeoutMs: number, fallbackValue: T) {
return await Promise.race([
task,
new Promise<T>((resolve) => {
window.setTimeout(() => resolve(fallbackValue), timeoutMs);
}),
]);
}
export async function ensurePreviewRuntimeFreshState() { export async function ensurePreviewRuntimeFreshState() {
if (typeof window === 'undefined' || (!isPreviewRuntime() && !isPreviewAppOrigin())) { if (typeof window === 'undefined' || !isPreviewRuntime()) {
return; return;
} }
@@ -129,7 +151,7 @@ export async function ensurePreviewRuntimeFreshState() {
return; return;
} }
const changed = await clearPreviewRuntimeServiceWorkersAndCaches(); const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
if (!changed) { if (!changed) {
if (resetSearchParam) { if (resetSearchParam) {
@@ -144,8 +166,8 @@ export async function ensurePreviewRuntimeFreshState() {
writePreviewRuntimeCacheResetMarker(currentLocationKey); writePreviewRuntimeCacheResetMarker(currentLocationKey);
window.location.replace(buildPreviewRuntimeCacheResetUrl()); window.location.replace(buildPreviewRuntimeCacheResetUrl());
await new Promise(() => { await new Promise<void>((resolve) => {
// Keep the bootstrap suspended until the browser navigates away. window.setTimeout(resolve, PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS);
}); });
} }
@@ -157,6 +179,148 @@ export function getPreviewRuntimeParentOrigin() {
return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY)?.trim() ?? ''; return new URLSearchParams(window.location.search).get(PREVIEW_RUNTIME_PARENT_ORIGIN_KEY)?.trim() ?? '';
} }
function stringifyPreviewRuntimeConsoleArg(value: unknown, seen = new WeakSet<object>()): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'undefined') {
return 'undefined';
}
if (value === null) {
return 'null';
}
if (typeof value === 'function') {
return `[function ${value.name || 'anonymous'}]`;
}
if (value instanceof Error) {
return value.stack || `${value.name}: ${value.message}`;
}
if (value instanceof URL) {
return value.toString();
}
if (typeof value === 'object') {
if (seen.has(value)) {
return '[circular]';
}
seen.add(value);
try {
return JSON.stringify(
value,
(_key, nestedValue) => {
if (typeof nestedValue === 'bigint') {
return nestedValue.toString();
}
if (nestedValue instanceof Error) {
return {
name: nestedValue.name,
message: nestedValue.message,
stack: nestedValue.stack,
};
}
if (nestedValue instanceof URL) {
return nestedValue.toString();
}
if (typeof nestedValue === 'function') {
return `[function ${nestedValue.name || 'anonymous'}]`;
}
if (nestedValue && typeof nestedValue === 'object') {
if (seen.has(nestedValue)) {
return '[circular]';
}
seen.add(nestedValue);
}
return nestedValue;
},
2,
);
} catch {
return Object.prototype.toString.call(value);
}
}
return String(value);
}
function postPreviewRuntimeConsoleMessage(level: PreviewRuntimeConsoleLevel, args: unknown[]) {
if (typeof window === 'undefined') {
return;
}
const parentOrigin = getPreviewRuntimeParentOrigin();
if (!parentOrigin || window.parent === window) {
return;
}
const payload: PreviewRuntimeConsoleBridgeMessage = {
source: PREVIEW_RUNTIME_CONSOLE_BRIDGE_EVENT,
level,
args: args.map((arg) => stringifyPreviewRuntimeConsoleArg(arg)),
timestamp: new Date().toISOString(),
href: window.location.href,
};
window.parent.postMessage(payload, parentOrigin);
}
export function installPreviewRuntimeConsoleBridge() {
if (typeof window === 'undefined' || !isPreviewRuntime()) {
return;
}
const consoleMethods: PreviewRuntimeConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug'];
const previewConsole = window.console as Console & {
__smHomePreviewConsoleBridgeInstalled?: boolean;
};
if (previewConsole.__smHomePreviewConsoleBridgeInstalled) {
return;
}
previewConsole.__smHomePreviewConsoleBridgeInstalled = true;
consoleMethods.forEach((level) => {
const originalMethod = previewConsole[level].bind(previewConsole);
previewConsole[level] = (...args: unknown[]) => {
postPreviewRuntimeConsoleMessage(level, args);
originalMethod(...args);
};
});
window.addEventListener('error', (event) => {
postPreviewRuntimeConsoleMessage('error', [
event.message || 'Unknown error',
event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : '',
event.error ?? '',
]);
});
window.addEventListener('unhandledrejection', (event) => {
postPreviewRuntimeConsoleMessage('error', ['Unhandled promise rejection', event.reason ?? '']);
});
postPreviewRuntimeConsoleMessage('info', ['Preview runtime console bridge connected']);
}
export function readPreviewRuntimeDeviceModeFromUrl(): 'desktop' | 'mobile' | null { export function readPreviewRuntimeDeviceModeFromUrl(): 'desktop' | 'mobile' | null {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;

View File

@@ -17,25 +17,31 @@ export type PlanSectionKey =
| 'history' | 'history'
| 'automation-type' | 'automation-type'
| 'automation-context' | 'automation-context'
| 'token-setting'
| 'shared-resource'
| 'server-command'; | 'server-command';
export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults'; export type ChatSectionKey = 'live' | 'rooms' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
export type PlaySectionKey = 'layout' | 'test' | 'cbt'; export type PlaySectionKey = 'layout' | 'draw' | 'apps' | 'test' | 'cbt';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`; export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = { export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live', live: 'Codex Live',
rooms: '시스템 채팅',
changes: 'Codex Live', changes: 'Codex Live',
resources: '리소스 관리', resources: '리소스 관리',
errors: '앱로그', errors: '앱로그',
manage: '채팅 관리', manage: '채팅 관리',
'manage-defaults': '채팅 관리', 'manage-defaults': '채팅 관리',
'manage-share': '채팅 관리',
}; };
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = { export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live', live: 'Codex Live',
rooms: '시스템 채팅',
changes: '변경 이력', changes: '변경 이력',
resources: '리소스 관리', resources: '리소스 관리',
errors: '에러 로그', errors: '에러 로그',
manage: '유형 권한 관리', manage: '유형 권한 관리',
'manage-defaults': '공통 문맥 관리', 'manage-defaults': '공통 문맥 관리',
'manage-share': '공유채팅 생성',
}; };
export const DOCS_DEFAULT_FOLDER = 'project'; export const DOCS_DEFAULT_FOLDER = 'project';
@@ -64,11 +70,15 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
history: '이력', history: '이력',
'automation-type': '자동화 유형', 'automation-type': '자동화 유형',
'automation-context': 'Context 유형', 'automation-context': 'Context 유형',
'token-setting': '설정',
'shared-resource': '공유 리소스 관리',
'server-command': 'Command', 'server-command': 'Command',
}; };
export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = { export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = {
layout: 'Layout Editor', layout: 'Layout Editor',
draw: 'Layout Draw',
apps: 'Apps',
test: 'Test App', test: 'Test App',
cbt: 'CBT', cbt: 'CBT',
}; };
@@ -86,6 +96,8 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
history: 'plan-menu-history', history: 'plan-menu-history',
'automation-type': 'plan-menu-automation-type', 'automation-type': 'plan-menu-automation-type',
'automation-context': 'plan-menu-automation-context', 'automation-context': 'plan-menu-automation-context',
'token-setting': 'plan-menu-token-setting',
'shared-resource': 'plan-menu-shared-resource',
'server-command': 'plan-menu-server-command', 'server-command': 'plan-menu-server-command',
}; };
@@ -104,7 +116,13 @@ export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) { export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu); const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
if (selectedPlayMenu === 'layout' || selectedPlayMenu === 'test' || selectedPlayMenu === 'cbt') { if (
selectedPlayMenu === 'layout' ||
selectedPlayMenu === 'draw' ||
selectedPlayMenu === 'apps' ||
selectedPlayMenu === 'test' ||
selectedPlayMenu === 'cbt'
) {
return PLAY_SIDEBAR_LABELS[selectedPlayMenu]; return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
} }
@@ -141,6 +159,36 @@ export function buildPlayPath(section: PlaySectionKey = 'layout') {
return `/play/${section}`; return `/play/${section}`;
} }
function normalizePlayAppReturnToPath(returnTo: string | null | undefined) {
if (!returnTo || !returnTo.startsWith('/') || returnTo.startsWith('//')) {
return null;
}
return returnTo;
}
export function buildPlayAppPath(
appId: string,
launchContext: 'direct' | 'embedded' = 'direct',
returnTo?: string | null,
) {
const searchParams = new URLSearchParams({
app: appId,
});
if (launchContext === 'embedded') {
searchParams.set('launchContext', launchContext);
}
const normalizedReturnTo = normalizePlayAppReturnToPath(returnTo);
if (normalizedReturnTo) {
searchParams.set('returnTo', normalizedReturnTo);
}
return `/play/apps?${searchParams.toString()}`;
}
export function buildSavedLayoutPath(layoutId: string) { export function buildSavedLayoutPath(layoutId: string) {
return `/play/layout-record/${layoutId}`; return `/play/layout-record/${layoutId}`;
} }
@@ -227,6 +275,21 @@ export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
}, },
], ],
}, },
{
key: 'token-management-group',
icon: <ProfileOutlined />,
label: '토큰관리',
children: [
{
key: 'token-setting',
label: renderPlanMenuLabel('token-setting', PLAN_SIDEBAR_LABELS['token-setting']),
},
{
key: 'shared-resource',
label: renderPlanMenuLabel('shared-resource', PLAN_SIDEBAR_LABELS['shared-resource']),
},
],
},
{ {
key: 'server-group', key: 'server-group',
icon: <ProfileOutlined />, icon: <ProfileOutlined />,
@@ -256,11 +319,12 @@ function renderChatUnreadLabel(label: string, unreadCount: number) {
export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProps['items'] { export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProps['items'] {
return [ return [
{ {
key: 'codex-live-group', key: 'chat-group',
icon: <MessageOutlined />, icon: <MessageOutlined />,
label: renderChatUnreadLabel('Codex Live', unreadCount), label: renderChatUnreadLabel('채팅', unreadCount),
children: [ children: [
{ key: 'live', label: renderChatUnreadLabel('Codex Live', unreadCount) }, { key: 'live', label: 'Codex Live' },
{ key: 'rooms', label: '시스템 채팅' },
{ key: 'changes', label: '변경 이력' }, { key: 'changes', label: '변경 이력' },
{ key: 'resources', label: '리소스 관리' }, { key: 'resources', label: '리소스 관리' },
], ],
@@ -278,6 +342,7 @@ export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProp
children: [ children: [
{ key: 'manage', label: '유형 권한 관리' }, { key: 'manage', label: '유형 권한 관리' },
{ key: 'manage-defaults', label: '공통 문맥 관리' }, { key: 'manage-defaults', label: '공통 문맥 관리' },
{ key: 'manage-share', label: '공유채팅 생성' },
], ],
}, },
]; ];
@@ -295,6 +360,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
label: 'Layout', label: 'Layout',
children: [ children: [
{ key: 'layout', label: 'Layout Editor' }, { key: 'layout', label: 'Layout Editor' },
{ key: 'draw', label: 'Layout Draw' },
...(savedLayouts.length ...(savedLayouts.length
? savedLayouts.map((record) => ({ ? savedLayouts.map((record) => ({
key: resolveSavedLayoutMenuKey(record.id), key: resolveSavedLayoutMenuKey(record.id),
@@ -307,6 +373,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
key: 'play-apps-group', key: 'play-apps-group',
label: 'Apps', label: 'Apps',
children: [ children: [
{ key: 'apps', label: 'Apps' },
{ key: 'test', label: 'Test App' }, { key: 'test', label: 'Test App' },
{ {
key: 'play-apps-general-group', key: 'play-apps-general-group',
@@ -321,7 +388,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
} }
export function resolvePlanOpenKeys() { export function resolvePlanOpenKeys() {
return ['plan-group', 'server-group', 'codex-live-group', 'app-log-group', 'chat-manage-group']; return ['plan-group', 'token-management-group', 'server-group', 'chat-group', 'app-log-group', 'chat-manage-group'];
} }
export function resolvePlayOpenKeys() { export function resolvePlayOpenKeys() {
@@ -380,7 +447,12 @@ export function resolveCurrentPageDescriptor(params: {
} }
if (topMenu === 'plans') { if (topMenu === 'plans') {
const title = planMenu === 'server-command' ? `Servers / ${PLAN_SIDEBAR_LABELS[planMenu]}` : `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS[planMenu]}`; const title =
planMenu === 'server-command'
? `Servers / ${PLAN_SIDEBAR_LABELS[planMenu]}`
: planMenu === 'token-setting' || planMenu === 'shared-resource'
? `토큰관리 / ${PLAN_SIDEBAR_LABELS[planMenu]}`
: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS[planMenu]}`;
return { return {
id: `plans:${planMenu}`, id: `plans:${planMenu}`,

View File

@@ -0,0 +1,539 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
export type SharedResourcePermission = 'view' | 'download' | 'comment' | 'upload' | 'manage';
export type SharedResourceType = 'file' | 'directory' | 'document' | 'chat-share' | 'external-url';
export type SharedResourceLinkedTokenSettingSummary = {
id: string;
name: string;
enabled: boolean;
defaultExpiresInMinutes: number;
allowedAppIds: string[];
syncState: 'ok' | 'missing' | 'disabled' | 'chat-share-disallowed';
syncMessage: string | null;
};
export type SharedResourceTokenSettingSnapshot = {
id: string;
name: string;
defaultExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
};
export type SharedResourceTokenRecord = {
id: string;
name: string;
description: string;
tokenSettingId: string | null;
tokenSettingSnapshot: SharedResourceTokenSettingSnapshot | null;
linkedTokenSetting: SharedResourceLinkedTokenSettingSummary | null;
resourceLabel: string;
resourcePath: string;
resourceType: SharedResourceType;
shareToken: string;
sharePath: string;
resourceAllowedAppIds: string[];
resourceAllowedAppIdsOverrideEnabled: boolean;
allowedAppIds: string[];
permissions: SharedResourcePermission[];
enabled: boolean;
revokedAt: string | null;
deletedAt: string | null;
expiresAt: string | null;
effectiveExpiresAt: string | null;
usageLimit: number;
usageCount: number;
hasAccessPin: boolean;
accessPinPromptTtlMinutes: number | null;
allowAccessPinChangeWithoutManage: boolean;
usageTokenTotal: number;
usageRequestCount: number;
usageCompletedRequestCount: number;
lastUsedAt: string | null;
lastTokenUsedAt: string | null;
createdAt: string;
updatedAt: string;
lastActivityAt: string | null;
activityCount: number;
};
export type SharedResourceTokenActivityRecord = {
id: number;
tokenId: string;
type: 'created' | 'updated' | 'permission-granted' | 'permission-revoked' | 'revoked' | 'restored' | 'deleted' | 'usage';
actorLabel: string | null;
summary: string;
detail: string | null;
usageDelta: number;
createdAt: string;
};
export type SharedResourceTokenDetail = {
token: SharedResourceTokenRecord;
activities: SharedResourceTokenActivityRecord[];
};
export type SharedResourceTokenBulkActionResult = {
requestedTokenIds: string[];
processedTokenIds: string[];
skippedTokenIds: string[];
missingTokenIds: string[];
};
export type SharedResourceTokenInput = {
id?: string;
name: string;
description?: string;
tokenSettingId?: string | null;
resourceLabel: string;
resourcePath: string;
resourceType: SharedResourceType;
shareToken?: string | null;
sharePath?: string | null;
resourceAllowedAppIds?: string[] | null;
resourceAllowedAppIdsOverrideEnabled?: boolean;
permissions: SharedResourcePermission[];
enabled: boolean;
expiresAt?: string | null;
usageLimit?: number;
accessPin?: string | null;
accessPinPromptTtlMinutes?: number | null;
allowAccessPinChangeWithoutManage?: boolean;
};
type SharedResourceTokenRequestOptions = {
shareToken?: string | null;
};
const SHARED_RESOURCE_TOKENS_API_PATH = '/shared-resource-tokens';
const SHARED_RESOURCE_TOKENS_SYNC_EVENT = 'work-app:shared-resource-tokens-changed';
const REQUEST_TIMEOUT_MS = 8000;
class SharedResourceTokenApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'SharedResourceTokenApiError';
this.status = status;
}
}
function isAbortRequestError(error: unknown) {
return error instanceof DOMException && error.name === 'AbortError';
}
function emitSharedResourceTokensChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(SHARED_RESOURCE_TOKENS_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const API_BASE_URL = resolveApiBaseUrl();
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
function normalizeRequiredText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeOptionalText(value: unknown) {
const normalized = normalizeRequiredText(value);
return normalized || null;
}
function normalizeStringArray(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => normalizeRequiredText(item)).filter(Boolean);
}
function normalizeBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function normalizeNumber(value: unknown, fallback = 0) {
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : fallback;
}
function normalizeSharedResourceTokenRecord(record: SharedResourceTokenRecord): SharedResourceTokenRecord {
return {
...record,
id: normalizeRequiredText(record.id),
name: normalizeRequiredText(record.name),
description: normalizeRequiredText(record.description),
tokenSettingId: normalizeOptionalText(record.tokenSettingId),
tokenSettingSnapshot: record.tokenSettingSnapshot
? {
id: normalizeRequiredText(record.tokenSettingSnapshot.id),
name: normalizeRequiredText(record.tokenSettingSnapshot.name),
defaultExpiresInMinutes: normalizeNumber(record.tokenSettingSnapshot.defaultExpiresInMinutes),
maxTokensPer30Days: normalizeNumber(record.tokenSettingSnapshot.maxTokensPer30Days),
maxTokensPer7Days: normalizeNumber(record.tokenSettingSnapshot.maxTokensPer7Days),
maxTokensPer5Hours: normalizeNumber(record.tokenSettingSnapshot.maxTokensPer5Hours),
oneTimeTokenLimit: normalizeNumber(record.tokenSettingSnapshot.oneTimeTokenLimit),
allowedAppIds: normalizeStringArray(record.tokenSettingSnapshot.allowedAppIds),
}
: null,
linkedTokenSetting: record.linkedTokenSetting
? {
...record.linkedTokenSetting,
id: normalizeRequiredText(record.linkedTokenSetting.id),
name: normalizeRequiredText(record.linkedTokenSetting.name),
enabled: normalizeBoolean(record.linkedTokenSetting.enabled),
defaultExpiresInMinutes: normalizeNumber(record.linkedTokenSetting.defaultExpiresInMinutes),
allowedAppIds: normalizeStringArray(record.linkedTokenSetting.allowedAppIds),
syncMessage: normalizeOptionalText(record.linkedTokenSetting.syncMessage),
}
: null,
resourceLabel: normalizeRequiredText(record.resourceLabel),
resourcePath: normalizeRequiredText(record.resourcePath),
shareToken: normalizeRequiredText(record.shareToken),
sharePath: normalizeRequiredText(record.sharePath),
resourceAllowedAppIds: normalizeStringArray(record.resourceAllowedAppIds),
resourceAllowedAppIdsOverrideEnabled: normalizeBoolean(record.resourceAllowedAppIdsOverrideEnabled),
allowedAppIds: normalizeStringArray(record.allowedAppIds),
permissions: Array.isArray(record.permissions)
? record.permissions.filter(
(item): item is SharedResourcePermission =>
item === 'view' || item === 'download' || item === 'comment' || item === 'upload' || item === 'manage',
)
: [],
enabled: normalizeBoolean(record.enabled, true),
revokedAt: normalizeOptionalText(record.revokedAt),
deletedAt: normalizeOptionalText(record.deletedAt),
expiresAt: normalizeOptionalText(record.expiresAt),
effectiveExpiresAt: normalizeOptionalText(record.effectiveExpiresAt),
usageLimit: normalizeNumber(record.usageLimit),
usageCount: normalizeNumber(record.usageCount),
hasAccessPin: normalizeBoolean(record.hasAccessPin),
accessPinPromptTtlMinutes:
record.accessPinPromptTtlMinutes === null ? null : normalizeNumber(record.accessPinPromptTtlMinutes),
allowAccessPinChangeWithoutManage: normalizeBoolean(record.allowAccessPinChangeWithoutManage),
usageTokenTotal: normalizeNumber(record.usageTokenTotal),
usageRequestCount: normalizeNumber(record.usageRequestCount),
usageCompletedRequestCount: normalizeNumber(record.usageCompletedRequestCount),
lastUsedAt: normalizeOptionalText(record.lastUsedAt),
lastTokenUsedAt: normalizeOptionalText(record.lastTokenUsedAt),
createdAt: normalizeRequiredText(record.createdAt),
updatedAt: normalizeRequiredText(record.updatedAt),
lastActivityAt: normalizeOptionalText(record.lastActivityAt),
activityCount: normalizeNumber(record.activityCount),
};
}
function normalizeSharedResourceTokenActivityRecord(
record: SharedResourceTokenActivityRecord,
): SharedResourceTokenActivityRecord {
return {
...record,
id: normalizeNumber(record.id),
tokenId: normalizeRequiredText(record.tokenId),
actorLabel: normalizeOptionalText(record.actorLabel),
summary: normalizeRequiredText(record.summary),
detail: normalizeOptionalText(record.detail),
usageDelta: normalizeNumber(record.usageDelta),
createdAt: normalizeRequiredText(record.createdAt),
};
}
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit, options?: SharedResourceTokenRequestOptions): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const token = getRegisteredAccessToken();
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const shareToken = options?.shareToken?.trim() ?? '';
if (!shareToken && !isAllowedRegistrationToken(token)) {
throw new SharedResourceTokenApiError('권한 토큰 등록 후에만 공유 리소스 관리를 사용할 수 있습니다.', 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);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
try {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new SharedResourceTokenApiError(payload.message || '공유 리소스 토큰 요청에 실패했습니다.', response.status);
} catch {
throw new SharedResourceTokenApiError(text || '공유 리소스 토큰 요청에 실패했습니다.', response.status);
}
}
const text = await response.text();
if (!text.trim()) {
throw new SharedResourceTokenApiError('공유 리소스 토큰 응답이 비어 있습니다.', 502);
}
try {
return JSON.parse(text) as T;
} catch {
throw new SharedResourceTokenApiError('공유 리소스 토큰 응답을 해석하지 못했습니다.', 502);
}
} catch (error) {
if (isAbortRequestError(error)) {
throw error;
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
}
async function request<T>(path: string, init?: RequestInit, options?: SharedResourceTokenRequestOptions) {
try {
return await requestOnce<T>(API_BASE_URL, path, init, options);
} catch (error) {
const shouldRetryWithFallback =
FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
(error instanceof SharedResourceTokenApiError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(FALLBACK_BASE_URL, path, init, options);
}
}
export async function loadSharedResourceTokens(options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean; items: SharedResourceTokenRecord[] }>(SHARED_RESOURCE_TOKENS_API_PATH, undefined, options);
return Array.isArray(response.items) ? response.items.map((item) => normalizeSharedResourceTokenRecord(item)) : [];
}
export async function loadSharedResourceTokenDetail(tokenId: string, options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean; token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] }>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/${encodeURIComponent(tokenId)}`,
undefined,
options,
);
return {
...response,
token: normalizeSharedResourceTokenRecord(response.token),
activities: Array.isArray(response.activities)
? response.activities.map((item) => normalizeSharedResourceTokenActivityRecord(item))
: [],
};
}
export async function saveSharedResourceToken(input: SharedResourceTokenInput, options?: SharedResourceTokenRequestOptions) {
const payload = {
...input,
title: input.name,
};
const response = await request<{ ok: boolean; token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] }>(
SHARED_RESOURCE_TOKENS_API_PATH,
{
method: 'PUT',
body: JSON.stringify(payload),
},
options,
);
emitSharedResourceTokensChange();
return {
...response,
token: normalizeSharedResourceTokenRecord(response.token),
activities: Array.isArray(response.activities)
? response.activities.map((item) => normalizeSharedResourceTokenActivityRecord(item))
: [],
};
}
export async function revokeSharedResourceToken(tokenId: string, reason = '', options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean; token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] }>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/${encodeURIComponent(tokenId)}/revoke`,
{
method: 'POST',
body: JSON.stringify({ reason }),
},
options,
);
emitSharedResourceTokensChange();
return {
...response,
token: normalizeSharedResourceTokenRecord(response.token),
activities: Array.isArray(response.activities)
? response.activities.map((item) => normalizeSharedResourceTokenActivityRecord(item))
: [],
};
}
export async function revokeSharedResourceTokens(tokenIds: string[], reason = '', options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean } & SharedResourceTokenBulkActionResult>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/bulk-revoke`,
{
method: 'POST',
body: JSON.stringify({ tokenIds, reason }),
},
options,
);
emitSharedResourceTokensChange();
return response;
}
export async function restoreSharedResourceToken(tokenId: string, options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean; token: SharedResourceTokenRecord; activities: SharedResourceTokenActivityRecord[] }>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/${encodeURIComponent(tokenId)}/restore`,
{
method: 'POST',
},
options,
);
emitSharedResourceTokensChange();
return {
...response,
token: normalizeSharedResourceTokenRecord(response.token),
activities: Array.isArray(response.activities)
? response.activities.map((item) => normalizeSharedResourceTokenActivityRecord(item))
: [],
};
}
export async function deleteSharedResourceToken(tokenId: string, options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean; deleted: boolean; tokenId: string }>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/${encodeURIComponent(tokenId)}`,
{
method: 'DELETE',
},
options,
);
emitSharedResourceTokensChange();
return response;
}
export async function deleteSharedResourceTokens(tokenIds: string[], options?: SharedResourceTokenRequestOptions) {
const response = await request<{ ok: boolean } & SharedResourceTokenBulkActionResult>(
`${SHARED_RESOURCE_TOKENS_API_PATH}/bulk-delete`,
{
method: 'POST',
body: JSON.stringify({ tokenIds }),
},
options,
);
emitSharedResourceTokensChange();
return response;
}
export function useSharedResourceTokenRegistry(options?: SharedResourceTokenRequestOptions) {
const [tokens, setTokens] = useState<SharedResourceTokenRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const refreshRef = useRef<(() => Promise<void>) | null>(null);
const shareToken = options?.shareToken?.trim() ?? '';
useEffect(() => {
let active = true;
const refresh = async () => {
setIsLoading(true);
setErrorMessage('');
try {
const nextItems = await loadSharedResourceTokens({ shareToken });
if (!active) {
return;
}
setTokens(nextItems);
} catch (error) {
if (!active || isAbortRequestError(error)) {
return;
}
setErrorMessage(error instanceof Error ? error.message : '공유 리소스 토큰을 불러오지 못했습니다.');
} finally {
if (active) {
setIsLoading(false);
}
}
};
refreshRef.current = refresh;
void refresh();
const handleSync = () => {
void refresh();
};
window.addEventListener(SHARED_RESOURCE_TOKENS_SYNC_EVENT, handleSync);
return () => {
active = false;
window.removeEventListener(SHARED_RESOURCE_TOKENS_SYNC_EVENT, handleSync);
};
}, [shareToken]);
return {
tokens,
isLoading,
errorMessage,
refresh: async () => {
if (refreshRef.current) {
await refreshRef.current();
}
},
};
}

View File

@@ -0,0 +1,311 @@
.app-chat-panel--rooms-shared.ant-card {
border: 0;
border-radius: 0;
height: 100%;
background: transparent;
box-shadow: none;
}
.app-chat-panel--rooms-shared .ant-card-head {
display: block;
}
.app-chat-panel--rooms-shared .ant-card-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
padding: 0;
border-radius: 14px;
background: linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%);
box-shadow:
inset 0 0 0 1px rgba(196, 210, 226, 0.96),
0 8px 24px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__stack,
.app-chat-panel--rooms-shared .app-chat-panel__stack--chat {
min-height: 0;
height: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main,
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
background: transparent;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main {
min-height: 0;
height: 100%;
overflow: hidden;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
padding: 18px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header {
padding: 8px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.32);
background: linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
backdrop-filter: blur(10px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-copy,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-row {
min-width: 0;
flex-wrap: wrap;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-title {
font-size: 14px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
font-size: 13px;
font-weight: 600;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 36px;
padding-inline: 12px;
border-radius: 999px;
border: 0;
color: #334155;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 245, 249, 0.9) 100%);
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.26),
0 6px 16px rgba(148, 163, 184, 0.12);
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-inline-end: 0;
border-radius: 999px;
color: #2563eb;
background: rgba(219, 234, 254, 0.92);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon .anticon {
font-size: 13px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(219, 234, 254, 0.94) 100%);
box-shadow:
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
0 8px 18px rgba(96, 165, 250, 0.16);
transform: translateY(-1px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover .ant-btn-icon,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible .ant-btn-icon {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:active {
transform: translateY(0);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-width: 34px;
height: 34px;
padding-inline: 0;
border-radius: 999px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:focus-visible {
color: #1d4ed8;
background: rgba(219, 234, 254, 0.86);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:focus-visible {
color: #b91c1c;
background: rgba(254, 226, 226, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.01em;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 5px;
padding: 5px 8px max(1px, env(safe-area-inset-bottom, 0px));
border: 0;
border-radius: 14px;
background: rgba(248, 250, 252, 0.94);
box-shadow:
inset 0 0 0 1px rgba(219, 226, 236, 0.82),
0 10px 28px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
width: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-type--readonly {
flex: 1 1 180px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-actions--shared,
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-utility-buttons {
flex: 0 0 auto;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-action-buttons .ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
padding-inline: 0;
border-radius: 999px;
}
@media (max-width: 760px) {
.app-chat-panel--rooms.app-chat-panel--rooms-shared .ant-card-body {
border-radius: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
width: auto;
gap: 4px;
justify-content: flex-end;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
width: auto;
gap: 0;
justify-content: flex-end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
width: auto;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
width: 34px;
min-width: 34px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 4px;
padding-bottom: max(1px, env(safe-area-inset-bottom, 0px));
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn {
width: 30px;
min-width: 30px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn .ant-btn-icon {
margin-inline-end: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn > span:not(.ant-btn-icon) {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
width: 24px;
height: 24px;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
white-space: normal;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
padding-bottom: max(12px, calc(env(safe-area-inset-bottom, 0px) + 8px));
}
}

View File

@@ -10,6 +10,7 @@ export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
export const ALLOWED_REGISTRATION_TOKEN = export const ALLOWED_REGISTRATION_TOKEN =
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f'; import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token'; const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token';
const PREVIEW_APP_ORIGIN = 'https://preview.sm-home.cloud';
let previewRuntimeTokenMemory = ''; let previewRuntimeTokenMemory = '';
@@ -43,6 +44,7 @@ function bootstrapRegisteredAccessToken() {
} }
const tokenFromUrl = readPreviewRuntimeTokenFromUrl(); const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
if (isPreviewRuntime()) { if (isPreviewRuntime()) {
if (!tokenFromUrl) { if (!tokenFromUrl) {
@@ -57,6 +59,9 @@ function bootstrapRegisteredAccessToken() {
} }
if (!tokenFromUrl) { if (!tokenFromUrl) {
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
}
return; return;
} }
@@ -82,7 +87,14 @@ export function getRegisteredAccessToken() {
return previewToken; return previewToken;
} }
return readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY); const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
return ALLOWED_REGISTRATION_TOKEN;
}
return storedToken;
} }
export function hasRegisteredAccessTokenAccess() { export function hasRegisteredAccessTokenAccess() {

View File

@@ -0,0 +1,371 @@
import { useEffect, useRef, useState } from 'react';
import { appendClientIdHeader } from './clientIdentity';
import { getRegisteredAccessToken, isAllowedRegistrationToken } from './tokenAccess';
const UNBOUNDED_NUMERIC_LIMIT = Number.MAX_SAFE_INTEGER;
export type TokenSettingRecord = {
id: string;
name: string;
description: string;
defaultExpiresInMinutes: number;
maxExpiresInMinutes: number;
maxTokensPer30Days: number;
maxTokensPer7Days: number;
maxTokensPer5Hours: number;
oneTimeTokenLimit: number;
allowedAppIds: string[];
enabled: boolean;
updatedAt: string;
};
export type TokenSettingInput = {
originalId?: string;
id?: string;
name: string;
description?: string;
defaultExpiresInMinutes?: number;
maxExpiresInMinutes?: number;
maxTokensPer30Days?: number;
maxTokensPer7Days?: number;
maxTokensPer5Hours?: number;
oneTimeTokenLimit?: number;
allowedAppIds?: string[];
enabled?: boolean;
};
const TOKEN_SETTINGS_API_PATH = '/token-settings';
const TOKEN_SETTINGS_SYNC_EVENT = 'work-app:token-settings-changed';
const TOKEN_SETTINGS_REQUEST_TIMEOUT_MS = 8000;
type TokenSettingsRequestOptions = {
shareToken?: string | null;
};
class TokenSettingApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'TokenSettingApiError';
this.status = status;
}
}
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function normalizeSettingId(value: string | null | undefined) {
return normalizeText(value)
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9._-]/g, '');
}
function normalizePositiveInteger(value: number | undefined, fallback: number, min: number, max: number) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(max, Math.max(min, Math.round(value)));
}
function normalizeAllowedAppIds(value: string[] | null | undefined) {
return Array.from(
new Set(
(value ?? [])
.map((item) => normalizeText(item))
.filter(Boolean),
),
).sort((left, right) => left.localeCompare(right, 'en'));
}
function normalizeTokenSetting(record: Partial<TokenSettingRecord>): TokenSettingRecord | null {
const id = normalizeSettingId(record.id);
const name = normalizeText(record.name);
if (!id || !name) {
return null;
}
const defaultExpiresInMinutes = normalizePositiveInteger(record.defaultExpiresInMinutes, 60, 0, UNBOUNDED_NUMERIC_LIMIT);
const resolvedMaxExpiresInMinutes = normalizePositiveInteger(
record.maxExpiresInMinutes,
defaultExpiresInMinutes <= 0 ? 0 : 10_080,
0,
UNBOUNDED_NUMERIC_LIMIT,
);
const maxExpiresInMinutes =
defaultExpiresInMinutes <= 0 || resolvedMaxExpiresInMinutes <= 0
? 0
: Math.max(defaultExpiresInMinutes, resolvedMaxExpiresInMinutes);
const legacyMaxTotalTokens =
'maxTotalTokens' in record
? normalizePositiveInteger((record as { maxTotalTokens?: number }).maxTotalTokens, 100_000, 0, UNBOUNDED_NUMERIC_LIMIT)
: 100_000;
return {
id,
name,
description: normalizeText(record.description),
defaultExpiresInMinutes,
maxExpiresInMinutes,
maxTokensPer30Days: normalizePositiveInteger(record.maxTokensPer30Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer7Days: normalizePositiveInteger(record.maxTokensPer7Days, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
maxTokensPer5Hours: normalizePositiveInteger(record.maxTokensPer5Hours, legacyMaxTotalTokens, 0, UNBOUNDED_NUMERIC_LIMIT),
oneTimeTokenLimit: normalizePositiveInteger(record.oneTimeTokenLimit, 0, 0, UNBOUNDED_NUMERIC_LIMIT),
allowedAppIds: normalizeAllowedAppIds(record.allowedAppIds),
enabled: record.enabled !== false,
updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(),
};
}
export function sanitizeTokenSettings(items: Partial<TokenSettingRecord>[] | null | undefined) {
const byId = new Map<string, TokenSettingRecord>();
for (const item of items ?? []) {
const normalized = normalizeTokenSetting(item);
if (!normalized) {
continue;
}
const current = byId.get(normalized.id);
if (!current || Date.parse(current.updatedAt) <= Date.parse(normalized.updatedAt)) {
byId.set(normalized.id, normalized);
}
}
return Array.from(byId.values()).sort((left, right) => {
const nameCompare = left.name.localeCompare(right.name, 'ko-KR');
if (nameCompare !== 0) {
return nameCompare;
}
return left.id.localeCompare(right.id, 'en');
});
}
function emitTokenSettingsChange() {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(new Event(TOKEN_SETTINGS_SYNC_EVENT));
}
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
function resolveFallbackBaseUrl() {
if (typeof window === 'undefined') {
return null;
}
const hostname = window.location.hostname;
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
return null;
}
const fallbackUrl = new URL(window.location.origin);
fallbackUrl.port = '3100';
fallbackUrl.pathname = '/api';
fallbackUrl.search = '';
fallbackUrl.hash = '';
return fallbackUrl.toString().replace(/\/+$/, '');
}
const API_BASE_URL = resolveApiBaseUrl();
const FALLBACK_BASE_URL = resolveFallbackBaseUrl();
async function requestOnce<T>(baseUrl: string, init?: RequestInit, options?: TokenSettingsRequestOptions): Promise<T> {
const headers = appendClientIdHeader(init?.headers);
const token = getRegisteredAccessToken();
const hasBody = init?.body !== undefined && init?.body !== null;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), TOKEN_SETTINGS_REQUEST_TIMEOUT_MS);
const shareToken = options?.shareToken?.trim() ?? '';
if (!shareToken && !isAllowedRegistrationToken(token)) {
throw new TokenSettingApiError('권한 토큰 등록 후에만 토큰 설정을 관리할 수 있습니다.', 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);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
try {
const response = await fetch(`${baseUrl}${TOKEN_SETTINGS_API_PATH}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new TokenSettingApiError(payload.message || '토큰 설정 요청에 실패했습니다.', response.status);
} catch {
throw new TokenSettingApiError(text || '토큰 설정 요청에 실패했습니다.', response.status);
}
}
const text = await response.text();
if (!text.trim()) {
throw new TokenSettingApiError('토큰 설정 응답이 비어 있습니다.', 502);
}
try {
return JSON.parse(text) as T;
} catch {
throw new TokenSettingApiError('토큰 설정 응답을 해석하지 못했습니다.', 502);
}
} finally {
window.clearTimeout(timeoutId);
}
}
async function requestTokenSettings<T>(init?: RequestInit, options?: TokenSettingsRequestOptions) {
try {
return await requestOnce<T>(API_BASE_URL, init, options);
} catch (error) {
const shouldRetryWithFallback =
FALLBACK_BASE_URL &&
FALLBACK_BASE_URL !== API_BASE_URL &&
(error instanceof TokenSettingApiError
? error.status === 404 || error.status === 408 || error.status === 502
: error instanceof Error && /404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message));
if (!shouldRetryWithFallback) {
throw error;
}
return requestOnce<T>(FALLBACK_BASE_URL, init, options);
}
}
async function loadTokenSettingsFromServer(options?: TokenSettingsRequestOptions) {
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] | null }>({
method: 'GET',
}, options);
return sanitizeTokenSettings(response.tokenSettings);
}
async function saveTokenSettingsToServer(items: TokenSettingRecord[], options?: TokenSettingsRequestOptions) {
const resolved = sanitizeTokenSettings(items);
const response = await requestTokenSettings<{ ok: boolean; tokenSettings: Partial<TokenSettingRecord>[] }>({
method: 'PUT',
body: JSON.stringify({ tokenSettings: resolved }),
}, options);
return sanitizeTokenSettings(response.tokenSettings);
}
export function upsertTokenSetting(items: TokenSettingRecord[], input: TokenSettingInput) {
const nextItem = normalizeTokenSetting({
...input,
id: input.id,
});
if (!nextItem) {
return sanitizeTokenSettings(items);
}
const originalId = normalizeSettingId(input.originalId);
const nextItems = items.filter((item) => item.id !== nextItem.id && item.id !== originalId);
nextItems.push(nextItem);
return sanitizeTokenSettings(nextItems);
}
export function deleteTokenSetting(items: TokenSettingRecord[], tokenSettingId: string) {
return sanitizeTokenSettings(items.filter((item) => item.id !== tokenSettingId));
}
export function useTokenSettingRegistry(enabled = true, options?: TokenSettingsRequestOptions) {
const [tokenSettings, setTokenSettingsState] = useState<TokenSettingRecord[]>([]);
const [isLoading, setIsLoading] = useState(enabled);
const [errorMessage, setErrorMessage] = useState('');
const loadVersionRef = useRef(0);
const shareToken = options?.shareToken?.trim() ?? '';
useEffect(() => {
if (!enabled) {
setTokenSettingsState([]);
setIsLoading(false);
setErrorMessage('');
return undefined;
}
let mounted = true;
const load = async () => {
const loadVersion = ++loadVersionRef.current;
setIsLoading(true);
setErrorMessage('');
try {
const nextItems = await loadTokenSettingsFromServer({ shareToken });
if (!mounted || loadVersion !== loadVersionRef.current) {
return;
}
setTokenSettingsState(nextItems);
} catch (error) {
if (!mounted || loadVersion !== loadVersionRef.current) {
return;
}
setErrorMessage(error instanceof Error ? error.message : '토큰 설정을 불러오지 못했습니다.');
} finally {
if (mounted && loadVersion === loadVersionRef.current) {
setIsLoading(false);
}
}
};
void load();
const handleSync = () => {
void load();
};
window.addEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
return () => {
mounted = false;
window.removeEventListener(TOKEN_SETTINGS_SYNC_EVENT, handleSync);
};
}, [enabled, shareToken]);
const setTokenSettings = async (items: TokenSettingRecord[]) => {
const savedItems = await saveTokenSettingsToServer(items, { shareToken });
setTokenSettingsState(savedItems);
emitTokenSettingsChange();
return savedItems;
};
return {
tokenSettings,
setTokenSettings,
isLoading,
errorMessage,
};
}

View File

@@ -26,6 +26,7 @@ export type MainHeaderProps = {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onToggleContentExpanded: () => void; onToggleContentExpanded: () => void;
onChangeTopMenu: (menu: HeaderTopMenuKey) => void; onChangeTopMenu: (menu: HeaderTopMenuKey) => void;
onOpenSearch: () => void;
onOpenPlanQuickFilter: (filter: PlanQuickFilter) => void; onOpenPlanQuickFilter: (filter: PlanQuickFilter) => void;
}; };

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