feat: update main chat and system chat UI
This commit is contained in:
14
src/App.tsx
14
src/App.tsx
@@ -26,13 +26,17 @@ function retryChunkLoadOnce(errorMessage: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { MainLayout } from './layout/MainLayout';
|
||||
import { ApisPage } from './pages/ApisPage';
|
||||
import { ChatPage } from './pages/ChatPage';
|
||||
import { ChatSharePage } from './pages/ChatSharePage';
|
||||
import { DocsPage } from './pages/DocsPage';
|
||||
import { PlansPage } from './pages/PlansPage';
|
||||
import { PlayPage } from './pages/PlayPage';
|
||||
@@ -10,6 +11,8 @@ import { buildChatPath, buildDocsPath } from './routes';
|
||||
export function AppShell() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/chat-share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/chat/share/:token" element={<ChatSharePage />} />
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
|
||||
<Route path="docs/:folder" element={<DocsPage />} />
|
||||
@@ -17,6 +20,8 @@ export function AppShell() {
|
||||
<Route path="plans/:section" element={<PlansPage />} />
|
||||
<Route path="chat/:section" element={<ChatPage />} />
|
||||
<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/cbt" element={<PlayPage />} />
|
||||
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Space, Switch, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
deleteAutomationContext,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
upsertAutomationContext,
|
||||
useAutomationContextRegistry,
|
||||
} from './automationContextAccess';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './AutomationContextManagementPage.css';
|
||||
|
||||
@@ -51,6 +52,8 @@ export function AutomationContextManagementPage() {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<AutomationContextFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
|
||||
const selectedAutomationContext = useMemo(
|
||||
() => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null,
|
||||
@@ -67,12 +70,20 @@ export function AutomationContextManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
lastHydratedFormKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormKey = isCreating ? '__create__' : selectedAutomationContext?.id ?? '__empty__';
|
||||
|
||||
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHydratedFormKeyRef.current = nextFormKey;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext));
|
||||
}, [detailMode, form, isCreating, selectedAutomationContext]);
|
||||
}, [detailMode, form, isCreating, selectedAutomationContext?.id]);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
@@ -98,7 +109,14 @@ export function AutomationContextManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: `"${selectedAutomationContext.title}" Context를 삭제할까요?`,
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,19 +140,23 @@ export function AutomationContextManagementPage() {
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="Context 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Card title="Context 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 Context를 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat-type-management-page${detailMode === 'detail' ? ' chat-type-management-page--detail' : ''}`}>
|
||||
{modalContextHolder}
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="Context 관리"
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
deleteAutomationType,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AutomationBehaviorType,
|
||||
type AutomationTypeRecord,
|
||||
} from './automationTypeAccess';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './AutomationTypeManagementPage.css';
|
||||
|
||||
@@ -63,6 +64,8 @@ export function AutomationTypeManagementPage() {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<AutomationTypeFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
|
||||
const selectedAutomationType = useMemo(
|
||||
@@ -80,12 +83,20 @@ export function AutomationTypeManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
lastHydratedFormKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormKey = isCreating ? '__create__' : selectedAutomationType?.id ?? '__empty__';
|
||||
|
||||
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHydratedFormKeyRef.current = nextFormKey;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType));
|
||||
}, [detailMode, form, isCreating, selectedAutomationType]);
|
||||
}, [detailMode, form, isCreating, selectedAutomationType?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -150,7 +161,14 @@ export function AutomationTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: `"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`,
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -209,14 +227,17 @@ export function AutomationTypeManagementPage() {
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Card title="자동화 유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 자동화 처리 유형을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,6 +247,7 @@ export function AutomationTypeManagementPage() {
|
||||
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
|
||||
>
|
||||
{modalContextHolder}
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="자동화 유형 관리"
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
ShrinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, List, Modal, Segmented, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
useChatContextSettingsRegistry,
|
||||
type ChatDefaultContextRecord,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatDefaultContextManagementPage.css';
|
||||
|
||||
@@ -78,6 +79,8 @@ export function ChatDefaultContextManagementPage() {
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [form] = Form.useForm<ChatDefaultContextFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
|
||||
const selectedContext = useMemo(
|
||||
() => defaultContexts.find((item) => item.id === selectedContextId) ?? null,
|
||||
@@ -113,12 +116,20 @@ export function ChatDefaultContextManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
lastHydratedFormKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormKey = isCreating ? '__create__' : selectedContext?.id ?? '__empty__';
|
||||
|
||||
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHydratedFormKeyRef.current = nextFormKey;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedContext));
|
||||
}, [detailMode, form, isCreating, selectedContext]);
|
||||
}, [detailMode, form, isCreating, selectedContext?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -227,7 +238,14 @@ export function ChatDefaultContextManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedContext.title}" 공통 문맥을 삭제할까요?`)) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: `"${selectedContext.title}" 공통 문맥을 삭제할까요?`,
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -249,14 +267,17 @@ export function ChatDefaultContextManagementPage() {
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="공통 문맥 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공통 문맥을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Card title="공통 문맥 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 공통 문맥을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -268,6 +289,7 @@ export function ChatDefaultContextManagementPage() {
|
||||
isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''
|
||||
}`}
|
||||
>
|
||||
{modalContextHolder}
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="공통 문맥 관리"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAppConfig } from './appConfig';
|
||||
import { isPreviewRuntime } from './previewRuntime';
|
||||
import { getOrCreateClientId } from './clientIdentity';
|
||||
@@ -10,9 +9,9 @@ import {
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import { chatGateway } from './chatV2';
|
||||
import { resolveChatPathForSession } from './isolatedChatRooms';
|
||||
import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types';
|
||||
|
||||
const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000;
|
||||
const MAX_NOTIFICATION_DETAIL_POLLS = 3;
|
||||
|
||||
function createConversationPreviewText(text: string) {
|
||||
@@ -60,7 +59,7 @@ function buildChatNotificationLink(sessionId: string) {
|
||||
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('sessionId', normalizedSessionId);
|
||||
return targetUrl.toString();
|
||||
@@ -199,229 +198,10 @@ function selectNotificationPollingCandidates<
|
||||
|
||||
export function ChatNotificationBridgeV2() {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { fetchServerCommands } from '../../features/serverCommand/api';
|
||||
import type { ServerCommandItem } from '../../features/serverCommand/types';
|
||||
import { CodexDiffBlock, parseCodexDiffSections } from '../../components/previewer';
|
||||
import { fetchChatSourceChanges } from './mainChatPanel';
|
||||
import { ChatPromptCard } from './mainChatPanel/ChatPromptCard';
|
||||
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) {
|
||||
return value
|
||||
.trim()
|
||||
@@ -247,9 +259,18 @@ export function ChatSourceChangesPage() {
|
||||
setSelectedEntryId(null);
|
||||
|
||||
try {
|
||||
const serverCommands = await fetchServerCommands();
|
||||
const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null;
|
||||
const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null;
|
||||
let testServerCommand: ServerCommandItem | null = null;
|
||||
let nextLatestTestServerBuiltAt: string | null = 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);
|
||||
|
||||
if (cancelled) {
|
||||
@@ -796,17 +817,19 @@ export function ChatSourceChangesPage() {
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={`변경 소스 / diff (${selectedEntry.diffBlocks.length})`}>
|
||||
{selectedEntry.diffBlocks.length ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{selectedEntry.diffBlocks.map((block, index) => (
|
||||
<pre key={`diff:${selectedEntry.id}:${index}`} className="chat-source-changes-page__diff">
|
||||
<code>{block}</code>
|
||||
</pre>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">추출된 diff 블록이 없습니다. 설명과 파일 목록만 확인할 수 있습니다.</Text>
|
||||
)}
|
||||
{(() => {
|
||||
if (!selectedEntry.diffBlocks.length) {
|
||||
return <Text type="secondary">추출된 diff 블록이 없습니다. 설명과 파일 목록만 확인할 수 있습니다.</Text>;
|
||||
}
|
||||
|
||||
const combinedDiff = combineCodexDiffBlocks(selectedEntry.diffBlocks);
|
||||
return (
|
||||
<CodexDiffBlock
|
||||
diffText={combinedDiff.diffText}
|
||||
summary={`파일 ${combinedDiff.fileCount}개 diff preview`}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
|
||||
@@ -25,17 +25,22 @@ import {
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Modal,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MarkdownPreviewContent } from '../../components/markdownPreview';
|
||||
import {
|
||||
canUseChatType,
|
||||
CHAT_PERMISSION_ROLE_LABELS,
|
||||
createDefaultChatTypeExecutionPolicy,
|
||||
deleteChatType,
|
||||
resolveCurrentChatPermissionRoles,
|
||||
upsertChatType,
|
||||
useChatTypeRegistry,
|
||||
type ChatPermissionRole,
|
||||
type ChatTypeExecutionMode,
|
||||
type ChatTypeExecutionPolicy,
|
||||
type ChatTypeRecord,
|
||||
} from './chatTypeAccess';
|
||||
import {
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
upsertChatTypeDefaultContextSelection,
|
||||
useChatContextSettingsRegistry,
|
||||
} from './chatContextSettingsAccess';
|
||||
import { confirmWithKeyboard } from './modalKeyboard';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
|
||||
@@ -53,6 +59,7 @@ type ChatTypeFormValue = {
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
executionPolicy: ChatTypeExecutionPolicy;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
};
|
||||
@@ -61,6 +68,7 @@ const EMPTY_FORM_VALUE: ChatTypeFormValue = {
|
||||
name: '',
|
||||
sortOrder: 1,
|
||||
description: '',
|
||||
executionPolicy: createDefaultChatTypeExecutionPolicy(),
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
};
|
||||
@@ -75,11 +83,24 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
name: chatType.name,
|
||||
sortOrder: chatType.sortOrder,
|
||||
description: chatType.description,
|
||||
executionPolicy: chatType.executionPolicy,
|
||||
permissions: chatType.permissions,
|
||||
enabled: chatType.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExecutionPolicyModeLabel(mode: ChatTypeExecutionMode) {
|
||||
if (mode === 'summary-free-talking') {
|
||||
return '회의 기록자 + 프리토킹';
|
||||
}
|
||||
|
||||
if (mode === 'dispatcher-workers') {
|
||||
return '중계 지시자 + 실작업자';
|
||||
}
|
||||
|
||||
return '직접 구성';
|
||||
}
|
||||
|
||||
export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry();
|
||||
@@ -101,6 +122,8 @@ export function ChatTypeManagementPage() {
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState<string[]>([]);
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const lastHydratedFormKeyRef = useRef('');
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
const isPaneMaximized = maximizedPane !== 'none';
|
||||
const builtInChatTypes: ChatTypeRecord[] = [];
|
||||
@@ -125,9 +148,17 @@ export function ChatTypeManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
lastHydratedFormKeyRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormKey = isCreating ? '__create__' : selectedChatType?.id ?? '__empty__';
|
||||
|
||||
if (lastHydratedFormKeyRef.current === nextFormKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHydratedFormKeyRef.current = nextFormKey;
|
||||
form.resetFields();
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
|
||||
setSelectedDefaultContextIds(
|
||||
@@ -137,7 +168,7 @@ export function ChatTypeManagementPage() {
|
||||
defaultContexts.some((context) => context.id === contextId && context.enabled),
|
||||
),
|
||||
);
|
||||
}, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]);
|
||||
}, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
@@ -221,7 +252,14 @@ export function ChatTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) {
|
||||
const confirmed = await confirmWithKeyboard(modalApi, {
|
||||
title: `"${selectedChatType.name}" 요청 유형을 삭제할까요?`,
|
||||
okText: '삭제',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -351,14 +389,17 @@ export function ChatTypeManagementPage() {
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="채팅유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Card title="채팅유형 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 채팅유형과 권한을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -368,6 +409,7 @@ export function ChatTypeManagementPage() {
|
||||
isPaneMaximized ? ' chat-type-management-page--pane-maximized' : ''
|
||||
}${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`}
|
||||
>
|
||||
{modalContextHolder}
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="채팅유형 관리"
|
||||
@@ -480,6 +522,7 @@ export function ChatTypeManagementPage() {
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
<Tag color="purple">{resolveExecutionPolicyModeLabel(item.executionPolicy.mode)}</Tag>
|
||||
{linkedDefaultContexts.map((context) => (
|
||||
<Tag key={`${item.id}-${context.id}`} color="gold">
|
||||
{context.title}
|
||||
@@ -611,6 +654,75 @@ export function ChatTypeManagementPage() {
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
</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 ? (
|
||||
<Segmented
|
||||
className="chat-type-management-page__mobile-toggle"
|
||||
|
||||
@@ -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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -142,6 +175,18 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 12px 12px 34px;
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
|
||||
<Badge count={unreadCount} size="small" offset={[-2, 4]} className="header-message-center__badge">
|
||||
<Button
|
||||
type="text"
|
||||
className="header-message-center__trigger"
|
||||
|
||||
@@ -212,6 +212,28 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -219,6 +241,8 @@
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-chat-panel__context-drawer-tabs {
|
||||
@@ -227,10 +251,12 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__context-drawer-tabs .ant-tabs-nav {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__context-drawer-tabs .ant-tabs-content-holder,
|
||||
@@ -240,6 +266,7 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-active {
|
||||
@@ -248,6 +275,7 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__context-drawer-tabs .ant-tabs-tabpane-hidden {
|
||||
@@ -259,6 +287,8 @@
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
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-checkbox {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 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 {
|
||||
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 {
|
||||
padding-left: 24px;
|
||||
}
|
||||
@@ -327,6 +413,18 @@
|
||||
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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -918,10 +1016,23 @@
|
||||
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 {
|
||||
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 {
|
||||
padding: 8px 0;
|
||||
}
|
||||
@@ -942,4 +1053,12 @@
|
||||
.app-chat-panel__context-drawer-textarea {
|
||||
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
@@ -15,10 +15,15 @@ import { AutomationTypeManagementPage } from './AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from './AutomationContextManagementPage';
|
||||
import { ChatDefaultContextManagementPage } from './ChatDefaultContextManagementPage';
|
||||
import { ResourceManagementPage } from './ResourceManagementPage';
|
||||
import { SharedChatManagementPage } from './SharedChatManagementPage';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
import { SystemChatPanel } from './SystemChatPanel';
|
||||
import { PlayAppOverlay } from './PlayAppOverlay';
|
||||
import { PreviewAppOverlay } from './PreviewAppOverlay';
|
||||
import { SharedResourceManagementPage } from './SharedResourceManagementPage';
|
||||
import { TokenSettingManagementPage } from './TokenSettingManagementPage';
|
||||
import type { PreviewTargetDescriptor } from './previewRuntime';
|
||||
import { useMainLayoutContext } from './layout/MainLayoutContext';
|
||||
import { buildPlayPath } from './routes';
|
||||
@@ -49,6 +54,7 @@ function parseWidgetSelectionId(selectionId: string): PreviewTargetDescriptor {
|
||||
}
|
||||
|
||||
const SAVED_LAYOUT_WINDOW_SELECTION_PREFIX = 'page:play:layout-record:';
|
||||
const PLAY_APP_SELECTION_PREFIX = 'page:play:app:';
|
||||
|
||||
function parseSavedLayoutSelectionId(selectionId: string) {
|
||||
return selectionId.startsWith(SAVED_LAYOUT_WINDOW_SELECTION_PREFIX)
|
||||
@@ -56,6 +62,10 @@ function parseSavedLayoutSelectionId(selectionId: string) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function parsePlayAppSelectionId(selectionId: string) {
|
||||
return selectionId.startsWith(PLAY_APP_SELECTION_PREFIX) ? selectionId.slice(PLAY_APP_SELECTION_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function MainContent({
|
||||
contentExpanded,
|
||||
sidebarOverlayActive = false,
|
||||
@@ -93,8 +103,15 @@ export function MainContent({
|
||||
() => windowSelections.find((selection) => selection.id === 'page:preview:app') ?? null,
|
||||
[windowSelections],
|
||||
);
|
||||
const playAppSelection = useMemo(
|
||||
() => windowSelections.find((selection) => selection.id.startsWith(PLAY_APP_SELECTION_PREFIX)) ?? null,
|
||||
[windowSelections],
|
||||
);
|
||||
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],
|
||||
);
|
||||
const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame =>
|
||||
@@ -231,6 +248,14 @@ export function MainContent({
|
||||
return <AutomationContextManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:token-setting') {
|
||||
return <TokenSettingManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:shared-resource') {
|
||||
return <SharedResourceManagementPage />;
|
||||
}
|
||||
|
||||
const planStatus = getPlanStatusFromWindowSelection(selectionId);
|
||||
|
||||
if (planStatus) {
|
||||
@@ -248,6 +273,10 @@ export function MainContent({
|
||||
return <MainChatPanel initialView="live" />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:rooms') {
|
||||
return <SystemChatPanel />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:errors') {
|
||||
return <MainChatPanel initialView="errors" />;
|
||||
}
|
||||
@@ -267,6 +296,11 @@ export function MainContent({
|
||||
if (selectionId === 'page:chat:manage-defaults') {
|
||||
return <ChatDefaultContextManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:manage-share') {
|
||||
return <SharedChatManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:play:layout') {
|
||||
return <LayoutPlaygroundView />;
|
||||
}
|
||||
@@ -303,6 +337,7 @@ export function MainContent({
|
||||
{!disableWindowLayer && previewAppSelection ? (
|
||||
<div className="app-main-preview-layer">
|
||||
<PreviewAppOverlay
|
||||
key={previewAppSelection.instanceId}
|
||||
pathname={buildPlayPath('cbt')}
|
||||
onClose={() => {
|
||||
clearWindowSelection(previewAppSelection.instanceId);
|
||||
@@ -310,6 +345,18 @@ export function MainContent({
|
||||
/>
|
||||
</div>
|
||||
) : 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 ? (
|
||||
<div className="app-main-window-layer">
|
||||
<div ref={stageRef} className="app-main-window-layer__stage">
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import {
|
||||
ApiOutlined,
|
||||
BellOutlined,
|
||||
BgColorsOutlined,
|
||||
BookOutlined,
|
||||
ClockCircleOutlined,
|
||||
CopyOutlined,
|
||||
ClusterOutlined,
|
||||
DownOutlined,
|
||||
FileMarkdownOutlined,
|
||||
LoadingOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
ProfileOutlined,
|
||||
ReloadOutlined,
|
||||
RocketOutlined,
|
||||
RightOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Drawer,
|
||||
Grid,
|
||||
Input,
|
||||
InputNumber,
|
||||
@@ -72,6 +76,7 @@ import {
|
||||
} from './tokenAccess';
|
||||
import { isPreviewRuntime } from './previewRuntime';
|
||||
import { chatConnectionGateway, chatGateway } from './chatV2';
|
||||
import { emitChatConversationCleared, emitChatConversationsUpdated } from './chatV2/data/chatClientEvents';
|
||||
import { HeaderMessageCenter } from './HeaderMessageCenter';
|
||||
import { fetchChatRuntimeJobDetail } from './mainChatPanel';
|
||||
import {
|
||||
@@ -92,6 +97,7 @@ const APP_SETTINGS_CATEGORIES = [
|
||||
|
||||
const APP_SETTINGS_SECTIONS: Array<{
|
||||
value:
|
||||
| 'headerAppearance'
|
||||
| 'chatSettings'
|
||||
| 'automationRuntime'
|
||||
| 'planDefaults'
|
||||
@@ -102,6 +108,7 @@ const APP_SETTINGS_SECTIONS: Array<{
|
||||
label: string;
|
||||
category: (typeof APP_SETTINGS_CATEGORIES)[number]['value'];
|
||||
}> = [
|
||||
{ value: 'headerAppearance', label: '헤더 표시', category: 'workspace' },
|
||||
{ value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' },
|
||||
{ value: 'automationRuntime', 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_TIMEOUT_MS = 90_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']) {
|
||||
return (
|
||||
@@ -580,7 +669,7 @@ function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draf
|
||||
}
|
||||
|
||||
if (saved.openWindowSearch !== draft.openWindowSearch) {
|
||||
changedLabels.push('Window UI 검색 열기');
|
||||
changedLabels.push('시스템 채팅 열기');
|
||||
}
|
||||
|
||||
return changedLabels;
|
||||
@@ -1511,6 +1600,7 @@ export function MainHeader({
|
||||
onToggleSidebar,
|
||||
onToggleContentExpanded,
|
||||
onChangeTopMenu,
|
||||
onOpenSearch,
|
||||
onOpenPlanQuickFilter,
|
||||
}: MainHeaderProps) {
|
||||
void contentExpanded;
|
||||
@@ -1520,6 +1610,9 @@ export function MainHeader({
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const navigate = useNavigate();
|
||||
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 [automationGroupExpanded, setAutomationGroupExpanded] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
@@ -1575,6 +1668,33 @@ export function MainHeader({
|
||||
const serverRestartReservationReloadTaskIdRef = useRef(0);
|
||||
const { registeredToken, hasAccess } = useTokenAccess();
|
||||
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 [appConfigFeedback, setAppConfigFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [appConfigSaving, setAppConfigSaving] = useState(false);
|
||||
@@ -1692,55 +1812,44 @@ export function MainHeader({
|
||||
serverRestartReservationNowTimestamp,
|
||||
serverRestartReservationReloadPending,
|
||||
);
|
||||
const renderTopMenuOptionLabel = (menu: 'docs' | 'plans' | 'play', label: ReactNode, iconLabel: string) => (
|
||||
const renderTopMenuOptionLabel = (
|
||||
menu: 'docs' | 'plans' | 'play',
|
||||
label: ReactNode,
|
||||
icon: ReactNode,
|
||||
iconLabel: string,
|
||||
) => (
|
||||
<span
|
||||
className="app-header__menu-option"
|
||||
aria-label={iconLabel}
|
||||
onClick={() => {
|
||||
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>
|
||||
);
|
||||
const headerTopMenuOptions = hasAccess
|
||||
? [
|
||||
{
|
||||
label: renderTopMenuOptionLabel(
|
||||
'docs',
|
||||
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
|
||||
'Docs',
|
||||
),
|
||||
label: renderTopMenuOptionLabel('docs', 'Docs', <BookOutlined />, 'Docs'),
|
||||
value: 'docs',
|
||||
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
|
||||
},
|
||||
{
|
||||
label: renderTopMenuOptionLabel(
|
||||
'plans',
|
||||
isMobileViewport ? <ProfileOutlined /> : '작업',
|
||||
'작업',
|
||||
),
|
||||
label: renderTopMenuOptionLabel('plans', '작업', <ClusterOutlined />, '작업'),
|
||||
value: 'plans',
|
||||
icon: isMobileViewport ? undefined : <ProfileOutlined />,
|
||||
},
|
||||
{
|
||||
label: renderTopMenuOptionLabel(
|
||||
'play',
|
||||
isMobileViewport ? <ApiOutlined /> : 'Play',
|
||||
'Play',
|
||||
),
|
||||
label: renderTopMenuOptionLabel('play', 'Play', <RocketOutlined />, 'Play'),
|
||||
value: 'play',
|
||||
icon: isMobileViewport ? undefined : <ApiOutlined />,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: renderTopMenuOptionLabel(
|
||||
'docs',
|
||||
isMobileViewport ? <FileMarkdownOutlined /> : 'Docs',
|
||||
'Docs',
|
||||
),
|
||||
label: renderTopMenuOptionLabel('docs', 'Docs', <BookOutlined />, 'Docs'),
|
||||
value: 'docs',
|
||||
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
|
||||
},
|
||||
];
|
||||
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
|
||||
@@ -1765,6 +1874,7 @@ export function MainHeader({
|
||||
}
|
||||
|
||||
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}${
|
||||
hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : ''
|
||||
}`;
|
||||
@@ -2742,7 +2852,8 @@ export function MainHeader({
|
||||
'재기동 요청 완료',
|
||||
`${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)) {
|
||||
setServerRestartFeedback({
|
||||
@@ -3030,7 +3141,7 @@ export function MainHeader({
|
||||
const handleResetNotificationIdentity = () => {
|
||||
modalApi.confirm({
|
||||
title: '알림 클라이언트 초기화',
|
||||
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
||||
content: '현재 브라우저의 알림 토큰/디바이스 식별자를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
||||
okText: '초기화',
|
||||
cancelText: '취소',
|
||||
autoFocusButton: 'ok',
|
||||
@@ -3111,33 +3222,40 @@ export function MainHeader({
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCopyFeedbackMessage = () => {
|
||||
void copyTextToClipboard(feedback.message)
|
||||
.then(() => {
|
||||
setCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
showIcon
|
||||
type={feedback.tone}
|
||||
message={feedback.message}
|
||||
action={
|
||||
action={!screens.xs ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="메시지 복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(feedback.message)
|
||||
.then(() => {
|
||||
setCopyFeedback({
|
||||
tone: 'success',
|
||||
message: '메시지를 복사했습니다.',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
|
||||
});
|
||||
}}
|
||||
onClick={handleCopyFeedbackMessage}
|
||||
/>
|
||||
}
|
||||
) : null}
|
||||
/>
|
||||
{screens.xs ? (
|
||||
<Button block icon={<CopyOutlined />} onClick={handleCopyFeedbackMessage}>
|
||||
메시지 복사
|
||||
</Button>
|
||||
) : null}
|
||||
{copyFeedback ? <Alert showIcon type={copyFeedback.tone} message={copyFeedback.message} /> : null}
|
||||
</Space>
|
||||
);
|
||||
@@ -3244,6 +3362,39 @@ export function MainHeader({
|
||||
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 = (
|
||||
<Button
|
||||
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 = (
|
||||
<Button
|
||||
type="text"
|
||||
@@ -4156,8 +4364,8 @@ export function MainHeader({
|
||||
}
|
||||
description={
|
||||
gestureShortcutSettingsDirty
|
||||
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, Window UI 검색 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
|
||||
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch}`
|
||||
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
|
||||
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch}`
|
||||
}
|
||||
/>
|
||||
{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 wrap>
|
||||
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
|
||||
@@ -4235,6 +4443,74 @@ export function MainHeader({
|
||||
</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 = (
|
||||
<div className="app-header__settings-menu">
|
||||
{hasAccess ? (
|
||||
@@ -4352,9 +4628,9 @@ export function MainHeader({
|
||||
className="app-header__settings-item"
|
||||
disabled={!hasAccess}
|
||||
onClick={() => {
|
||||
openSettingsModal('appSettings', 'planDefaults');
|
||||
openSettingsModal('appSettings', 'headerAppearance');
|
||||
}}
|
||||
>
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
<SettingOutlined />
|
||||
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
|
||||
@@ -4475,6 +4751,13 @@ export function MainHeader({
|
||||
</Space>
|
||||
|
||||
<Space size={4} className="app-header__actions">
|
||||
<Button
|
||||
type="text"
|
||||
aria-label={`통합 검색 열기 · ${searchShortcutLabel}`}
|
||||
title={isMobileViewport ? undefined : `통합 검색 열기 (${searchShortcutLabel})`}
|
||||
icon={<SearchOutlined />}
|
||||
onClick={onOpenSearch}
|
||||
/>
|
||||
{hasAccess ? (
|
||||
<>
|
||||
<button
|
||||
@@ -4763,6 +5046,7 @@ export function MainHeader({
|
||||
{activeAppSettingsSection === 'automationRuntime' ? automationRuntimePanel : null}
|
||||
{activeAppSettingsSection === 'planDefaults' ? planDefaultsPanel : null}
|
||||
{activeAppSettingsSection === 'planCost' ? planCostPanel : null}
|
||||
{activeAppSettingsSection === 'headerAppearance' ? headerAppearancePanel : null}
|
||||
{activeAppSettingsSection === 'chatSettings' ? chatSettingsPanel : null}
|
||||
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
||||
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,12 +43,12 @@ export function MainSidebar({
|
||||
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']);
|
||||
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']);
|
||||
return;
|
||||
}
|
||||
|
||||
91
src/app/main/PlayAppOverlay.tsx
Normal file
91
src/app/main/PlayAppOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
@@ -9,7 +18,13 @@ import {
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
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 = {
|
||||
pathname: string;
|
||||
@@ -23,16 +38,48 @@ type DragPosition = {
|
||||
y: number;
|
||||
};
|
||||
|
||||
type DetachedConsoleSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const HEADER_HEIGHT = 44;
|
||||
const MINIMIZED_WIDTH = 168;
|
||||
const MOBILE_SHELL_WIDTH = 430;
|
||||
const MOBILE_SHELL_HEIGHT = 860;
|
||||
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) {
|
||||
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 {
|
||||
if (typeof window === 'undefined') {
|
||||
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({
|
||||
pathname,
|
||||
search = '',
|
||||
targetDescriptor = null,
|
||||
onClose,
|
||||
}: PreviewAppOverlayProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const minimizedPositionRef = useRef<DragPosition>(getDefaultMinimizedPosition());
|
||||
const mobileShellPositionRef = useRef<DragPosition>(getDefaultMobileShellPosition());
|
||||
const detachedConsolePositionRef = useRef<DragPosition>(getDefaultDetachedConsolePosition());
|
||||
const detachedConsoleSizeRef = useRef<DetachedConsoleSize>(getDefaultDetachedConsoleSize());
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const consoleBodyRef = useRef<HTMLDivElement>(null);
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
lastX: number;
|
||||
lastY: number;
|
||||
captureTarget: HTMLDivElement;
|
||||
} | 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 [minimized, setMinimized] = useState(false);
|
||||
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(() =>
|
||||
typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false,
|
||||
);
|
||||
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 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(() => {
|
||||
document.body.classList.add('preview-app-overlay-open');
|
||||
|
||||
return () => {
|
||||
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(() => {
|
||||
if (!minimized && !isDesktopMobileShell) {
|
||||
return;
|
||||
@@ -288,6 +460,214 @@ export function PreviewAppOverlay({
|
||||
}
|
||||
}, [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>) => {
|
||||
if (!minimized && !isDesktopMobileShell) {
|
||||
return;
|
||||
@@ -344,94 +724,417 @@ export function PreviewAppOverlay({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`preview-app-overlay${minimized ? ' preview-app-overlay--minimized' : ''}${
|
||||
isDesktopMobileShell ? ' preview-app-overlay--mobile-shell' : ''
|
||||
const handleDetachedConsolePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!showDetachedConsole || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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={
|
||||
minimized || isDesktopMobileShell
|
||||
detached
|
||||
? {
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: `${detachedConsoleSize.width}px`,
|
||||
height: `${detachedConsoleSize.height}px`,
|
||||
left: `${detachedConsolePosition.x}px`,
|
||||
top: `${detachedConsolePosition.y}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="preview-app-overlay__header"
|
||||
onClick={() => {
|
||||
if (minimized && !dragMovedRef.current) {
|
||||
setMinimized(false);
|
||||
}
|
||||
}}
|
||||
onPointerDown={handleHeaderPointerDown}
|
||||
onPointerUp={handleHeaderPointerUp}
|
||||
onPointerCancel={handleHeaderPointerUp}
|
||||
className={`preview-app-overlay__console-head${
|
||||
detached ? ' preview-app-overlay__console-head--detached' : ''
|
||||
}`}
|
||||
onPointerDown={detached ? handleDetachedConsolePointerDown : undefined}
|
||||
onPointerUp={detached ? handleDetachedConsolePointerUp : undefined}
|
||||
onPointerCancel={detached ? handleDetachedConsolePointerUp : undefined}
|
||||
>
|
||||
<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 className="preview-app-overlay__console-head-copy">
|
||||
<strong>{detached ? 'Console Detached' : 'Console'}</strong>
|
||||
<div className="preview-app-overlay__console-summary" aria-label="Preview 콘솔 요약">
|
||||
<span>로그 {consoleEntries.length}</span>
|
||||
<span>경고 {warnEntryCount}</span>
|
||||
<span>오류 {errorEntryCount}</span>
|
||||
</div>
|
||||
<div className="preview-app-overlay__console-location" title={latestConsoleHref || 'Preview URL 확인 대기 중'}>
|
||||
{latestConsoleHref || 'Preview URL 확인 대기 중'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-app-overlay__actions">
|
||||
{!minimized && !isMobileViewport ? (
|
||||
<div
|
||||
className="preview-app-overlay__console-head-actions"
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{canDetachConsole ? (
|
||||
<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'));
|
||||
size="small"
|
||||
aria-label={detached ? '콘솔 오버레이에 붙이기' : '콘솔 분리하기'}
|
||||
onClick={() => {
|
||||
setConsoleDetached((current) => !current);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!minimized ? (
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="Preview 최소화"
|
||||
icon={<MinusOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
handleMinimizeToggle();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{detached ? 'Attach' : 'Detach'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
aria-label="Preview 닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={(event) => {
|
||||
handleActionButtonClick(event);
|
||||
dragMovedRef.current = false;
|
||||
onClose();
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
aria-label="Preview URL 복사"
|
||||
disabled={!latestConsoleHref}
|
||||
onClick={() => {
|
||||
if (!latestConsoleHref) {
|
||||
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 className={`preview-app-overlay__body${minimized ? ' preview-app-overlay__body--hidden' : ''}`}>
|
||||
<PreviewAppWindow
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
targetDescriptor={targetDescriptor}
|
||||
deviceMode={deviceMode}
|
||||
/>
|
||||
<div className="preview-app-overlay__console-filters" aria-label="Preview 콘솔 레벨 필터">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
className={`preview-app-overlay__console-filter${hasAllConsoleLevelsSelected ? ' is-active' : ''}`}
|
||||
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 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { forwardRef, useMemo } from 'react';
|
||||
import { getRegisteredAccessToken } from './tokenAccess';
|
||||
import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime';
|
||||
|
||||
@@ -7,14 +7,19 @@ type PreviewAppWindowProps = {
|
||||
search?: string;
|
||||
targetDescriptor?: PreviewTargetDescriptor;
|
||||
deviceMode?: 'desktop' | 'mobile';
|
||||
reloadKey?: number;
|
||||
};
|
||||
|
||||
export function PreviewAppWindow({
|
||||
pathname,
|
||||
search = '',
|
||||
targetDescriptor = null,
|
||||
deviceMode = 'desktop',
|
||||
}: PreviewAppWindowProps) {
|
||||
export const PreviewAppWindow = forwardRef<HTMLIFrameElement, PreviewAppWindowProps>(function PreviewAppWindow(
|
||||
{
|
||||
pathname,
|
||||
search = '',
|
||||
targetDescriptor = null,
|
||||
deviceMode = 'desktop',
|
||||
reloadKey = 0,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const previewUrl = useMemo(
|
||||
() => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode),
|
||||
[deviceMode, pathname, search, targetDescriptor],
|
||||
@@ -27,6 +32,8 @@ export function PreviewAppWindow({
|
||||
>
|
||||
<div className={`preview-app-window__viewport preview-app-window__viewport--${deviceMode}`}>
|
||||
<iframe
|
||||
key={`${previewUrl}::${reloadKey}`}
|
||||
ref={ref}
|
||||
title="Preview App"
|
||||
src={previewUrl}
|
||||
className="preview-app-window__frame"
|
||||
@@ -36,4 +43,4 @@ export function PreviewAppWindow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -500,6 +500,10 @@ function collectExpandedKeys(treeRoot: ResourceManagerTreeRoot | null) {
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectCollapsedKeys() {
|
||||
return ['/'] as Key[];
|
||||
}
|
||||
|
||||
function findTreeNode(treeRoot: ResourceManagerTreeRoot | null, targetPath: string) {
|
||||
if (!treeRoot) {
|
||||
return null;
|
||||
@@ -803,7 +807,7 @@ export function ResourceManagementPage() {
|
||||
const previewTouchGestureRef = useRef<PreviewTouchGestureState | null>(null);
|
||||
const previewShellRef = useRef<HTMLDivElement | 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 [selectedDirectoryPath, setSelectedDirectoryPath] = useState('');
|
||||
const [selectedListPath, setSelectedListPath] = useState('');
|
||||
@@ -1144,7 +1148,7 @@ export function ResourceManagementPage() {
|
||||
try {
|
||||
const nextTree = await fetchResourceManagerTree();
|
||||
setTreeRoot(nextTree);
|
||||
setExpandedKeys((current) => (preserveExpandedKeys && current.length > 0 ? current : collectExpandedKeys(nextTree)));
|
||||
setExpandedKeys((current) => (preserveExpandedKeys && current.length > 0 ? current : collectCollapsedKeys()));
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : '리소스 트리를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
@@ -2379,7 +2383,7 @@ export function ResourceManagementPage() {
|
||||
size="small"
|
||||
icon={<MinusSquareOutlined />}
|
||||
aria-label="전체 접기"
|
||||
onClick={() => setExpandedKeys(['/'])}
|
||||
onClick={() => setExpandedKeys(collectCollapsedKeys())}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
245
src/app/main/ScopedChatRoomsWindow.css
Normal file
245
src/app/main/ScopedChatRoomsWindow.css
Normal 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));
|
||||
}
|
||||
}
|
||||
152
src/app/main/ScopedChatRoomsWindow.tsx
Normal file
152
src/app/main/ScopedChatRoomsWindow.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
29
src/app/main/SharedAppSettingsPage.css
Normal file
29
src/app/main/SharedAppSettingsPage.css
Normal 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;
|
||||
}
|
||||
252
src/app/main/SharedAppSettingsPage.tsx
Normal file
252
src/app/main/SharedAppSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
src/app/main/SharedChatManagementPage.css
Normal file
246
src/app/main/SharedChatManagementPage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
494
src/app/main/SharedChatManagementPage.tsx
Normal file
494
src/app/main/SharedChatManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
444
src/app/main/SharedResourceManagementPage.css
Normal file
444
src/app/main/SharedResourceManagementPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
1628
src/app/main/SharedResourceManagementPage.tsx
Normal file
1628
src/app/main/SharedResourceManagementPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1064
src/app/main/SystemChatPanel.css
Normal file
1064
src/app/main/SystemChatPanel.css
Normal file
File diff suppressed because it is too large
Load Diff
3
src/app/main/SystemChatPanel.hotfix.css
Normal file
3
src/app/main/SystemChatPanel.hotfix.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import './mainChatPanel/styles/MainChatPanel.layout.css';
|
||||
@import './mainChatPanel/styles/MainChatPanel.conversation.css';
|
||||
@import './mainChatPanel/styles/MainChatPanel.preview-runtime.css';
|
||||
9528
src/app/main/SystemChatPanel.tsx
Normal file
9528
src/app/main/SystemChatPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
135
src/app/main/TokenSettingManagementPage.css
Normal file
135
src/app/main/TokenSettingManagementPage.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
740
src/app/main/TokenSettingManagementPage.tsx
Normal file
740
src/app/main/TokenSettingManagementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -495,6 +495,11 @@ function resolveAppConfigFallbackBaseUrl() {
|
||||
const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
|
||||
const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
|
||||
|
||||
type AppConfigRequestOptions = {
|
||||
shareToken?: string | null;
|
||||
skipAutomationNotifications?: boolean;
|
||||
};
|
||||
|
||||
class AppConfigApiError extends Error {
|
||||
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 hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS);
|
||||
const shareToken = options?.shareToken?.trim() ?? '';
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
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);
|
||||
}
|
||||
|
||||
if (shareToken && !headers.has('X-Chat-Share-Token')) {
|
||||
headers.set('X-Chat-Share-Token', shareToken);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
@@ -566,9 +581,9 @@ async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: Req
|
||||
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 {
|
||||
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) {
|
||||
const shouldRetryWithFallback =
|
||||
APP_CONFIG_FALLBACK_BASE_URL &&
|
||||
@@ -581,15 +596,15 @@ async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T>
|
||||
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 {
|
||||
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 preference = await fetchAutomationNotificationPreferenceFromServer();
|
||||
const preference = options?.skipAutomationNotifications ? null : await fetchAutomationNotificationPreferenceFromServer();
|
||||
return mergeAutomationNotificationSettings(config, preference);
|
||||
} catch {
|
||||
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, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
}, options);
|
||||
|
||||
return normalizeConfig(response.config);
|
||||
}
|
||||
@@ -635,8 +650,8 @@ export async function saveAutomationNotificationPreferenceToServer(config: AppCo
|
||||
return mergeAutomationNotificationSettings(config, response.automation);
|
||||
}
|
||||
|
||||
export async function syncAppConfigFromServer() {
|
||||
const config = await fetchAppConfigFromServer();
|
||||
export async function syncAppConfigFromServer(options?: AppConfigRequestOptions) {
|
||||
const config = await fetchAppConfigFromServer(options);
|
||||
|
||||
if (!config) {
|
||||
return false;
|
||||
@@ -685,8 +700,12 @@ export function setStoredAppConfig(config: AppConfig) {
|
||||
const raw = JSON.stringify(normalized);
|
||||
cachedConfig = normalized;
|
||||
cachedRawConfig = raw;
|
||||
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
|
||||
storage.setItem(APP_CONFIG_STORAGE_KEY, raw);
|
||||
try {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
65
src/app/main/chatActionContextStore.ts
Normal file
65
src/app/main/chatActionContextStore.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,22 @@ export type ChatTypeDefaultContextSelection = {
|
||||
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 = {
|
||||
sessionId: string;
|
||||
defaultContextIds: string[];
|
||||
customContextTitle: string;
|
||||
customContextContent: string;
|
||||
codexParticipants: ChatRoomCodexParticipant[];
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -84,6 +95,48 @@ function normalizeDefaultContextIds(defaultContextIds: string[] | null | undefin
|
||||
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) {
|
||||
const byId = new Map<string, ChatDefaultContextRecord>();
|
||||
|
||||
@@ -169,13 +222,15 @@ function sanitizeRoomContexts(items: Partial<ChatRoomContextSettings>[] | null |
|
||||
defaultContextIds: normalizeDefaultContextIds(item.defaultContextIds),
|
||||
customContextTitle: normalizeText(item.customContextTitle),
|
||||
customContextContent: normalizeText(item.customContextContent),
|
||||
codexParticipants: sanitizeCodexParticipants(item.codexParticipants),
|
||||
updatedAt: normalizeText(item.updatedAt) || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const hasCustomContext = Boolean(nextRecord.customContextTitle || nextRecord.customContextContent);
|
||||
const hasDefaultOverrides = nextRecord.defaultContextIds.length > 0;
|
||||
const hasCodexParticipants = nextRecord.codexParticipants.length > 0;
|
||||
|
||||
if (!hasCustomContext && !hasDefaultOverrides) {
|
||||
if (!hasCustomContext && !hasDefaultOverrides && !hasCodexParticipants) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,6 +619,7 @@ export function upsertChatRoomContextSettings(
|
||||
defaultContextIds: normalizeDefaultContextIds(input.defaultContextIds),
|
||||
customContextTitle: normalizeText(input.customContextTitle),
|
||||
customContextContent: normalizeText(input.customContextContent),
|
||||
codexParticipants: sanitizeCodexParticipants(input.codexParticipants),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,29 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
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 = {
|
||||
id: string;
|
||||
name: string;
|
||||
sortOrder: number;
|
||||
description: string;
|
||||
executionPolicy: ChatTypeExecutionPolicy;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
@@ -22,6 +39,7 @@ export type ChatTypeInput = {
|
||||
name: string;
|
||||
sortOrder?: number;
|
||||
description?: string;
|
||||
executionPolicy?: Partial<ChatTypeExecutionPolicy>;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
@@ -53,6 +71,80 @@ function normalizePermissions(permissions: ChatPermissionRole[] | null | undefin
|
||||
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) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
@@ -82,6 +174,7 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): NormalizedChatTypeC
|
||||
name,
|
||||
sortOrder: normalizeSortOrder(record.sortOrder),
|
||||
description: normalizeText(record.description),
|
||||
executionPolicy: normalizeExecutionPolicy(record.executionPolicy),
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
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,
|
||||
sortOrder: input.sortOrder,
|
||||
description: input.description,
|
||||
executionPolicy: normalizeExecutionPolicy(input.executionPolicy),
|
||||
permissions: input.permissions,
|
||||
enabled: input.enabled,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
||||
@@ -7,10 +7,15 @@ export type DefaultChatTypeRecord = {
|
||||
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_NAME = '일반 요청';
|
||||
export const GENERAL_REQUEST_CHAT_TYPE_DESCRIPTION =
|
||||
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- 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_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는 사용하지 않습니다.';
|
||||
|
||||
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,
|
||||
name: GENERAL_REQUEST_CHAT_TYPE_NAME,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { shouldShowConversationForMode } from '../../isolatedChatRooms';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
||||
type ConversationListPaneProps = {
|
||||
items: ChatConversationSummary[];
|
||||
isLoading: boolean;
|
||||
@@ -24,13 +26,15 @@ export function ConversationListPane({
|
||||
onSelectSession,
|
||||
onCreateConversation,
|
||||
}: ConversationListPaneProps) {
|
||||
const visibleItems = items.filter((item) => shouldShowConversationForMode(item.sessionId, 'live'));
|
||||
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--list">
|
||||
<div className="chat-v2__pane-header">
|
||||
<div>
|
||||
<Text strong>채팅 목록</Text>
|
||||
<br />
|
||||
<Text type="secondary">{items.length}개 대화</Text>
|
||||
<Text type="secondary">{visibleItems.length}개 대화</Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={onCreateConversation}>
|
||||
새 채팅
|
||||
@@ -54,14 +58,14 @@ export function ConversationListPane({
|
||||
<div className="chat-v2__state">
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
) : visibleItems.length === 0 ? (
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="대화가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
className="chat-v2__conversation-list"
|
||||
dataSource={items}
|
||||
dataSource={visibleItems}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<button
|
||||
|
||||
@@ -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({
|
||||
sessionId,
|
||||
messages,
|
||||
@@ -29,20 +37,25 @@ export function ConversationRoomPane({
|
||||
loadingLabel,
|
||||
errorMessage,
|
||||
}: 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 composerRef = useRef<TextAreaRef | null>(null);
|
||||
const requestStateMap = useMemo(
|
||||
() => new Map(requests.map((request) => [request.requestId, request])),
|
||||
[requests],
|
||||
() => new Map(normalizedRequests.map((request) => [request.requestId, request])),
|
||||
[normalizedRequests],
|
||||
);
|
||||
const activeSystemStatus = errorMessage.trim() || (sessionId ? null : '대화방을 선택해 주세요.');
|
||||
const activeSystemStatus = normalizedErrorMessage.trim() || (normalizedSessionId ? null : '대화방을 선택해 주세요.');
|
||||
|
||||
return (
|
||||
<ChatConversationView
|
||||
sessionId={sessionId}
|
||||
sessionId={normalizedSessionId}
|
||||
viewportRef={viewportRef}
|
||||
composerRef={composerRef}
|
||||
visibleMessages={messages}
|
||||
visibleMessages={normalizedMessages}
|
||||
activeSystemStatus={activeSystemStatus}
|
||||
isSystemStatusPending={false}
|
||||
showScrollToBottom={false}
|
||||
@@ -50,20 +63,24 @@ export function ConversationRoomPane({
|
||||
draft=""
|
||||
draftVersion={0}
|
||||
composerAttachments={[]}
|
||||
lastReadResponseMessageId={null}
|
||||
requestStateMap={requestStateMap}
|
||||
isConversationLoading={isLoading}
|
||||
conversationLoadingLabel={loadingLabel}
|
||||
conversationLoadingLabel={normalizedLoadingLabel}
|
||||
hasOlderMessages={false}
|
||||
isLoadingOlderMessages={false}
|
||||
isPullToLoadArmed={false}
|
||||
pullToLoadDistance={0}
|
||||
selectedChatTypeId="general-request"
|
||||
selectedCodexModel="gpt-5.4"
|
||||
queuedRequests={[]}
|
||||
chatTypeOptions={GENERAL_REQUEST_OPTION}
|
||||
codexModelOptions={GENERAL_CODEX_MODEL_OPTION}
|
||||
previewItems={[]}
|
||||
isResourceStripOpen={false}
|
||||
isComposerDisabled={true}
|
||||
isMobileViewport={false}
|
||||
isIpadLikeViewport={false}
|
||||
isChatTypeSelectionLocked={true}
|
||||
isComposerAttachmentUploading={false}
|
||||
isSendWithoutContextEnabled={false}
|
||||
@@ -75,21 +92,27 @@ export function ConversationRoomPane({
|
||||
onPickComposerFiles={async () => ({ items: [] })}
|
||||
onRemoveComposerAttachment={() => {}}
|
||||
onSelectChatType={() => {}}
|
||||
onSend={() => {}}
|
||||
onSendImmediate={() => {}}
|
||||
onSelectCodexModel={() => {}}
|
||||
onSend={() => 'blocked'}
|
||||
onSendImmediate={() => 'blocked'}
|
||||
onToggleSendWithoutContext={() => {}}
|
||||
isImmediateSendPinned={false}
|
||||
onToggleImmediateSendPinned={() => {}}
|
||||
onClearDraft={() => {}}
|
||||
onScrollToBottom={() => {}}
|
||||
onToggleResourceStrip={() => {}}
|
||||
onLoadOlderMessages={() => {}}
|
||||
onOpenPreview={() => {}}
|
||||
onCopyMessage={() => {}}
|
||||
onRetryMessage={() => {}}
|
||||
onRetryFailedRequest={() => {}}
|
||||
onCancelMessage={() => {}}
|
||||
onDeleteRequest={() => {}}
|
||||
onCompleteManualRequestBadge={() => {}}
|
||||
onRemoveQueuedRequest={() => {}}
|
||||
onPromoteQueuedRequest={() => {}}
|
||||
onSubmitPrompt={async () => false}
|
||||
onSubmitChildRequest={async () => false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
|
||||
export const CHAT_CONVERSATIONS_UPDATED_EVENT = 'work-server.chat-conversations-updated';
|
||||
export const CHAT_CONVERSATION_CLEARED_EVENT = 'work-server.chat-conversation-cleared';
|
||||
|
||||
type ChatConversationsUpdatedDetail = {
|
||||
items: ChatConversationSummary[];
|
||||
};
|
||||
|
||||
type ChatConversationClearedDetail = {
|
||||
item: ChatConversationSummary;
|
||||
};
|
||||
|
||||
export function emitChatConversationsUpdated(items: ChatConversationSummary[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -32,3 +37,30 @@ export function readChatConversationsUpdatedEvent(
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
fetchChatRuntimeJobDetail,
|
||||
fetchChatRuntimeSnapshot,
|
||||
markChatConversationResponsesRead,
|
||||
renameChatConversationRoom,
|
||||
updateChatConversationRoom,
|
||||
uploadChatComposerFile,
|
||||
} from '../../mainChatPanel';
|
||||
@@ -36,28 +35,33 @@ export type ChatGateway = {
|
||||
createConversation: (args: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
draftText?: string | null;
|
||||
requestBadgeLabel?: string | null;
|
||||
codexModel?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
generalSectionName?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
roomScope?: Record<string, unknown> | null;
|
||||
}) => Promise<ChatConversationSummary>;
|
||||
renameConversation: (sessionId: string, title: string) => Promise<ChatConversationSummary>;
|
||||
updateConversation: (
|
||||
sessionId: string,
|
||||
payload: Partial<
|
||||
Pick<
|
||||
ChatConversationSummary,
|
||||
| 'title'
|
||||
| 'draftText'
|
||||
| 'requestBadgeLabel'
|
||||
| 'codexModel'
|
||||
| 'chatTypeId'
|
||||
| 'lastChatTypeId'
|
||||
| 'generalSectionName'
|
||||
| 'contextLabel'
|
||||
| 'contextDescription'
|
||||
| 'notifyOffline'
|
||||
| 'roomScope'
|
||||
>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
@@ -76,7 +80,6 @@ export const chatGateway: ChatGateway = {
|
||||
listConversations: fetchChatConversations,
|
||||
getConversationDetail: fetchChatConversationDetail,
|
||||
createConversation: createChatConversationRoom,
|
||||
renameConversation: renameChatConversationRoom,
|
||||
updateConversation: updateChatConversationRoom,
|
||||
clearConversation: clearChatConversationRoom,
|
||||
deleteConversation: async (sessionId) => {
|
||||
|
||||
63
src/app/main/chatV2/hooks/conversationListMerge.test.mjs
Normal file
63
src/app/main/chatV2/hooks/conversationListMerge.test.mjs
Normal 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, '채팅 관리 / 유형 권한 관리');
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread';
|
||||
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
|
||||
import {
|
||||
resolveConversationUnreadMergeState,
|
||||
resolveStoredConversationUnreadState,
|
||||
} from '../../mainChatPanel/conversationUnread';
|
||||
|
||||
function shouldPreserveRequestMetadata(
|
||||
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
|
||||
@@ -56,13 +60,16 @@ function mergeConversationSummaries(existing: ChatConversationSummary, incoming:
|
||||
...preferred,
|
||||
clientId: preferred.clientId ?? fallback.clientId,
|
||||
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,
|
||||
codexModel: preferred.codexModel?.trim() || fallback.codexModel?.trim() || null,
|
||||
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
|
||||
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
|
||||
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
|
||||
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
|
||||
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
|
||||
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
|
||||
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
|
||||
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
|
||||
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[]) {
|
||||
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
|
||||
const sessionId = item.sessionId.trim();
|
||||
@@ -125,7 +140,10 @@ export function mergeConversationItemsPreservingRequestedSession(
|
||||
const previousItem = previousBySessionId.get(item.sessionId);
|
||||
|
||||
if (!previousItem) {
|
||||
return item;
|
||||
return {
|
||||
...item,
|
||||
hasUnreadResponse: resolveStoredConversationUnreadState(item),
|
||||
};
|
||||
}
|
||||
|
||||
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
|
||||
@@ -140,12 +158,13 @@ export function mergeConversationItemsPreservingRequestedSession(
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: preserveRequestMetadata
|
||||
? previousItem.title.trim() || item.title.trim()
|
||||
: item.title.trim() || previousItem.title.trim(),
|
||||
title: resolveMergedConversationTitle(previousItem.title, item.title, {
|
||||
preservePrevious: preserveRequestMetadata,
|
||||
}),
|
||||
requestBadgeLabel: preserveRequestMetadata
|
||||
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
|
||||
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
|
||||
codexModel: item.codexModel?.trim() || previousItem.codexModel?.trim() || null,
|
||||
chatTypeId,
|
||||
lastChatTypeId,
|
||||
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
|
||||
@@ -154,32 +173,14 @@ export function mergeConversationItemsPreservingRequestedSession(
|
||||
lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(),
|
||||
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
|
||||
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
|
||||
currentRequestId:
|
||||
item.currentRequestId?.trim() ||
|
||||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
|
||||
null,
|
||||
currentJobStatus:
|
||||
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,
|
||||
// For sessions that still exist in the server list, trust the server's
|
||||
// current job state instead of reviving stale local queued/started flags.
|
||||
currentRequestId: item.currentRequestId?.trim() || null,
|
||||
currentJobStatus: item.currentJobStatus ?? null,
|
||||
currentJobMessage: item.currentJobMessage?.trim() || null,
|
||||
currentQueueSize:
|
||||
item.currentQueueSize > 0
|
||||
? item.currentQueueSize
|
||||
: 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),
|
||||
item.currentJobStatus === 'queued' ? Math.max(1, item.currentQueueSize) : Math.max(0, item.currentQueueSize),
|
||||
currentStatusUpdatedAt: item.currentStatusUpdatedAt || null,
|
||||
};
|
||||
});
|
||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||
@@ -205,6 +206,10 @@ export function mergeConversationItemsPreservingRequestedSession(
|
||||
const preservedRequestedSession =
|
||||
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
|
||||
|
||||
if (!shouldPreserveMissingConversation(preservedRequestedSession)) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
if (!preservedRequestedSession) {
|
||||
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
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 { shouldSkipContextConfirmForSessionToday } from '../../mainChatPanel/contextConfirmPreference';
|
||||
|
||||
export type ComposerFilePickResult = {
|
||||
items: {
|
||||
@@ -19,7 +20,9 @@ type PendingChatRequest = {
|
||||
mode: 'queue' | 'direct';
|
||||
origin?: 'composer' | 'prompt';
|
||||
parentRequestId?: string | null;
|
||||
promptContextRef?: ChatPromptContextRef | null;
|
||||
omitPromptHistory?: boolean;
|
||||
codexModel: string;
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
@@ -37,10 +40,13 @@ type PendingChatRequest = {
|
||||
};
|
||||
|
||||
type PendingContextConfirm = {
|
||||
sessionId: string;
|
||||
mode: 'queue' | 'direct';
|
||||
text: string;
|
||||
origin?: 'composer' | 'prompt';
|
||||
parentRequestId?: string | null;
|
||||
promptContextRef?: ChatPromptContextRef | null;
|
||||
codexModel: string;
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
@@ -87,6 +93,7 @@ type UseConversationComposerControllerOptions = {
|
||||
getDraft: () => string;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
isComposerAttachmentUploading: boolean;
|
||||
selectedCodexModel: string;
|
||||
selectedChatType: SelectedChatType;
|
||||
socketRef: { current: WebSocket | null };
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
@@ -128,13 +135,23 @@ type UseConversationComposerControllerOptions = {
|
||||
ensureSessionReady?: (sessionId: string) => Promise<boolean>;
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
scrollViewportToBottom: () => void;
|
||||
releaseAutoScrollSuspension: () => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
sessionId?: string;
|
||||
mode: 'queue' | 'direct';
|
||||
draftText?: string;
|
||||
};
|
||||
|
||||
export type SendMessageResult = 'sent' | 'pending' | 'blocked';
|
||||
const COMPOSER_SUBMISSION_DEDUP_WINDOW_MS = 1200;
|
||||
|
||||
type RecentComposerSubmission = {
|
||||
key: string;
|
||||
submittedAt: number;
|
||||
};
|
||||
|
||||
function createClientRequestId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return `client-${crypto.randomUUID()}`;
|
||||
@@ -149,6 +166,7 @@ export function useConversationComposerController({
|
||||
getDraft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedCodexModel,
|
||||
selectedChatType,
|
||||
socketRef,
|
||||
composerRef,
|
||||
@@ -176,10 +194,47 @@ export function useConversationComposerController({
|
||||
ensureSessionReady,
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
releaseAutoScrollSuspension,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
|
||||
const activeComposerUploadCountRef = useRef(0);
|
||||
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(
|
||||
async (files: File[]): Promise<ComposerFilePickResult> => {
|
||||
@@ -286,18 +341,47 @@ export function useConversationComposerController({
|
||||
const focusComposerAfterSend = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
scrollViewportToBottom();
|
||||
}, 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(
|
||||
async (request: PendingContextConfirm) => {
|
||||
const {
|
||||
sessionId,
|
||||
mode,
|
||||
text,
|
||||
origin,
|
||||
parentRequestId,
|
||||
promptContextRef,
|
||||
chatTypeId,
|
||||
codexModel,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeBaseDescription,
|
||||
@@ -307,176 +391,280 @@ export function useConversationComposerController({
|
||||
customContextContent,
|
||||
omitPromptHistory,
|
||||
} = 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) {
|
||||
setActiveSystemStatus('새 채팅방 준비 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
const isSessionReady = await ensureSessionReady(activeSessionId);
|
||||
if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSessionReady) {
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
if (origin !== 'prompt') {
|
||||
const recentComposerSubmission = recentComposerSubmissionRef.current;
|
||||
if (
|
||||
recentComposerSubmission &&
|
||||
recentComposerSubmission.key === submissionKey &&
|
||||
Date.now() - recentComposerSubmission.submittedAt < COMPOSER_SUBMISSION_DEDUP_WINDOW_MS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const requestId = createClientRequestId();
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
text,
|
||||
mode,
|
||||
origin: origin ?? 'composer',
|
||||
parentRequestId: parentRequestId?.trim() || null,
|
||||
omitPromptHistory: omitPromptHistory === true,
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeBaseDescription,
|
||||
defaultContextIds,
|
||||
defaultContexts,
|
||||
customContextTitle,
|
||||
customContextContent,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
if (origin !== 'prompt') {
|
||||
activeComposerSubmissionKeyRef.current = submissionKey;
|
||||
recentComposerSubmissionRef.current = {
|
||||
key: submissionKey,
|
||||
submittedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
const shouldOptimisticallyClearComposer = origin !== 'prompt';
|
||||
const previousDraft = shouldOptimisticallyClearComposer ? getDraft() : '';
|
||||
const previousAttachments = shouldOptimisticallyClearComposer ? composerAttachments : [];
|
||||
let composerRestoreNeeded = shouldOptimisticallyClearComposer;
|
||||
|
||||
const restoreComposerOnFailure = () => {
|
||||
if (!composerRestoreNeeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
sendChatRequest(socketRef.current, outgoingRequest);
|
||||
} catch {
|
||||
setActiveSystemStatus('전송 재시도 중...');
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
|
||||
outgoingRequest,
|
||||
];
|
||||
if (mode === 'direct') {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
if (shouldOptimisticallyClearComposer) {
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
focusComposerAfterSend();
|
||||
}
|
||||
|
||||
if (!targetSessionId) {
|
||||
restoreComposerOnFailure();
|
||||
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,
|
||||
buildComposerSubmissionKey,
|
||||
composerAttachments,
|
||||
createActivityLogPlaceholder,
|
||||
createChatMessage,
|
||||
createLocalMessage,
|
||||
ensureSessionReady,
|
||||
focusComposerAfterSend,
|
||||
getDraft,
|
||||
isSocketOpen,
|
||||
pendingRequestsRef,
|
||||
promptRequestIdsRef,
|
||||
scheduleViewportBottomSyncAfterSend,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
@@ -493,15 +681,15 @@ export function useConversationComposerController({
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
({ mode, draftText }: SendMessageOptions) => {
|
||||
({ sessionId, mode, draftText }: SendMessageOptions): SendMessageResult => {
|
||||
if (isComposerAttachmentUploading) {
|
||||
return;
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if (!selectedChatType) {
|
||||
@@ -509,7 +697,15 @@ export function useConversationComposerController({
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
|
||||
]);
|
||||
return;
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
if (!isSocketOpen()) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'),
|
||||
]);
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
const recentContext = summarizeRecentContext(
|
||||
@@ -519,9 +715,34 @@ export function useConversationComposerController({
|
||||
);
|
||||
|
||||
if (recentContext.omittedCount > 0) {
|
||||
setPendingContextConfirm({
|
||||
const targetSessionId = sessionId?.trim() || activeSessionId.trim();
|
||||
const nextRequest = {
|
||||
sessionId: targetSessionId,
|
||||
mode,
|
||||
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,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
@@ -533,12 +754,14 @@ export function useConversationComposerController({
|
||||
includedContextCount: recentContext.includedCount,
|
||||
omittedContextCount: recentContext.omittedCount,
|
||||
});
|
||||
return;
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
void executeSendMessage({
|
||||
sessionId: sessionId?.trim() || activeSessionId.trim(),
|
||||
mode,
|
||||
text: trimmed,
|
||||
codexModel: selectedCodexModel,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
@@ -549,7 +772,8 @@ export function useConversationComposerController({
|
||||
customContextContent: selectedChatType.customContextContent,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
}).catch(handleExecuteSendError);
|
||||
return 'sent';
|
||||
},
|
||||
[
|
||||
appConfigChat.maxContextChars,
|
||||
@@ -558,8 +782,12 @@ export function useConversationComposerController({
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
getDraft,
|
||||
handleExecuteSendError,
|
||||
executeSendMessage,
|
||||
isSocketOpen,
|
||||
isComposerAttachmentUploading,
|
||||
selectedCodexModel,
|
||||
activeSessionId,
|
||||
messagesRef,
|
||||
selectedChatType,
|
||||
setMessages,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ChatConversationRequest,
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
ChatPromptContextRef,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
type PendingChatRequest = {
|
||||
@@ -14,7 +15,9 @@ type PendingChatRequest = {
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
promptContextRef?: ChatPromptContextRef | null;
|
||||
omitPromptHistory?: boolean;
|
||||
codexModel: string;
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
@@ -33,9 +36,9 @@ type PendingChatRequest = {
|
||||
type UseConversationRoomActionsControllerOptions = {
|
||||
activeSessionId: string;
|
||||
requestedSessionId: string;
|
||||
handledRequestedSessionIdRef: { current: string };
|
||||
isClosingConversationRef: { current: boolean };
|
||||
conversationItems: ChatConversationSummary[];
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
editingConversationTitle: string;
|
||||
isMobileViewport: boolean;
|
||||
pendingRequestsRef: { current: PendingChatRequest[] };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
@@ -54,9 +57,7 @@ type UseConversationRoomActionsControllerOptions = {
|
||||
setIsResourceStripOpen: (value: boolean) => void;
|
||||
setIsConversationPaneClosed: (value: boolean) => void;
|
||||
setIsMobileConversationView: (value: boolean) => void;
|
||||
setRenamingConversationSessionId: (value: string | null | ((current: string | null) => string | null)) => void;
|
||||
setEditingConversationTitle: (value: string) => void;
|
||||
setIsEditingConversationTitle: (value: boolean) => void;
|
||||
setPreserveEmptyConversationSelection: (value: boolean) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
@@ -69,9 +70,9 @@ type UseConversationRoomActionsControllerOptions = {
|
||||
export function useConversationRoomActionsController({
|
||||
activeSessionId,
|
||||
requestedSessionId,
|
||||
handledRequestedSessionIdRef,
|
||||
isClosingConversationRef,
|
||||
conversationItems,
|
||||
activeConversation,
|
||||
editingConversationTitle,
|
||||
isMobileViewport,
|
||||
pendingRequestsRef,
|
||||
sessionMessageCacheRef,
|
||||
@@ -90,9 +91,7 @@ export function useConversationRoomActionsController({
|
||||
setIsResourceStripOpen,
|
||||
setIsConversationPaneClosed,
|
||||
setIsMobileConversationView,
|
||||
setRenamingConversationSessionId,
|
||||
setEditingConversationTitle,
|
||||
setIsEditingConversationTitle,
|
||||
setPreserveEmptyConversationSelection,
|
||||
updatePendingMessageStatus,
|
||||
sendChatRequest,
|
||||
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(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
@@ -340,6 +283,9 @@ export function useConversationRoomActionsController({
|
||||
setConversationItems(remaining);
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
isClosingConversationRef.current = true;
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
setPreserveEmptyConversationSelection(true);
|
||||
replaceChatSessionInUrl('');
|
||||
chatConnectionGateway.resetLastReceivedEventId('');
|
||||
setActiveSessionId('');
|
||||
@@ -353,9 +299,10 @@ export function useConversationRoomActionsController({
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
setIsConversationPaneClosed(false);
|
||||
setIsMobileConversationView(!isMobileViewport);
|
||||
setIsConversationPaneClosed(true);
|
||||
setIsMobileConversationView(false);
|
||||
} else if (requestedSessionId === sessionId) {
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
replaceChatSessionInUrl(activeSessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -365,6 +312,8 @@ export function useConversationRoomActionsController({
|
||||
[
|
||||
activeSessionId,
|
||||
conversationItems,
|
||||
handledRequestedSessionIdRef,
|
||||
isClosingConversationRef,
|
||||
isMobileViewport,
|
||||
messageApi,
|
||||
replaceChatSessionInUrl,
|
||||
@@ -382,6 +331,7 @@ export function useConversationRoomActionsController({
|
||||
setIsPreviewModalOpen,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setPreserveEmptyConversationSelection,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
@@ -434,7 +384,6 @@ export function useConversationRoomActionsController({
|
||||
handleClearConversation,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
removeQueuedComposerRequest,
|
||||
retryPendingRequest,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatConversationRequest,
|
||||
@@ -8,8 +9,8 @@ import type {
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 10;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 10;
|
||||
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
||||
|
||||
function mergeConversationRequests(
|
||||
@@ -75,6 +76,7 @@ type UseConversationRoomDataOptions = {
|
||||
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
|
||||
viewportRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onMissingConversation?: (sessionId: string) => void;
|
||||
shouldPreserveMissingConversation?: (sessionId: string) => boolean;
|
||||
};
|
||||
|
||||
function isMissingConversationError(error: unknown) {
|
||||
@@ -110,6 +112,7 @@ export function useConversationRoomData({
|
||||
queueViewportPrependRestore,
|
||||
viewportRef,
|
||||
onMissingConversation,
|
||||
shouldPreserveMissingConversation,
|
||||
}: UseConversationRoomDataOptions) {
|
||||
const previousSessionIdRef = useRef('');
|
||||
|
||||
@@ -194,13 +197,21 @@ export function useConversationRoomData({
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
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) {
|
||||
return sortChatConversationSummaries([response.item, ...previous]);
|
||||
return sortChatConversationSummaries([mergedItem, ...previous]);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!isCancelled) {
|
||||
if (cachedMessages.length === 0 && isMissingConversationError(error)) {
|
||||
if (shouldPreserveMissingConversation?.(requestedSessionId)) {
|
||||
setMessages([]);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(null);
|
||||
setConversationLoadingLabel('새 채팅방을 준비하는 중입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
sessionMessageCacheRef.current.delete(requestedSessionId);
|
||||
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
|
||||
setMessages([]);
|
||||
@@ -275,6 +294,7 @@ export function useConversationRoomData({
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
onMissingConversation,
|
||||
shouldPreserveMissingConversation,
|
||||
setRequestItems,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types';
|
||||
import type { ChatComposerAttachment } from '../../mainChatPanel/types';
|
||||
|
||||
type PreviewItem = {
|
||||
id: string;
|
||||
@@ -22,7 +22,6 @@ type UseConversationViewControllerOptions = {
|
||||
setDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setIsResourceStripOpen: (value: boolean) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
};
|
||||
|
||||
export function useConversationViewController({
|
||||
@@ -38,7 +37,6 @@ export function useConversationViewController({
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
|
||||
const previousSessionId = previousSessionIdRef.current;
|
||||
const hasSessionChanged = previousSessionId !== activeSessionId;
|
||||
|
||||
if (!hasSessionChanged) {
|
||||
return;
|
||||
@@ -60,8 +59,13 @@ export function useConversationViewController({
|
||||
|
||||
previousSessionIdRef.current = activeSessionId;
|
||||
|
||||
setMessages([]);
|
||||
setDraft('');
|
||||
// Draft restoration is handled by the panel layer per session. Keep this
|
||||
// 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([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
@@ -78,7 +82,6 @@ export function useConversationViewController({
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -49,7 +49,10 @@ export function useConversationViewportController({
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const systemStatusTimerRef = useRef<number | null>(null);
|
||||
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const showScrollToBottomRef = useRef(false);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const lastViewportScrollTopRef = useRef(0);
|
||||
const autoScrollSuspendedUntilRef = useRef(0);
|
||||
const pendingViewportRestoreRef = useRef(false);
|
||||
const pendingPrependRestoreRef = useRef<{
|
||||
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(
|
||||
(behavior: ScrollBehavior = 'smooth') => {
|
||||
(behavior: ScrollBehavior = 'auto') => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
@@ -87,6 +99,12 @@ export function useConversationViewportController({
|
||||
[viewportRef],
|
||||
);
|
||||
|
||||
const isAutoScrollSuspended = useCallback(() => autoScrollSuspendedUntilRef.current > Date.now(), []);
|
||||
|
||||
const releaseAutoScrollSuspension = useCallback(() => {
|
||||
autoScrollSuspendedUntilRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const scheduleViewportBottomSync = useCallback(
|
||||
(frameCount = 6) => {
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
@@ -96,12 +114,12 @@ export function useConversationViewportController({
|
||||
|
||||
const run = (remainingFrames: number) => {
|
||||
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
if (!shouldStickToBottomRef.current || isConversationContentLoading) {
|
||||
if (!shouldStickToBottomRef.current || isConversationContentLoading || isAutoScrollSuspended()) {
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowScrollToBottom(false);
|
||||
syncShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
|
||||
if (remainingFrames <= 1) {
|
||||
@@ -115,7 +133,7 @@ export function useConversationViewportController({
|
||||
|
||||
run(frameCount);
|
||||
},
|
||||
[isConversationContentLoading, scrollViewportToBottom],
|
||||
[isAutoScrollSuspended, isConversationContentLoading, scrollViewportToBottom, syncShowScrollToBottom],
|
||||
);
|
||||
|
||||
const handleViewportScroll = useCallback(() => {
|
||||
@@ -127,10 +145,19 @@ export function useConversationViewportController({
|
||||
|
||||
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
const isNearBottom = remainingDistance <= 24;
|
||||
const isScrollingUp = viewport.scrollTop < lastViewportScrollTopRef.current - 2;
|
||||
|
||||
shouldStickToBottomRef.current = isNearBottom;
|
||||
setShowScrollToBottom(!isNearBottom);
|
||||
}, [viewportRef]);
|
||||
if (isNearBottom) {
|
||||
releaseAutoScrollSuspension();
|
||||
} 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 }) => {
|
||||
if (options?.forceStickToBottom) {
|
||||
@@ -166,15 +193,15 @@ export function useConversationViewportController({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
if (!shouldStickToBottomRef.current || isAutoScrollSuspended()) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto');
|
||||
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]);
|
||||
scrollViewportToBottom('auto');
|
||||
}, [chatMessageCount, chatMessageSyncKey, isAutoScrollSuspended, scrollViewportToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConversationContentLoading || !shouldStickToBottomRef.current) {
|
||||
if (isConversationContentLoading || !shouldStickToBottomRef.current || isAutoScrollSuspended()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,7 +213,13 @@ export function useConversationViewportController({
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]);
|
||||
}, [
|
||||
activeConversation?.sessionId,
|
||||
chatMessageSyncKey,
|
||||
isAutoScrollSuspended,
|
||||
isConversationContentLoading,
|
||||
scheduleViewportBottomSync,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingPrependRestore = pendingPrependRestoreRef.current;
|
||||
@@ -233,8 +266,9 @@ export function useConversationViewportController({
|
||||
}
|
||||
|
||||
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
|
||||
releaseAutoScrollSuspension();
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
syncShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +288,9 @@ export function useConversationViewportController({
|
||||
chatMessageCount,
|
||||
handleViewportScroll,
|
||||
isConversationContentLoading,
|
||||
releaseAutoScrollSuspension,
|
||||
scrollViewportToBottom,
|
||||
syncShowScrollToBottom,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
@@ -382,7 +418,7 @@ export function useConversationViewportController({
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeConversation?.currentJobStatus && !runtimeSnapshot) {
|
||||
if (activeConversation?.currentJobStatus) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(mapJobStatusLabel(activeConversation));
|
||||
setIsSystemStatusPending(
|
||||
@@ -399,12 +435,18 @@ export function useConversationViewportController({
|
||||
}
|
||||
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -415,6 +457,15 @@ export function useConversationViewportController({
|
||||
|
||||
const nextStatus = mapSystemStatusMessage(latestMessage.text);
|
||||
if (!nextStatus || isTerminalStatus) {
|
||||
if (activeSystemStatus == null && !isSystemStatusPending) {
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
systemStatusTimerRef.current = window.setTimeout(() => {
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
systemStatusTimerRef.current = null;
|
||||
}, 450);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
@@ -429,10 +480,12 @@ export function useConversationViewportController({
|
||||
clearSystemStatusTimer,
|
||||
connectionState,
|
||||
isActivityLogMessage,
|
||||
isSystemStatusPending,
|
||||
mapJobStatusLabel,
|
||||
mapSystemStatusMessage,
|
||||
messages,
|
||||
runtimeSnapshot,
|
||||
activeSystemStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -454,7 +507,7 @@ export function useConversationViewportController({
|
||||
scrollViewportToBottom,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
setShowScrollToBottom: syncShowScrollToBottom,
|
||||
shouldStickToBottomRef,
|
||||
showScrollToBottom,
|
||||
handleViewportTouchEnd,
|
||||
@@ -463,5 +516,6 @@ export function useConversationViewportController({
|
||||
isPullToLoadArmed,
|
||||
pullToLoadDistance,
|
||||
queueViewportPrependRestore,
|
||||
releaseAutoScrollSuspension,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
|
||||
|
||||
type UseUnreadCountsResult = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
@@ -150,21 +148,14 @@ export function useUnreadCounts(): UseUnreadCountsResult {
|
||||
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('online', handleVisibilityOrFocus);
|
||||
window.addEventListener('pageshow', handleVisibilityOrFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleVisibilityOrFocus);
|
||||
window.removeEventListener('online', handleVisibilityOrFocus);
|
||||
window.removeEventListener('pageshow', handleVisibilityOrFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,34 @@ import type { AppPageDescriptor } from '../../store/appStore/types';
|
||||
import { isPreviewRuntime } from './previewRuntime';
|
||||
|
||||
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() {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -11,7 +38,7 @@ function getClientStorage() {
|
||||
|
||||
if (isPreviewRuntime()) {
|
||||
return {
|
||||
key: PREVIEW_CLIENT_ID_STORAGE_KEY,
|
||||
key: CLIENT_ID_STORAGE_KEY,
|
||||
primaryStorage: window.localStorage,
|
||||
legacyStorage: window.sessionStorage,
|
||||
};
|
||||
@@ -43,17 +70,17 @@ export function getClientId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const savedClientId = storageConfig.primaryStorage.getItem(storageConfig.key)?.trim() ?? '';
|
||||
const savedClientId = readStorageValue(storageConfig.primaryStorage, storageConfig.key);
|
||||
|
||||
if (savedClientId) {
|
||||
return savedClientId;
|
||||
}
|
||||
|
||||
const legacyClientId = storageConfig.legacyStorage?.getItem(storageConfig.key)?.trim() ?? '';
|
||||
const legacyClientId = readStorageValue(storageConfig.legacyStorage, storageConfig.key);
|
||||
|
||||
if (legacyClientId) {
|
||||
storageConfig.primaryStorage.setItem(storageConfig.key, legacyClientId);
|
||||
storageConfig.legacyStorage?.removeItem(storageConfig.key);
|
||||
writeStorageValue(storageConfig.primaryStorage, storageConfig.key, legacyClientId);
|
||||
removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
|
||||
return legacyClientId;
|
||||
}
|
||||
|
||||
@@ -67,8 +94,8 @@ export function clearClientId() {
|
||||
return;
|
||||
}
|
||||
|
||||
storageConfig.primaryStorage.removeItem(storageConfig.key);
|
||||
storageConfig.legacyStorage?.removeItem(storageConfig.key);
|
||||
removeStorageValue(storageConfig.primaryStorage, storageConfig.key);
|
||||
removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
|
||||
}
|
||||
|
||||
export function getOrCreateClientId() {
|
||||
@@ -85,8 +112,8 @@ export function getOrCreateClientId() {
|
||||
}
|
||||
|
||||
const nextClientId = generateClientId();
|
||||
storageConfig.primaryStorage.setItem(storageConfig.key, nextClientId);
|
||||
storageConfig.legacyStorage?.removeItem(storageConfig.key);
|
||||
writeStorageValue(storageConfig.primaryStorage, storageConfig.key, nextClientId);
|
||||
removeStorageValue(storageConfig.legacyStorage, storageConfig.key);
|
||||
return nextClientId;
|
||||
}
|
||||
|
||||
|
||||
60
src/app/main/codexModelOptions.ts
Normal file
60
src/app/main/codexModelOptions.ts
Normal 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;
|
||||
}
|
||||
397
src/app/main/isolatedChatRoomScopeStore.ts
Normal file
397
src/app/main/isolatedChatRoomScopeStore.ts
Normal 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,
|
||||
() => [],
|
||||
);
|
||||
}
|
||||
155
src/app/main/isolatedChatRooms.ts
Normal file
155
src/app/main/isolatedChatRooms.ts
Normal 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])
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
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 { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
|
||||
import { useAppStore } from '../../../store';
|
||||
import { useTokenAccess } from '../tokenAccess';
|
||||
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
|
||||
import { getChatActionContextSnapshot } from '../chatActionContextStore';
|
||||
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 { normalizeIsolatedChatRoomScope } from '../isolatedChatRooms';
|
||||
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
||||
import { MainContent } from '../MainContent';
|
||||
import { MainHeader } from '../MainHeader';
|
||||
@@ -43,6 +54,8 @@ import {
|
||||
type TopMenuKey,
|
||||
} from '../routes';
|
||||
|
||||
const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive';
|
||||
|
||||
function parseRoute(pathname: string): {
|
||||
topMenu: TopMenuKey;
|
||||
docsMenu: string;
|
||||
@@ -90,6 +103,8 @@ function parseRoute(pathname: string): {
|
||||
first === 'history' ||
|
||||
first === 'automation-type' ||
|
||||
first === 'automation-context' ||
|
||||
first === 'token-setting' ||
|
||||
first === 'shared-resource' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
@@ -105,11 +120,13 @@ function parseRoute(pathname: string): {
|
||||
if (
|
||||
top === 'chat' &&
|
||||
(first === 'live' ||
|
||||
first === 'rooms' ||
|
||||
first === 'changes' ||
|
||||
first === 'resources' ||
|
||||
first === 'errors' ||
|
||||
first === 'manage' ||
|
||||
first === 'manage-defaults')
|
||||
first === 'manage-defaults' ||
|
||||
first === 'manage-share')
|
||||
) {
|
||||
return {
|
||||
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 {
|
||||
topMenu: 'play',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
@@ -212,14 +229,22 @@ function resolveSidebarOpenKeys(
|
||||
}
|
||||
|
||||
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') {
|
||||
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() {
|
||||
@@ -227,13 +252,18 @@ export function MainLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setCurrentPage, setFocusedComponentId } = useAppStore();
|
||||
const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const activeScopedChatRoomScope = useActiveIsolatedChatRoomScope();
|
||||
const isScopedChatRoomsWindowOpen = useIsolatedChatRoomsWindowOpen();
|
||||
const appConfig = useAppConfig();
|
||||
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
||||
const layoutData = useMainLayoutData();
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
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(() =>
|
||||
resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)),
|
||||
);
|
||||
@@ -250,14 +280,58 @@ export function MainLayout() {
|
||||
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData;
|
||||
const { chatUnreadCount } = useUnreadCounts();
|
||||
const navigateWithinApp = (path: string, options?: { replace?: boolean }) => {
|
||||
const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, location.search) : path;
|
||||
navigate(nextPath, options);
|
||||
const navigateWithinApp = (path: string, options?: { replace?: boolean; resetSearch?: boolean }) => {
|
||||
const baseSearch = options?.resetSearch ? '' : location.search;
|
||||
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(() => {
|
||||
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(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
@@ -331,45 +405,50 @@ export function MainLayout() {
|
||||
setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null));
|
||||
}, [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');
|
||||
useGestureLayer({
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
useGestureLayer(gestureLayer);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat || isTypingTarget(event.target)) {
|
||||
if (event.repeat || isTypingTarget(event.target) || isEReaderImmersiveActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
|
||||
event.preventDefault();
|
||||
openSearch('window');
|
||||
openScopedChatRooms();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -384,7 +463,13 @@ export function MainLayout() {
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
};
|
||||
}, [appConfig.gestureShortcuts.openSearch, appConfig.gestureShortcuts.openWindowSearch, openSearch]);
|
||||
}, [
|
||||
appConfig.gestureShortcuts.openSearch,
|
||||
appConfig.gestureShortcuts.openWindowSearch,
|
||||
isEReaderImmersiveActive,
|
||||
openScopedChatRooms,
|
||||
openSearch,
|
||||
]);
|
||||
|
||||
const selectedDocs = useMemo(
|
||||
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
|
||||
@@ -448,6 +533,10 @@ export function MainLayout() {
|
||||
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
|
||||
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
|
||||
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 initialSelectedWorkId = searchParams.get('workId');
|
||||
|
||||
@@ -477,8 +566,8 @@ export function MainLayout() {
|
||||
}}
|
||||
>
|
||||
<Layout className={`app-shell app-shell--docs-api${previewRuntime ? ' app-shell--preview-runtime' : ''}`}>
|
||||
{routeState.topMenu === 'chat' ? null : <ChatRuntimeBridgeV2 />}
|
||||
{contentExpanded ? null : (
|
||||
{routeState.topMenu === 'chat' || isPlayAppFullscreen ? null : <ChatRuntimeBridgeV2 />}
|
||||
{contentExpanded || isPlayAppFullscreen ? null : (
|
||||
<MainHeader
|
||||
activeTopMenu={routeState.topMenu}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
@@ -494,6 +583,9 @@ export function MainLayout() {
|
||||
navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu)));
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
openSearch();
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
setActivePlanQuickFilter(filter);
|
||||
@@ -506,7 +598,7 @@ export function MainLayout() {
|
||||
)}
|
||||
|
||||
<Layout className="app-shell__body">
|
||||
{contentExpanded || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
|
||||
{contentExpanded || isPlayAppFullscreen || (isSidebarOverlayViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
@@ -545,14 +637,14 @@ export function MainLayout() {
|
||||
}
|
||||
}}
|
||||
onSelectChatMenu={(key) => {
|
||||
navigateWithinApp(buildChatPath(key));
|
||||
navigateWithinApp(buildChatPath(key), { resetSearch: true });
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlayMenu={(key) => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
||||
navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout' | 'test'));
|
||||
navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key));
|
||||
if (isSidebarOverlayViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
@@ -572,6 +664,16 @@ export function MainLayout() {
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
</Layout>
|
||||
{routeState.chatMenu !== 'rooms' && isScopedChatRoomsWindowOpen ? (
|
||||
<ScopedChatRoomsWindow
|
||||
onClose={() => {
|
||||
writeIsolatedChatRoomsWindowOpen(false);
|
||||
}}
|
||||
>
|
||||
<SystemChatPanel lockOuterScrollOnMobile />
|
||||
</ScopedChatRoomsWindow>
|
||||
) : null}
|
||||
{routeState.chatMenu !== 'rooms' ? <ScopedChatRoomsWindowDock /> : null}
|
||||
</Layout>
|
||||
</MainLayoutContextProvider>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildChatPath,
|
||||
buildDocsPath,
|
||||
buildPlansPath,
|
||||
buildPlayAppPath,
|
||||
buildPlayPath,
|
||||
buildSavedLayoutPath,
|
||||
getDocsSectionLabel,
|
||||
@@ -13,6 +14,15 @@ import {
|
||||
PLAN_SIDEBAR_LABELS,
|
||||
} from '../routes';
|
||||
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 = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
@@ -176,6 +186,30 @@ export function buildSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
} 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',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
|
||||
@@ -203,6 +237,18 @@ export function buildSearchOptions({
|
||||
},
|
||||
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',
|
||||
label: 'Codex Live / Codex Live',
|
||||
@@ -275,6 +321,18 @@ export function buildSearchOptions({
|
||||
},
|
||||
onSelectWindow,
|
||||
} 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) => ({
|
||||
id: `docs-folder:${folder}`,
|
||||
label: `Docs / ${getDocsSectionLabel(folder)}`,
|
||||
@@ -313,6 +371,19 @@ export function buildSearchOptions({
|
||||
},
|
||||
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) => ({
|
||||
id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
label: entry.sampleMeta.title,
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleFilled,
|
||||
LoadingOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ChatConversationRequest } from './types';
|
||||
|
||||
export type ActivityChecklistState = 'complete' | 'current' | 'pending' | 'error';
|
||||
export type ActivityChecklistStageKey = 'intake' | 'analysis' | 'inspection' | 'confirmation' | 'execution' | 'result';
|
||||
|
||||
export type ActivityChecklistEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
state: ActivityChecklistState;
|
||||
note: string;
|
||||
};
|
||||
|
||||
type ActivityChecklistSourceEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
};
|
||||
|
||||
function getEntryStateLabel(entry: ActivityChecklistEntry) {
|
||||
if (entry.state === 'complete') {
|
||||
if (entry.key === 'confirmation') {
|
||||
return '확인 완료';
|
||||
}
|
||||
|
||||
if (entry.key === 'execution' || entry.key === 'result') {
|
||||
return '회신 완료';
|
||||
}
|
||||
|
||||
return '완료';
|
||||
}
|
||||
|
||||
@@ -41,142 +35,136 @@ function getEntryStateLabel(entry: ActivityChecklistEntry) {
|
||||
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[]) {
|
||||
return lines.map((line) => String(line ?? '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function getLastStageSummary(lines: string[], stageKey: ActivityChecklistStageKey) {
|
||||
const patterns = CHECKLIST_STAGE_PATTERNS[stageKey];
|
||||
function parseDirectiveChecklistEntries(lines: string[]) {
|
||||
const entriesByLabel = new Map<string, ActivityChecklistSourceEntry>();
|
||||
const labelOrder: string[] = [];
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(/^(☐|☑)\s+(.+)$/u);
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (patterns.some((pattern) => pattern.test(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const [, marker, rawLabel] = match;
|
||||
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[]) {
|
||||
const labels = new Set<string>();
|
||||
|
||||
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('화면');
|
||||
}
|
||||
function extractProgressLabel(line: string) {
|
||||
if (!line.startsWith('# 진행:')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
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) {
|
||||
if (request?.status === 'completed' || request?.status === 'failed' || request?.status === 'cancelled' || request?.status === 'removed') {
|
||||
return 'result' as const;
|
||||
}
|
||||
function parseProgressChecklistEntries(lines: string[]) {
|
||||
const entriesByLabel = new Map<string, ActivityChecklistSourceEntry>();
|
||||
const labelOrder: string[] = [];
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = sanitizeActivitySummary(lines[index] ?? '');
|
||||
lines.forEach((line, index) => {
|
||||
const label = extractProgressLabel(line);
|
||||
|
||||
if (!candidate) {
|
||||
continue;
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const stageKey of ['result', 'execution', 'confirmation', 'inspection', 'analysis', 'intake'] as const) {
|
||||
if (CHECKLIST_STAGE_PATTERNS[stageKey].some((pattern) => pattern.test(candidate))) {
|
||||
return stageKey;
|
||||
}
|
||||
if (!entriesByLabel.has(label)) {
|
||||
labelOrder.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (request?.status === 'started') {
|
||||
return 'analysis' as const;
|
||||
}
|
||||
entriesByLabel.set(label, {
|
||||
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) {
|
||||
const normalizedStatusMessage = String(request?.statusMessage ?? '').trim();
|
||||
|
||||
@@ -199,63 +187,6 @@ function resolveResultNote(request?: ChatConversationRequest) {
|
||||
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) {
|
||||
if (state === 'complete') {
|
||||
return <CheckCircleFilled aria-hidden="true" />;
|
||||
@@ -268,8 +199,7 @@ function renderStateIcon(state: ActivityChecklistState) {
|
||||
if (state === 'error') {
|
||||
return <CloseCircleFilled aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <ClockCircleOutlined aria-hidden="true" />;
|
||||
return <LoadingOutlined aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
|
||||
@@ -292,12 +222,24 @@ function buildSummaryLabel(entries: ActivityChecklistEntry[]) {
|
||||
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({
|
||||
lines,
|
||||
request,
|
||||
title,
|
||||
statusLabel,
|
||||
}: {
|
||||
lines: string[];
|
||||
request?: ChatConversationRequest;
|
||||
title?: string;
|
||||
statusLabel?: string;
|
||||
}) {
|
||||
if (lines.length === 0 && !request) {
|
||||
return null;
|
||||
@@ -306,16 +248,19 @@ export function ChatActivityChecklist({
|
||||
const normalizedLines = normalizeLines(lines);
|
||||
const entries = buildChatActivityChecklistEntries(normalizedLines, request);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="app-chat-activity-checklist" aria-label="Plan 체크리스트">
|
||||
<div className="app-chat-activity-checklist__header">
|
||||
<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>
|
||||
</div>
|
||||
<span className="app-chat-activity-checklist__legend">
|
||||
<MinusCircleOutlined aria-hidden="true" />
|
||||
<span>실시간 반영</span>
|
||||
<span>{statusLabel?.trim() || buildChecklistStatusLabel(request)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<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__state">{getEntryStateLabel(entry)}</span>
|
||||
</div>
|
||||
<p className="app-chat-activity-checklist__note">{entry.note}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,13 @@ import { Button } from 'antd';
|
||||
import { openChatExternalLink } from './linkNavigation';
|
||||
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 (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--link-card">
|
||||
<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"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
onOpen?.();
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
@@ -37,6 +44,7 @@ export function ChatLinkCardPreview({ target }: { target: Extract<ChatMessagePar
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
onOpen?.();
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -97,7 +97,7 @@ function resolvePreviewErrorMessage(previewError: string) {
|
||||
}
|
||||
|
||||
function resolvePreviewExtension(target: ChatPreviewTarget) {
|
||||
const raw = target.label || target.url;
|
||||
const raw = target.url || target.label;
|
||||
const normalized = raw.toLowerCase().split('?')[0] ?? '';
|
||||
const match = normalized.match(/\.([a-z0-9]+)$/i);
|
||||
return match?.[1] ?? '';
|
||||
@@ -263,7 +263,11 @@ function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, p
|
||||
normalizedPreview.includes('<head') ||
|
||||
normalizedPreview.includes('<body');
|
||||
|
||||
return looksLikeHtml;
|
||||
if (!looksLikeHtml) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalizedPreview.includes('<div id="root"');
|
||||
}
|
||||
|
||||
export function ChatPreviewBody({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ export type RankedLinkPreviewTarget = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTarget }) {
|
||||
export function ChatRankedLinkPreview({ target, onOpen }: { target: RankedLinkPreviewTarget; onOpen?: () => void }) {
|
||||
return (
|
||||
<section className="app-chat-preview-card app-chat-preview-card--ranked-link">
|
||||
<div className="app-chat-preview-card__header">
|
||||
@@ -27,6 +27,7 @@ export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTar
|
||||
className="app-chat-preview-card__open-link"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={(event) => {
|
||||
onOpen?.();
|
||||
void openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
@@ -41,6 +42,7 @@ export function ChatRankedLinkPreview({ target }: { target: RankedLinkPreviewTar
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
onClick={(event) => {
|
||||
onOpen?.();
|
||||
openChatExternalLink(target.url, event);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
|
||||
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_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
|
||||
var CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
|
||||
@@ -599,7 +599,7 @@ function appendActivityEventToMessages(previous, event) {
|
||||
function createIntroMessage(chatTypeLabel, chatTypeDescription) {
|
||||
var _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)
|
||||
: '';
|
||||
var contextDescriptionLine = chatTypeDescription ? "\uAE30\uBCF8 \uBB38\uB9E5: ".concat(chatTypeDescription) : '';
|
||||
@@ -609,7 +609,7 @@ function buildOfflineReply(context, input) {
|
||||
var _a, _b;
|
||||
var normalized = input.toLowerCase();
|
||||
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)
|
||||
: '';
|
||||
var descriptionLine = context.chatTypeDescription ? "- \uAE30\uBCF8 \uBB38\uB9E5: ".concat(context.chatTypeDescription) : '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
83
src/app/main/mainChatPanel/contextConfirmPreference.ts
Normal file
83
src/app/main/mainChatPanel/contextConfirmPreference.ts
Normal 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);
|
||||
}
|
||||
13
src/app/main/mainChatPanel/conversationDraftState.ts
Normal file
13
src/app/main/mainChatPanel/conversationDraftState.ts
Normal 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));
|
||||
}
|
||||
45
src/app/main/mainChatPanel/conversationTitle.ts
Normal file
45
src/app/main/mainChatPanel/conversationTitle.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,51 @@
|
||||
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) {
|
||||
if (!value) {
|
||||
return 0;
|
||||
@@ -9,22 +55,73 @@ function toConversationActivityTime(value: string | null | undefined) {
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function resolveConversationUnreadMergeState(
|
||||
previousItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
|
||||
nextItem: Pick<ChatConversationSummary, 'hasUnreadResponse' | 'lastMessageAt' | 'updatedAt'>,
|
||||
export function getConversationActivityTime(
|
||||
item: Pick<ChatConversationSummary, 'lastMessageAt' | 'updatedAt'>,
|
||||
) {
|
||||
if (!previousItem.hasUnreadResponse && nextItem.hasUnreadResponse) {
|
||||
const previousActivityTime = Math.max(
|
||||
toConversationActivityTime(previousItem.lastMessageAt),
|
||||
toConversationActivityTime(previousItem.updatedAt),
|
||||
);
|
||||
const nextActivityTime = Math.max(
|
||||
toConversationActivityTime(nextItem.lastMessageAt),
|
||||
toConversationActivityTime(nextItem.updatedAt),
|
||||
);
|
||||
const lastMessageActivityTime = toConversationActivityTime(item.lastMessageAt);
|
||||
|
||||
// Keep a locally-cleared unread state until a newer response actually arrives.
|
||||
if (nextActivityTime <= previousActivityTime) {
|
||||
if (lastMessageActivityTime > 0) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
136
src/app/main/mainChatPanel/executorActivitySummary.ts
Normal file
136
src/app/main/mainChatPanel/executorActivitySummary.ts
Normal 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;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ export {
|
||||
clearStoredChatClientConversationState,
|
||||
copyPreviewContent,
|
||||
copyText,
|
||||
createManagedChatShareRoom,
|
||||
ChatApiError,
|
||||
createChatShareLink,
|
||||
sharePreviewLink,
|
||||
resolvePreviewBodyForCopy,
|
||||
createActivityLogPlaceholder,
|
||||
createChatConversationRoom,
|
||||
@@ -14,9 +18,11 @@ export {
|
||||
createLocalMessage,
|
||||
cancelChatRuntimeJob,
|
||||
clearChatConversationRoom,
|
||||
completeChatConversationRequestManualBadge,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
fetchChatShareSnapshot,
|
||||
fetchChatConversations,
|
||||
fetchChatSourceChanges,
|
||||
fetchChatRuntimeJobDetail,
|
||||
@@ -28,16 +34,22 @@ export {
|
||||
getChatClientSessionId,
|
||||
markChatConversationResponsesRead,
|
||||
mergeRecoveredChatMessages,
|
||||
renameChatConversationRoom,
|
||||
removeChatRuntimeJob,
|
||||
resetLastReceivedChatEventId,
|
||||
persistChatPromptSelection,
|
||||
setStoredChatSessionLastTypeId,
|
||||
setChatClientSessionId,
|
||||
sortChatConversationSummaries,
|
||||
submitChatPromptSelection,
|
||||
submitChatShareMessage,
|
||||
submitChatSharePrompt,
|
||||
getStoredChatShareAccessPin,
|
||||
setStoredChatShareAccessPin,
|
||||
uploadChatComposerFile,
|
||||
upsertChatMessage,
|
||||
updateChatConversationRoom,
|
||||
} from './chatUtils';
|
||||
export type { ManagedChatShareRoom } from './chatUtils';
|
||||
export {
|
||||
getSharedChatRuntimeSnapshot,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
|
||||
@@ -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_TTL_MS = 15_000;
|
||||
const EXTERNAL_WINDOW_TARGET_PREFIX = 'ai-code-app.external-window';
|
||||
const UNSUPPORTED_STANDALONE_FALLBACK_DELAY_MS = 600;
|
||||
|
||||
type LinkNavigationEvent = {
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
};
|
||||
|
||||
type OpenExternalLinkOptions = {
|
||||
event?: LinkNavigationEvent;
|
||||
onUnsupportedStandalone?: (url: string) => void;
|
||||
};
|
||||
|
||||
function canUseSessionStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
||||
}
|
||||
@@ -26,6 +36,165 @@ function clearExternalLinkOpenTimestamp() {
|
||||
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() {
|
||||
if (!canUseSessionStorage()) {
|
||||
return false;
|
||||
@@ -42,24 +211,29 @@ export function shouldSkipForegroundResyncAfterExternalLink() {
|
||||
return Number.isFinite(openedAt) && Date.now() - openedAt <= CHAT_EXTERNAL_LINK_TTL_MS;
|
||||
}
|
||||
|
||||
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
||||
event?.preventDefault?.();
|
||||
event?.stopPropagation?.();
|
||||
export function openExternalLinkInNewWindow(url: string, options: OpenExternalLinkOptions = {}) {
|
||||
options.event?.preventDefault?.();
|
||||
options.event?.stopPropagation?.();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
persistExternalLinkOpenTimestamp(Date.now());
|
||||
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const target = buildExternalWindowTarget();
|
||||
const openedWindow = window.open(url, target, 'noopener,noreferrer');
|
||||
|
||||
if (openedWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.click();
|
||||
clickExternalAnchor(url, target);
|
||||
scheduleUnsupportedStandaloneFallback(url, options.onUnsupportedStandalone);
|
||||
schedulePopupBlockedFallback(url, options.onUnsupportedStandalone);
|
||||
}
|
||||
|
||||
export function openChatExternalLink(url: string, event?: LinkNavigationEvent) {
|
||||
openExternalLinkInNewWindow(url, {
|
||||
event,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,15 @@ import type { ChatMessagePart } from './types';
|
||||
|
||||
const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\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_END_PATTERN = /^\s*\]\]\s*$/;
|
||||
const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i;
|
||||
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 CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_DOT_CODEX_MARKER = '/.codex_chat/';
|
||||
@@ -21,6 +26,17 @@ function normalizeText(value: unknown) {
|
||||
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) {
|
||||
const normalized = normalizeText(segment);
|
||||
|
||||
@@ -81,7 +97,7 @@ function extractKnownPreviewPath(value: string) {
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
const normalized = normalizeText(value);
|
||||
const normalized = unwrapMarkdownLinkTarget(value);
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
@@ -132,6 +148,55 @@ function normalizeUrl(value: string) {
|
||||
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 {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
@@ -328,6 +393,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
const selectedValues = [
|
||||
...normalizePromptSelectedValues(record.selectedValues),
|
||||
...(record.selectedValue != null ? [record.selectedValue] : []),
|
||||
...steps.flatMap((step) => step.selectedValues ?? []),
|
||||
]
|
||||
.map((item) => normalizeText(item))
|
||||
.filter(Boolean)
|
||||
@@ -349,7 +415,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
|
||||
freeTextPlaceholder: normalizeText(record.freeTextPlaceholder) || null,
|
||||
currentStepKey: normalizeText(record.currentStepKey) || null,
|
||||
steps: steps.length > 0 ? steps : undefined,
|
||||
readOnly: record.readOnly === true || selectedValues.length > 0,
|
||||
readOnly: record.readOnly === true || resolvedBy != null,
|
||||
selectedValues,
|
||||
resolvedBy,
|
||||
resolvedAt: normalizeText(record.resolvedAt) || null,
|
||||
@@ -369,11 +435,82 @@ function buildPromptPartFromBlock(rawBody: string) {
|
||||
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 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 seenLinkKeys = new Set<string>();
|
||||
let isInsideCodeFence = false;
|
||||
const pushPart = (nextPart: ChatMessagePart | null) => {
|
||||
if (!nextPart) {
|
||||
return false;
|
||||
@@ -423,6 +560,46 @@ export function extractChatMessageParts(text: string) {
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
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);
|
||||
|
||||
if (promptMatched) {
|
||||
@@ -460,37 +637,29 @@ export function extractChatMessageParts(text: string) {
|
||||
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);
|
||||
|
||||
if (!matched) {
|
||||
const markdownLinkMatch = line.match(STANDALONE_MARKDOWN_LINK_LINE_PATTERN);
|
||||
if (markdownLinkMatch) {
|
||||
const [, rawTitle, rawUrl] = markdownLinkMatch;
|
||||
if (isStructuredLinkCardCandidate(rawUrl ?? '')) {
|
||||
if (pushPart(buildLinkCardPart(`${rawTitle}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standaloneUrlMatch = line.match(STANDALONE_URL_LINE_PATTERN);
|
||||
if (standaloneUrlMatch) {
|
||||
const rawUrl = standaloneUrlMatch[1] ?? '';
|
||||
if (isStructuredLinkCardCandidate(rawUrl)) {
|
||||
if (pushPart(buildLinkCardPart(`${resolveStandaloneLinkTitle(keptLines, rawUrl)}|${rawUrl}`))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keptLines.push(line);
|
||||
continue;
|
||||
}
|
||||
@@ -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 {
|
||||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||||
strippedText: strippedWithEmbeddedPrompts.replace(/\n{3,}/g, '\n\n').trim(),
|
||||
parts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||||
import { extractChatMessageParts } from './messageParts';
|
||||
import { extractAttachmentPreviewUrls, extractChatMessageParts } from './messageParts';
|
||||
import { extractHiddenPreviewUrls } from './previewMarkers';
|
||||
import { classifyPreviewKind, type PreviewKind } from './previewKind';
|
||||
import type { ChatMessage } from './types';
|
||||
@@ -159,7 +159,8 @@ export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const extractedMessageParts = extractChatMessageParts(message.text);
|
||||
const attachmentExtraction = extractAttachmentPreviewUrls(message.text);
|
||||
const extractedMessageParts = extractChatMessageParts(attachmentExtraction.strippedText);
|
||||
const structuredLinkUrls = [
|
||||
...(Array.isArray(message.parts) ? message.parts : []),
|
||||
...extractedMessageParts.parts,
|
||||
@@ -171,6 +172,7 @@ export function extractPreviewItems(messages: ChatMessage[]) {
|
||||
.map((part) => part.url);
|
||||
const matches = [
|
||||
...extractHiddenPreviewUrls(message.text),
|
||||
...attachmentExtraction.urls,
|
||||
...structuredLinkUrls,
|
||||
];
|
||||
|
||||
|
||||
@@ -11,5 +11,5 @@ export function resolvePromptPreviewOptionValue<T extends { value: string }>(
|
||||
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;
|
||||
}
|
||||
|
||||
7
src/app/main/mainChatPanel/promptState.ts
Normal file
7
src/app/main/mainChatPanel/promptState.ts
Normal 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;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
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) {
|
||||
const normalized = label.replace(/\s+/g, ' ').trim();
|
||||
|
||||
@@ -46,6 +50,16 @@ function normalizeConversationPromptFollowupText(text: string | null | undefined
|
||||
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) {
|
||||
const normalized = String(text ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -357,25 +371,49 @@ function sanitizeConversationBadgeFallbackText(text: string | null | undefined)
|
||||
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) {
|
||||
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
|
||||
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
|
||||
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
|
||||
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
|
||||
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
|
||||
const titleText = sanitizeConversationBadgeFallbackText(item.title);
|
||||
const contextLabel = sanitizeConversationBadgeContextFallbackText(item.contextLabel);
|
||||
const contextDescription = sanitizeConversationBadgeContextFallbackText(item.contextDescription);
|
||||
const titleText = sanitizeConversationBadgeTitleFallbackText(item.title);
|
||||
const runtimeText = normalizeConversationPromptFollowupText(runtimeSummary);
|
||||
const isMetaRequest = isConversationBadgeMetaRequest(requestText);
|
||||
const isInquiryRequest = isConversationInquiryRequest(requestText);
|
||||
|
||||
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(
|
||||
@@ -395,9 +433,9 @@ function inferConversationRequestBadgeLabel(
|
||||
const requestText = normalizeConversationPromptFollowupText(item.lastRequestPreview);
|
||||
const responseText = normalizeConversationPromptFollowupText(item.lastResponsePreview);
|
||||
const messageText = normalizeConversationPromptFollowupText(item.lastMessagePreview);
|
||||
const contextLabel = sanitizeConversationBadgeFallbackText(item.contextLabel);
|
||||
const contextDescription = sanitizeConversationBadgeFallbackText(item.contextDescription);
|
||||
const titleText = sanitizeConversationBadgeFallbackText(item.title);
|
||||
const contextLabel = sanitizeConversationBadgeContextFallbackText(item.contextLabel);
|
||||
const contextDescription = sanitizeConversationBadgeContextFallbackText(item.contextDescription);
|
||||
const titleText = sanitizeConversationBadgeTitleFallbackText(item.title);
|
||||
const requestActionLabel = findConversationBadgeActionLabel(requestText);
|
||||
const actionLabel = findConversationBadgeActionLabel(...labelSourceTexts);
|
||||
const targetLabel = findConversationBadgeTargetLabel(...labelSourceTexts);
|
||||
@@ -431,14 +469,14 @@ function inferConversationRequestBadgeLabel(
|
||||
|
||||
if (actionLabel) {
|
||||
const fallbackLabel = compactConversationBadgeLabel(
|
||||
contextDescription || titleText || contextLabel || responseText || messageText,
|
||||
requestText || responseText || messageText || titleText || contextDescription || contextLabel,
|
||||
1,
|
||||
);
|
||||
return fallbackLabel ? trimConversationRequestBadgeLabel(`${fallbackLabel} ${actionLabel}`) : actionLabel;
|
||||
}
|
||||
|
||||
const fallbackLabel = compactConversationBadgeLabel(
|
||||
contextDescription || titleText || contextLabel || responseText || messageText,
|
||||
requestText || responseText || messageText || titleText || contextDescription || contextLabel,
|
||||
2,
|
||||
);
|
||||
if (fallbackLabel) {
|
||||
@@ -461,6 +499,10 @@ export function resolveConversationRequestBadgeLabelForUserText(args: {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isConversationTrivialMessage(normalizedRequestText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
|
||||
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
|
||||
|
||||
@@ -474,10 +516,10 @@ export function resolveConversationRequestBadgeLabelForUserText(args: {
|
||||
|
||||
if (isConversationInquiryRequest(normalizedRequestText)) {
|
||||
const fallbackLabel = compactConversationBadgeLabel(
|
||||
sanitizeConversationBadgeFallbackText(args.contextDescription) ||
|
||||
sanitizeConversationBadgeFallbackText(args.contextLabel) ||
|
||||
sanitizeConversationBadgeFallbackText(args.title) ||
|
||||
args.currentMenuLabel?.trim() ||
|
||||
sanitizeConversationBadgeTitleFallbackText(args.title) ||
|
||||
sanitizeConversationBadgeContextFallbackText(args.contextDescription) ||
|
||||
sanitizeConversationBadgeContextFallbackText(args.contextLabel) ||
|
||||
sanitizeConversationBadgeTitleFallbackText(args.currentMenuLabel) ||
|
||||
'',
|
||||
2,
|
||||
);
|
||||
@@ -511,6 +553,10 @@ export function resolveConversationTitleForUserText(args: {
|
||||
return fallbackTitle || '새 대화';
|
||||
}
|
||||
|
||||
if (isConversationTrivialMessage(normalizedRequestText)) {
|
||||
return fallbackTitle || '새 대화';
|
||||
}
|
||||
|
||||
const requestActionLabel = findConversationBadgeActionLabel(normalizedRequestText);
|
||||
const taskDescriptionLabel = buildConversationTaskDescriptionLabel(normalizedRequestText);
|
||||
const targetLabel = findConversationBadgeTargetLabel(normalizedRequestText);
|
||||
@@ -583,6 +629,16 @@ export function resolveConversationScreenTitle(pageTitle?: string | null, topMen
|
||||
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) {
|
||||
const uniqueSegments = resolvePageTitleSegments(pageTitle);
|
||||
const preferredPageLabel = uniqueSegments.at(-1) ?? uniqueSegments[0] ?? '';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -387,7 +653,8 @@
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 26px;
|
||||
padding-inline: 8px;
|
||||
margin-top: 2px;
|
||||
padding-inline: 10px;
|
||||
font-size: 12px;
|
||||
color: #475569;
|
||||
border-radius: 999px;
|
||||
@@ -451,11 +718,19 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
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 {
|
||||
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 {
|
||||
@@ -563,23 +838,57 @@
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
height: clamp(112px, 18dvh, 160px);
|
||||
min-height: clamp(112px, 18dvh, 160px);
|
||||
height: var(--app-chat-panel-composer-height, clamp(112px, 18dvh, 160px));
|
||||
min-height: var(--app-chat-panel-composer-height, clamp(112px, 18dvh, 160px));
|
||||
padding: 10px 52px 8px 14px;
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
height: clamp(104px, 16dvh, 148px);
|
||||
min-height: clamp(104px, 16dvh, 148px);
|
||||
height: var(--app-chat-panel-composer-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));
|
||||
}
|
||||
|
||||
: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 {
|
||||
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-actions {
|
||||
display: flex;
|
||||
@@ -606,6 +915,8 @@
|
||||
.app-chat-panel__composer-action-buttons {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-contextless-toggle.ant-btn {
|
||||
@@ -653,6 +964,29 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
@@ -692,50 +1026,6 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -800,12 +1090,56 @@
|
||||
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 {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
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 {
|
||||
padding-block: 2px;
|
||||
}
|
||||
@@ -1378,21 +1712,29 @@
|
||||
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 {
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer textarea.ant-input {
|
||||
height: clamp(104px, 16dvh, 136px);
|
||||
min-height: clamp(104px, 16dvh, 136px);
|
||||
height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 136px));
|
||||
min-height: var(--app-chat-panel-composer-height, clamp(104px, 16dvh, 136px));
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
311
src/app/main/mainChatPanel/styles/MainChatPanel.rooms-shared.css
Normal file
311
src/app/main/mainChatPanel/styles/MainChatPanel.rooms-shared.css
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatPromptContextRef = {
|
||||
key: 'prompt_parent_question';
|
||||
promptTitle: string;
|
||||
promptDescription?: string | null;
|
||||
parentQuestionText?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessagePart =
|
||||
| {
|
||||
type: 'link_card';
|
||||
@@ -86,15 +93,29 @@ export type ChatComposerAttachment = {
|
||||
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 = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
topMenu: string;
|
||||
focusedComponentId: string | null;
|
||||
pageUrl: string;
|
||||
appOrigin?: string;
|
||||
appDomain?: string;
|
||||
isStandaloneMode: boolean;
|
||||
pageVisibilityState: 'visible' | 'hidden';
|
||||
pageFocusState?: 'focused' | 'blurred';
|
||||
codexModel?: string | null;
|
||||
codexParticipants?: ChatCodexParticipant[];
|
||||
chatTypeId: string | null;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
@@ -113,13 +134,16 @@ export type ChatConversationSummary = {
|
||||
sessionId: string;
|
||||
clientId: string | null;
|
||||
isDraftOnly?: boolean;
|
||||
draftText: string;
|
||||
title: string;
|
||||
requestBadgeLabel?: string | null;
|
||||
codexModel?: string | null;
|
||||
chatTypeId: string | null;
|
||||
lastChatTypeId: string | null;
|
||||
generalSectionName: string | null;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
roomScope: Record<string, unknown> | null;
|
||||
notifyOffline: boolean;
|
||||
hasUnreadResponse: boolean;
|
||||
currentRequestId: string | null;
|
||||
@@ -146,10 +170,23 @@ export type ChatConversationRequestStatus =
|
||||
| 'cancelled'
|
||||
| 'removed';
|
||||
|
||||
export type ChatConversationRequestUsageSnapshot = {
|
||||
tokenTotals: {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cached: number;
|
||||
reasoning: number;
|
||||
};
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type ChatConversationRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
requesterClientId?: string | null;
|
||||
chatTypeId?: string | null;
|
||||
chatTypeLabel?: string;
|
||||
requestOrigin?: 'composer' | 'prompt' | null;
|
||||
parentRequestId?: string | null;
|
||||
status: ChatConversationRequestStatus;
|
||||
@@ -158,8 +195,12 @@ export type ChatConversationRequest = {
|
||||
userText: string;
|
||||
responseMessageId: number | null;
|
||||
responseText: string;
|
||||
usageSnapshot?: ChatConversationRequestUsageSnapshot | null;
|
||||
totalTokens?: number | null;
|
||||
hasResponse: boolean;
|
||||
canDelete: boolean;
|
||||
manualPromptCompletedAt?: string | null;
|
||||
manualVerificationCompletedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
answeredAt: string | null;
|
||||
@@ -281,6 +322,14 @@ export type ErrorReferenceSummary = {
|
||||
export type MainChatPanelProps = {
|
||||
initialView?: 'live' | 'errors';
|
||||
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 =
|
||||
@@ -344,6 +393,12 @@ export type ChatServerEvent =
|
||||
type: 'chat:activity';
|
||||
payload: ChatActivityEvent;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:request:update';
|
||||
payload: ChatConversationRequest;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatConversationRequest,
|
||||
ChatJobEvent,
|
||||
ChatMessage,
|
||||
ChatRuntimeJobDetail,
|
||||
@@ -32,6 +33,7 @@ type UseChatConnectionOptions = {
|
||||
onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
|
||||
onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
|
||||
onActivityEvent?: (event: ChatActivityEvent) => void;
|
||||
onRequestEvent?: (request: ChatConversationRequest, sessionId: string) => void;
|
||||
};
|
||||
|
||||
type SharedChatConnectionState = {
|
||||
@@ -53,6 +55,7 @@ type SharedChatConnection = SharedChatConnectionState & {
|
||||
onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined;
|
||||
onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined;
|
||||
onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined;
|
||||
onRequestEvent?: ((request: ChatConversationRequest, sessionId: string) => void) | undefined;
|
||||
lastEventId: number;
|
||||
websocketUrl: string;
|
||||
subscribers: Set<() => void>;
|
||||
@@ -88,6 +91,7 @@ const sharedChatConnection: SharedChatConnection = {
|
||||
onRuntimeEvent: undefined,
|
||||
onRuntimeDetailEvent: undefined,
|
||||
onActivityEvent: undefined,
|
||||
onRequestEvent: undefined,
|
||||
lastEventId: 0,
|
||||
websocketUrl: '',
|
||||
subscribers: new Set(),
|
||||
@@ -199,6 +203,8 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
|
||||
? 'blurred'
|
||||
: 'focused';
|
||||
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(
|
||||
JSON.stringify({
|
||||
@@ -209,9 +215,13 @@ function sendContextUpdate(context: ChatViewContext | null = sharedChatConnectio
|
||||
topMenu: context.topMenu,
|
||||
focusedComponentId: context.focusedComponentId,
|
||||
pageUrl: livePageUrl,
|
||||
appOrigin: liveAppOrigin,
|
||||
appDomain: liveAppDomain,
|
||||
isStandaloneMode: context.isStandaloneMode,
|
||||
pageVisibilityState: liveVisibilityState,
|
||||
pageFocusState: liveFocusState,
|
||||
codexModel: context.codexModel ?? null,
|
||||
codexParticipants: context.codexParticipants ?? [],
|
||||
chatTypeId: context.chatTypeId,
|
||||
chatTypeLabel: context.chatTypeLabel,
|
||||
chatTypeDescription: context.chatTypeDescription,
|
||||
@@ -251,6 +261,8 @@ function sendImmediateHiddenContextUpdate() {
|
||||
}
|
||||
|
||||
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(
|
||||
JSON.stringify({
|
||||
@@ -261,9 +273,13 @@ function sendImmediateHiddenContextUpdate() {
|
||||
topMenu: context.topMenu,
|
||||
focusedComponentId: context.focusedComponentId,
|
||||
pageUrl: livePageUrl,
|
||||
appOrigin: liveAppOrigin,
|
||||
appDomain: liveAppDomain,
|
||||
isStandaloneMode: context.isStandaloneMode,
|
||||
pageVisibilityState: 'hidden',
|
||||
pageFocusState: 'blurred',
|
||||
codexModel: context.codexModel ?? null,
|
||||
codexParticipants: context.codexParticipants ?? [],
|
||||
chatTypeId: context.chatTypeId,
|
||||
chatTypeLabel: context.chatTypeLabel,
|
||||
chatTypeDescription: context.chatTypeDescription,
|
||||
@@ -663,6 +679,7 @@ function connectSharedSocket() {
|
||||
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
|
||||
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
|
||||
onActivityEvent: sharedChatConnection.onActivityEvent,
|
||||
onRequestEvent: sharedChatConnection.onRequestEvent,
|
||||
onEventReceived: (eventId) => {
|
||||
sharedChatConnection.lastEventId = eventId;
|
||||
persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId);
|
||||
@@ -712,6 +729,7 @@ function ensureSharedConnection(options: UseChatConnectionOptions) {
|
||||
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = options.onActivityEvent;
|
||||
sharedChatConnection.onRequestEvent = options.onRequestEvent;
|
||||
|
||||
if (sessionChanged) {
|
||||
sharedChatConnection.sessionId = options.sessionId;
|
||||
@@ -732,6 +750,7 @@ export function useChatConnection({
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
onRequestEvent,
|
||||
}: UseChatConnectionOptions) {
|
||||
const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot());
|
||||
|
||||
@@ -758,6 +777,7 @@ export function useChatConnection({
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
onRequestEvent,
|
||||
});
|
||||
handleSnapshotChange();
|
||||
|
||||
@@ -768,22 +788,18 @@ export function useChatConnection({
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.currentContext = currentContext;
|
||||
sendContextUpdate(currentContext);
|
||||
}, [currentContext]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.setMessages = setMessages;
|
||||
sharedChatConnection.onMessageEvent = onMessageEvent;
|
||||
sharedChatConnection.onJobEvent = onJobEvent;
|
||||
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = onActivityEvent;
|
||||
sendContextUpdate(currentContext);
|
||||
}, [
|
||||
currentContext,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
setMessages,
|
||||
]);
|
||||
sharedChatConnection.onRequestEvent = onRequestEvent;
|
||||
}, [onActivityEvent, onJobEvent, onMessageEvent, onRequestEvent, onRuntimeDetailEvent, onRuntimeEvent, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.pingSubscriberCount += 1;
|
||||
|
||||
@@ -26,16 +26,22 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'token-setting': '설정',
|
||||
'shared-resource': '공유 리소스 관리',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
export const PLAN_BASE_OPEN_KEYS = ['plan-group', 'server-group', 'codex-live-group', 'app-log-group'] as const;
|
||||
export const PLAY_BASE_OPEN_KEYS = ['play-group', 'play-layout-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', 'play-apps-group', 'play-apps-general-group'] 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 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',
|
||||
draw: 'Layout Draw',
|
||||
apps: 'Apps',
|
||||
test: 'Test App',
|
||||
cbt: 'CBT',
|
||||
};
|
||||
|
||||
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',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'token-setting': 'plan-menu-token-setting',
|
||||
'shared-resource': 'plan-menu-shared-resource',
|
||||
'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 }>) {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||
|
||||
if (selectedPlayMenu === 'layout') {
|
||||
if (
|
||||
selectedPlayMenu === 'layout' ||
|
||||
selectedPlayMenu === 'draw' ||
|
||||
selectedPlayMenu === 'apps' ||
|
||||
selectedPlayMenu === 'test' ||
|
||||
selectedPlayMenu === 'cbt'
|
||||
) {
|
||||
return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
|
||||
}
|
||||
|
||||
|
||||
@@ -45,13 +45,15 @@ export function resolveInitialNavigation(): MainViewInitialNavigation {
|
||||
selectedPlayMenu:
|
||||
playSectionParam === 'layout'
|
||||
? 'layout'
|
||||
: playSectionParam === 'test'
|
||||
? 'test'
|
||||
: playSectionParam === 'apps'
|
||||
? 'apps'
|
||||
: playSectionParam === 'test'
|
||||
? 'test'
|
||||
: playSectionParam === 'cbt'
|
||||
? 'cbt'
|
||||
: playSectionParam === 'layout-record' && playLayoutIdParam
|
||||
? resolveSavedLayoutMenuKey(playLayoutIdParam)
|
||||
: 'layout',
|
||||
: playSectionParam === 'layout-record' && playLayoutIdParam
|
||||
? resolveSavedLayoutMenuKey(playLayoutIdParam)
|
||||
: 'layout',
|
||||
initialSelectedPlanId: Number.isFinite(parsedPlanId) ? parsedPlanId : null,
|
||||
initialSelectedWorkId: params.get('workId'),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { SearchKeywordOption } from '../../../components/search';
|
||||
import type { LoadedSampleEntry } from '../../../samples/registry';
|
||||
import { buildPlayAppPath } from '../routes';
|
||||
import type { ChatSidebarKey, PlanSidebarKey, TopMenuKey } from '../types';
|
||||
import { compactKeywords, scrollToElement } from './utils';
|
||||
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 = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
@@ -127,19 +137,43 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
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:plans:token-setting',
|
||||
label: `토큰관리 / ${PLAN_SIDEBAR_LABELS['token-setting']}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', 'token', 'token setting', '토큰', '토큰 설정', '권한 설정', '앱 권한'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('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: () => {
|
||||
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',
|
||||
@@ -152,6 +186,36 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
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',
|
||||
label: 'Codex Live / Codex Live',
|
||||
@@ -164,6 +228,18 @@ export function buildMainViewSearchOptions({
|
||||
},
|
||||
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',
|
||||
label: '리소스 관리 / 리소스 관리',
|
||||
@@ -211,6 +287,18 @@ export function buildMainViewSearchOptions({
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
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,
|
||||
...docFolders.map((folder) => ({
|
||||
id: `docs-folder:${folder}`,
|
||||
|
||||
@@ -54,3 +54,43 @@ export function renderModalWithEnterConfirm(node: React.ReactNode) {
|
||||
</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);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import { appendClientIdHeader, getOrCreateClientId } from './clientIdentity';
|
||||
|
||||
function resolveNotificationApiBaseUrl() {
|
||||
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() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
@@ -116,6 +120,41 @@ function getCurrentAppDomain() {
|
||||
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 NotificationMessageListStatus = 'all' | 'unread';
|
||||
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
|
||||
@@ -761,11 +800,14 @@ export async function registerWebPushSubscription(
|
||||
subscription: WebPushSubscriptionPayload,
|
||||
deviceId?: string,
|
||||
) {
|
||||
const clientId = getOrCreateClientId().trim();
|
||||
|
||||
return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
subscription,
|
||||
deviceId,
|
||||
clientId: clientId || undefined,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
appOrigin: getCurrentAppOrigin(),
|
||||
appDomain: getCurrentAppDomain(),
|
||||
@@ -801,9 +843,10 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
||||
const notificationOptions = {
|
||||
body: payload.body,
|
||||
data: payload.data ?? {},
|
||||
body: appendNotificationOriginToBody(payload.body, notificationData),
|
||||
data: notificationData,
|
||||
tag: payload.threadId ?? payload.data?.notificationKey ?? undefined,
|
||||
badge: '/pwa-192x192.svg',
|
||||
icon: '/pwa-192x192.svg',
|
||||
@@ -831,8 +874,12 @@ export async function showLocalClientNotification(payload: ClientNotificationPay
|
||||
}
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
const notificationData = withCurrentAppOriginMetadata(payload.data);
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
data: notificationData,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clearClientId, getOrCreateClientId } from './clientIdentity';
|
||||
import { getClientId } from './clientIdentity';
|
||||
import { isPreviewRuntime } from './previewRuntime';
|
||||
|
||||
export const NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
|
||||
@@ -9,52 +9,82 @@ export type AutomationNotificationPreferenceTarget = {
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export function getSavedNotificationDeviceId() {
|
||||
function getNotificationIdentityStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPreview = isPreviewRuntime();
|
||||
const storage = isPreview ? window.sessionStorage : window.localStorage;
|
||||
const deviceIdStorageKey = isPreview ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY;
|
||||
const clientId = getOrCreateClientId();
|
||||
if (clientId) {
|
||||
storage.setItem(deviceIdStorageKey, clientId);
|
||||
return clientId;
|
||||
|
||||
return {
|
||||
storage: isPreview ? window.sessionStorage : window.localStorage,
|
||||
key: isPreview ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
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 (shouldRotateLegacyNotificationDeviceId(saved)) {
|
||||
const rotated = generateNotificationDeviceId();
|
||||
storageConfig.storage.setItem(storageConfig.key, rotated);
|
||||
return rotated;
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
const generated = `web-${Date.now()}`;
|
||||
storage.setItem(deviceIdStorageKey, generated);
|
||||
const generated = generateNotificationDeviceId();
|
||||
storageConfig.storage.setItem(storageConfig.key, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
export function clearNotificationIdentity() {
|
||||
if (typeof window === 'undefined') {
|
||||
const storageConfig = getNotificationIdentityStorage();
|
||||
|
||||
if (!storageConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = isPreviewRuntime() ? window.sessionStorage : window.localStorage;
|
||||
const deviceIdStorageKey = isPreviewRuntime() ? PREVIEW_NOTIFICATION_DEVICE_ID_STORAGE_KEY : NOTIFICATION_DEVICE_ID_STORAGE_KEY;
|
||||
|
||||
storage.removeItem(deviceIdStorageKey);
|
||||
clearClientId();
|
||||
storageConfig.storage.removeItem(storageConfig.key);
|
||||
}
|
||||
|
||||
export function getAutomationNotificationPreferenceTarget(): AutomationNotificationPreferenceTarget | null {
|
||||
const clientId = getSavedNotificationDeviceId();
|
||||
const deviceId = getSavedNotificationDeviceId();
|
||||
|
||||
if (!clientId) {
|
||||
if (!deviceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetKind: 'client',
|
||||
targetId: clientId,
|
||||
targetId: deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChatDefaultContextManagementPage } from '../ChatDefaultContextManagementPage';
|
||||
import { ResourceManagementPage } from '../ResourceManagementPage';
|
||||
import { SharedChatManagementPage } from '../SharedChatManagementPage';
|
||||
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
|
||||
import { MainChatPanel } from '../MainChatPanel';
|
||||
import { SystemChatPanel } from '../SystemChatPanel';
|
||||
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
|
||||
@@ -14,10 +16,14 @@ export function ChatPage() {
|
||||
<ChatTypeManagementPage />
|
||||
) : selectedChatMenu === 'manage-defaults' ? (
|
||||
<ChatDefaultContextManagementPage />
|
||||
) : selectedChatMenu === 'manage-share' ? (
|
||||
<SharedChatManagementPage />
|
||||
) : selectedChatMenu === 'resources' ? (
|
||||
<ResourceManagementPage />
|
||||
) : selectedChatMenu === 'changes' ? (
|
||||
<ChatSourceChangesPage />
|
||||
) : selectedChatMenu === 'rooms' ? (
|
||||
<SystemChatPanel lockOuterScrollOnMobile />
|
||||
) : (
|
||||
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
|
||||
)}
|
||||
|
||||
1794
src/app/main/pages/ChatSharePage.css
Normal file
1794
src/app/main/pages/ChatSharePage.css
Normal file
File diff suppressed because it is too large
Load Diff
5415
src/app/main/pages/ChatSharePage.tsx
Normal file
5415
src/app/main/pages/ChatSharePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
|
||||
import { AutomationTypeManagementPage } from '../AutomationTypeManagementPage';
|
||||
import { AutomationContextManagementPage } from '../AutomationContextManagementPage';
|
||||
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
|
||||
import { BoardPage } from '../../../features/board';
|
||||
import { HistoryPage } from '../../../features/history';
|
||||
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') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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 { TestPlayAppView } from '../../../views/play/apps/test/TestPlayAppView';
|
||||
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
|
||||
@@ -32,6 +34,8 @@ export function PlayPage() {
|
||||
/>
|
||||
) : null}
|
||||
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
||||
{selectedPlayMenu === 'draw' ? <LayoutDrawPage /> : null}
|
||||
{selectedPlayMenu === 'apps' ? <AppsLibraryView /> : null}
|
||||
{selectedPlayMenu === 'test' ? <TestPlayAppView /> : null}
|
||||
{selectedPlayMenu === 'cbt' && !isWidgetPreview ? <CbtPlayAppView /> : null}
|
||||
{selectedSavedLayoutId && selectedSavedLayout
|
||||
|
||||
@@ -2,11 +2,14 @@ const PREVIEW_RUNTIME_QUERY_KEY = 'appPreviewMode';
|
||||
const PREVIEW_RUNTIME_PARENT_ORIGIN_KEY = 'previewParentOrigin';
|
||||
const PREVIEW_RUNTIME_TOKEN_QUERY_KEY = 'registeredAccessToken';
|
||||
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_COMPONENT_ID_QUERY_KEY = 'previewComponentId';
|
||||
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_PARAM_KEY = '__previewRuntimeCacheReset';
|
||||
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
|
||||
const PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS = 1200;
|
||||
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
|
||||
PREVIEW_RUNTIME_QUERY_KEY,
|
||||
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
|
||||
@@ -22,6 +25,16 @@ export type PreviewTargetDescriptor =
|
||||
}
|
||||
| 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() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -116,8 +129,17 @@ async function clearPreviewRuntimeServiceWorkersAndCaches() {
|
||||
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() {
|
||||
if (typeof window === 'undefined' || (!isPreviewRuntime() && !isPreviewAppOrigin())) {
|
||||
if (typeof window === 'undefined' || !isPreviewRuntime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,7 +151,7 @@ export async function ensurePreviewRuntimeFreshState() {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed = await clearPreviewRuntimeServiceWorkersAndCaches();
|
||||
const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
|
||||
|
||||
if (!changed) {
|
||||
if (resetSearchParam) {
|
||||
@@ -144,8 +166,8 @@ export async function ensurePreviewRuntimeFreshState() {
|
||||
|
||||
writePreviewRuntimeCacheResetMarker(currentLocationKey);
|
||||
window.location.replace(buildPreviewRuntimeCacheResetUrl());
|
||||
await new Promise(() => {
|
||||
// Keep the bootstrap suspended until the browser navigates away.
|
||||
await new Promise<void>((resolve) => {
|
||||
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() ?? '';
|
||||
}
|
||||
|
||||
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 {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
|
||||
@@ -17,25 +17,31 @@ export type PlanSectionKey =
|
||||
| 'history'
|
||||
| 'automation-type'
|
||||
| 'automation-context'
|
||||
| 'token-setting'
|
||||
| 'shared-resource'
|
||||
| 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults';
|
||||
export type PlaySectionKey = 'layout' | 'test' | 'cbt';
|
||||
export type ChatSectionKey = 'live' | 'rooms' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
|
||||
export type PlaySectionKey = 'layout' | 'draw' | 'apps' | 'test' | 'cbt';
|
||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
|
||||
live: 'Codex Live',
|
||||
rooms: '시스템 채팅',
|
||||
changes: 'Codex Live',
|
||||
resources: '리소스 관리',
|
||||
errors: '앱로그',
|
||||
manage: '채팅 관리',
|
||||
'manage-defaults': '채팅 관리',
|
||||
'manage-share': '채팅 관리',
|
||||
};
|
||||
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
|
||||
live: 'Codex Live',
|
||||
rooms: '시스템 채팅',
|
||||
changes: '변경 이력',
|
||||
resources: '리소스 관리',
|
||||
errors: '에러 로그',
|
||||
manage: '유형 권한 관리',
|
||||
'manage-defaults': '공통 문맥 관리',
|
||||
'manage-share': '공유채팅 생성',
|
||||
};
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'project';
|
||||
@@ -64,11 +70,15 @@ export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
|
||||
history: '이력',
|
||||
'automation-type': '자동화 유형',
|
||||
'automation-context': 'Context 유형',
|
||||
'token-setting': '설정',
|
||||
'shared-resource': '공유 리소스 관리',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = {
|
||||
layout: 'Layout Editor',
|
||||
draw: 'Layout Draw',
|
||||
apps: 'Apps',
|
||||
test: 'Test App',
|
||||
cbt: 'CBT',
|
||||
};
|
||||
@@ -86,6 +96,8 @@ export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
|
||||
history: 'plan-menu-history',
|
||||
'automation-type': 'plan-menu-automation-type',
|
||||
'automation-context': 'plan-menu-automation-context',
|
||||
'token-setting': 'plan-menu-token-setting',
|
||||
'shared-resource': 'plan-menu-shared-resource',
|
||||
'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 }>) {
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -141,6 +159,36 @@ export function buildPlayPath(section: PlaySectionKey = 'layout') {
|
||||
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) {
|
||||
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',
|
||||
icon: <ProfileOutlined />,
|
||||
@@ -256,11 +319,12 @@ function renderChatUnreadLabel(label: string, unreadCount: number) {
|
||||
export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'codex-live-group',
|
||||
key: 'chat-group',
|
||||
icon: <MessageOutlined />,
|
||||
label: renderChatUnreadLabel('Codex Live', unreadCount),
|
||||
label: renderChatUnreadLabel('채팅', unreadCount),
|
||||
children: [
|
||||
{ key: 'live', label: renderChatUnreadLabel('Codex Live', unreadCount) },
|
||||
{ key: 'live', label: 'Codex Live' },
|
||||
{ key: 'rooms', label: '시스템 채팅' },
|
||||
{ key: 'changes', label: '변경 이력' },
|
||||
{ key: 'resources', label: '리소스 관리' },
|
||||
],
|
||||
@@ -278,6 +342,7 @@ export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProp
|
||||
children: [
|
||||
{ key: 'manage', label: '유형 권한 관리' },
|
||||
{ key: 'manage-defaults', label: '공통 문맥 관리' },
|
||||
{ key: 'manage-share', label: '공유채팅 생성' },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -295,6 +360,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
|
||||
label: 'Layout',
|
||||
children: [
|
||||
{ key: 'layout', label: 'Layout Editor' },
|
||||
{ key: 'draw', label: 'Layout Draw' },
|
||||
...(savedLayouts.length
|
||||
? savedLayouts.map((record) => ({
|
||||
key: resolveSavedLayoutMenuKey(record.id),
|
||||
@@ -307,6 +373,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
|
||||
key: 'play-apps-group',
|
||||
label: 'Apps',
|
||||
children: [
|
||||
{ key: 'apps', label: 'Apps' },
|
||||
{ key: 'test', label: 'Test App' },
|
||||
{
|
||||
key: 'play-apps-general-group',
|
||||
@@ -321,7 +388,7 @@ export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: strin
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -380,7 +447,12 @@ export function resolveCurrentPageDescriptor(params: {
|
||||
}
|
||||
|
||||
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 {
|
||||
id: `plans:${planMenu}`,
|
||||
|
||||
539
src/app/main/sharedResourceTokenAccess.ts
Normal file
539
src/app/main/sharedResourceTokenAccess.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
311
src/app/main/systemChat/styles/SystemChatPanel.rooms-shared.css
Normal file
311
src/app/main/systemChat/styles/SystemChatPanel.rooms-shared.css
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
|
||||
export const ALLOWED_REGISTRATION_TOKEN =
|
||||
import.meta.env.VITE_ALLOWED_REGISTRATION_TOKEN?.trim() || 'usr_7f3a9c2d8e1b4a6f';
|
||||
const PREVIEW_RUNTIME_TOKEN_STORAGE_KEY = 'work-app.preview-runtime.registered-token';
|
||||
const PREVIEW_APP_ORIGIN = 'https://preview.sm-home.cloud';
|
||||
|
||||
let previewRuntimeTokenMemory = '';
|
||||
|
||||
@@ -43,6 +44,7 @@ function bootstrapRegisteredAccessToken() {
|
||||
}
|
||||
|
||||
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
|
||||
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
|
||||
|
||||
if (isPreviewRuntime()) {
|
||||
if (!tokenFromUrl) {
|
||||
@@ -57,6 +59,9 @@ function bootstrapRegisteredAccessToken() {
|
||||
}
|
||||
|
||||
if (!tokenFromUrl) {
|
||||
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
|
||||
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,7 +87,14 @@ export function getRegisteredAccessToken() {
|
||||
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() {
|
||||
|
||||
371
src/app/main/tokenSettingAccess.ts
Normal file
371
src/app/main/tokenSettingAccess.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export type MainHeaderProps = {
|
||||
onToggleSidebar: () => void;
|
||||
onToggleContentExpanded: () => void;
|
||||
onChangeTopMenu: (menu: HeaderTopMenuKey) => void;
|
||||
onOpenSearch: () => void;
|
||||
onOpenPlanQuickFilter: (filter: PlanQuickFilter) => void;
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user