Initial import
This commit is contained in:
24
src/app/main/AppShell.tsx
Executable file
24
src/app/main/AppShell.tsx
Executable file
@@ -0,0 +1,24 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { MainLayout } from './layout/MainLayout';
|
||||
import { ApisPage } from './pages/ApisPage';
|
||||
import { ChatPage } from './pages/ChatPage';
|
||||
import { DocsPage } from './pages/DocsPage';
|
||||
import { PlansPage } from './pages/PlansPage';
|
||||
import { buildDocsPath, buildPlansPath } from './routes';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<Navigate to={buildPlansPath('all')} replace />} />
|
||||
<Route path="docs/:folder" element={<DocsPage />} />
|
||||
<Route path="apis/:section" element={<ApisPage />} />
|
||||
<Route path="plans/:section" element={<PlansPage />} />
|
||||
<Route path="chat/:section" element={<ChatPage />} />
|
||||
<Route path="play/layout" element={<Navigate to={buildPlansPath('all')} replace />} />
|
||||
<Route path="play/layout-record/:layoutId" element={<Navigate to={buildPlansPath('all')} replace />} />
|
||||
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
447
src/app/main/ChatNotificationBridge.tsx
Normal file
447
src/app/main/ChatNotificationBridge.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { chatConnectionGateway, chatGateway } from './chatV2';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
sendClientNotification,
|
||||
shouldFallbackToLocalNotification,
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import {
|
||||
getChatClientSessionId,
|
||||
loadStoredChatMessages,
|
||||
persistStoredChatMessages,
|
||||
} from './mainChatPanel';
|
||||
import type { ChatConversationSummary, ChatJobEvent, ChatMessage, ChatViewContext } from './mainChatPanel/types';
|
||||
|
||||
const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
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 createConversationPreviewText(text: string) {
|
||||
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function createChatNotificationBody(text: string, fallback: string) {
|
||||
const preview = createConversationPreviewText(text);
|
||||
return preview || fallback;
|
||||
}
|
||||
|
||||
function createChatQuestionAnswerNotificationBody(args: {
|
||||
questionText?: string | null;
|
||||
answerText?: string | null;
|
||||
fallback: string;
|
||||
}) {
|
||||
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||
|
||||
if (questionPreview && answerPreview) {
|
||||
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (answerPreview) {
|
||||
return `답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (questionPreview) {
|
||||
return `질문: ${questionPreview}`;
|
||||
}
|
||||
|
||||
return args.fallback;
|
||||
}
|
||||
|
||||
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
||||
const questionPreview = createConversationPreviewText(questionText ?? '');
|
||||
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
||||
}
|
||||
|
||||
function normalizeNotificationDetailText(text?: string | null) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function buildChatNotificationLink(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`;
|
||||
}
|
||||
|
||||
async function tryShowLocalChatNotification(args: {
|
||||
title: string;
|
||||
body: string;
|
||||
threadId: string;
|
||||
data: Record<string, string>;
|
||||
}) {
|
||||
await showLocalClientNotification({
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
threadId: args.threadId,
|
||||
data: args.data,
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
function findQuestionText(messages: ChatMessage[], requestId?: string | null) {
|
||||
if (!requestId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function findLatestCodexMessage(messages: ChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author === 'codex') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ChatNotificationBridge() {
|
||||
const { currentPage, focusedComponentId } = useAppStore();
|
||||
const [sessionId] = useState(() => getChatClientSessionId());
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => loadStoredChatMessages(getChatClientSessionId()));
|
||||
const [conversation, setConversation] = useState<ChatConversationSummary | null>(null);
|
||||
const notifiedIncomingMessageKeysRef = useRef<string[]>([]);
|
||||
const notifiedFailedJobKeysRef = useRef<string[]>([]);
|
||||
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const conversationPollInFlightRef = useRef(false);
|
||||
const messagesRef = useRef(messages);
|
||||
const currentContext: ChatViewContext = useMemo(
|
||||
() => ({
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
persistStoredChatMessages(sessionId, messages);
|
||||
}, [messages, sessionId]);
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleIncomingMessageEvent = (incomingMessage: ChatMessage) => {
|
||||
if (incomingMessage.author !== 'codex' || conversation?.notifyOffline !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationKey = `${sessionId}:${incomingMessage.id}:${incomingMessage.timestamp}`;
|
||||
|
||||
if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120);
|
||||
|
||||
const questionText = findQuestionText(messagesRef.current, incomingMessage.clientRequestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: sessionId,
|
||||
conversationTitle: conversation?.title,
|
||||
title: 'Codex Live 새 메시지',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: incomingMessage.text,
|
||||
fallback: createChatNotificationBody(
|
||||
incomingMessage.text,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
createChatNotificationBody(
|
||||
incomingMessage.text,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
),
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
messageId: incomingMessage.id,
|
||||
messageTimestamp: incomingMessage.timestamp,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(incomingMessage.text),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleJobEvent = (event: ChatJobEvent) => {
|
||||
if (event.status !== 'failed' || conversation?.notifyOffline !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationKey = `${sessionId}:${event.requestId}:${event.status}`;
|
||||
|
||||
if (notifiedFailedJobKeysRef.current.includes(notificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80);
|
||||
|
||||
const questionText = findQuestionText(messagesRef.current, event.requestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: sessionId,
|
||||
conversationTitle: conversation?.title,
|
||||
title: 'Codex Live 요청 실패',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: event.message,
|
||||
fallback: `${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`,
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`,
|
||||
),
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
requestId: event.requestId,
|
||||
status: event.status,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(event.message),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const pollConversations = async (seedOnly: boolean) => {
|
||||
if (conversationPollInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversationPollInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversation(items.find((item) => item.sessionId === sessionId) ?? null);
|
||||
|
||||
const enabledItems = items.filter((item) => item.notifyOffline === true);
|
||||
|
||||
if (enabledItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailResults = await Promise.allSettled(
|
||||
enabledItems.map(async (item) => ({
|
||||
item,
|
||||
detail: await chatGateway.getConversationDetail(item.sessionId),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const result of detailResults) {
|
||||
if (cancelled || result.status !== 'fulfilled') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { item, detail } = result.value;
|
||||
const latestCodexMessage = findLatestCodexMessage(detail.messages);
|
||||
|
||||
if (!latestCodexMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousMessageId = lastPolledCodexMessageIdBySessionRef.current[item.sessionId];
|
||||
lastPolledCodexMessageIdBySessionRef.current[item.sessionId] = latestCodexMessage.id;
|
||||
|
||||
if (seedOnly || previousMessageId == null || latestCodexMessage.id <= previousMessageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const notificationKey = `${item.sessionId}:${latestCodexMessage.id}:${latestCodexMessage.timestamp}`;
|
||||
|
||||
if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120);
|
||||
|
||||
const questionText = findQuestionText(detail.messages, latestCodexMessage.clientRequestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: item.sessionId,
|
||||
conversationTitle: item.title,
|
||||
title: 'Codex Live 새 메시지',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: latestCodexMessage.text,
|
||||
fallback: createChatNotificationBody(
|
||||
latestCodexMessage.text,
|
||||
`${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
createChatNotificationBody(
|
||||
latestCodexMessage.text,
|
||||
`${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
),
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
messageId: latestCodexMessage.id,
|
||||
messageTimestamp: latestCodexMessage.timestamp,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(latestCodexMessage.text),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors and retry on the next cycle.
|
||||
} finally {
|
||||
conversationPollInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void pollConversations(true);
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollConversations(false);
|
||||
}, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS);
|
||||
|
||||
const handleResume = () => {
|
||||
void pollConversations(false);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleResume);
|
||||
window.addEventListener('pageshow', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleResume);
|
||||
window.removeEventListener('pageshow', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
chatConnectionGateway.useConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent: handleIncomingMessageEvent,
|
||||
onJobEvent: handleJobEvent,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
258
src/app/main/ChatNotificationBridgeV2.tsx
Normal file
258
src/app/main/ChatNotificationBridgeV2.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
// @ts-nocheck
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
sendClientNotification,
|
||||
shouldFallbackToLocalNotification,
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import { chatGateway } from './chatV2';
|
||||
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) {
|
||||
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function createChatQuestionAnswerNotificationBody(args: {
|
||||
questionText?: string | null;
|
||||
answerText?: string | null;
|
||||
fallback: string;
|
||||
}) {
|
||||
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||
|
||||
if (questionPreview && answerPreview) {
|
||||
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (answerPreview) {
|
||||
return `답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (questionPreview) {
|
||||
return `질문: ${questionPreview}`;
|
||||
}
|
||||
|
||||
return args.fallback;
|
||||
}
|
||||
|
||||
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
||||
const questionPreview = createConversationPreviewText(questionText ?? '');
|
||||
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
||||
}
|
||||
|
||||
function normalizeNotificationDetailText(text?: string | null) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function buildChatNotificationLink(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`;
|
||||
}
|
||||
|
||||
async function tryShowLocalChatNotification(args: {
|
||||
title: string;
|
||||
body: string;
|
||||
threadId: string;
|
||||
data: Record<string, string>;
|
||||
}) {
|
||||
await showLocalClientNotification({
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
threadId: args.threadId,
|
||||
data: args.data,
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
function findLatestCodexMessage(messages: ChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author === 'codex') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findQuestionText(messages: ChatMessage[], requestId?: string | null) {
|
||||
if (!requestId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function findLatestFailedRequest(requests: ChatConversationRequest[]) {
|
||||
for (let index = requests.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = requests[index];
|
||||
|
||||
if (candidate.status === 'failed') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldNotifyWhileAway() {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof document.hasFocus === 'function') {
|
||||
return !document.hasFocus();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldPollConversationNotifications() {
|
||||
if (typeof document === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof document.hasFocus === 'function') {
|
||||
return !document.hasFocus();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getConversationActivityTime(item: { lastMessageAt?: string | null; updatedAt?: string | null }) {
|
||||
const candidate = item.lastMessageAt || item.updatedAt || '';
|
||||
const parsed = candidate ? Date.parse(candidate) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function selectNotificationPollingCandidates<
|
||||
T extends {
|
||||
notifyOffline: boolean;
|
||||
hasUnreadResponse: boolean;
|
||||
currentJobStatus: string | null;
|
||||
currentRequestId: string | null;
|
||||
lastMessageAt: string | null;
|
||||
updatedAt: string;
|
||||
},
|
||||
>(items: T[]) {
|
||||
return items
|
||||
.filter((item) => item.notifyOffline === true)
|
||||
.filter((item) => item.hasUnreadResponse || item.currentJobStatus === 'started' || item.currentJobStatus === 'queued' || item.currentRequestId)
|
||||
.sort((left, right) => getConversationActivityTime(right) - getConversationActivityTime(left))
|
||||
.slice(0, MAX_NOTIFICATION_DETAIL_POLLS);
|
||||
}
|
||||
|
||||
export function ChatNotificationBridgeV2() {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
return null;
|
||||
}
|
||||
68
src/app/main/ChatRuntimeBridge.tsx
Normal file
68
src/app/main/ChatRuntimeBridge.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import {
|
||||
fetchChatRuntimeSnapshot,
|
||||
getChatClientSessionId,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
useChatConnection,
|
||||
} from './mainChatPanel';
|
||||
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatRuntimeBridge() {
|
||||
const { currentPage, focusedComponentId } = useAppStore();
|
||||
const [sessionId] = useState(() => getChatClientSessionId());
|
||||
const [, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
const currentContext: ChatViewContext = useMemo(
|
||||
() => ({
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void fetchChatRuntimeSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) {
|
||||
setSharedChatRuntimeSnapshot(snapshot);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useChatConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
64
src/app/main/ChatRuntimeBridgeV2.tsx
Normal file
64
src/app/main/ChatRuntimeBridgeV2.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { getChatClientSessionId } from './mainChatPanel';
|
||||
import { chatConnectionGateway, chatGateway } from './chatV2';
|
||||
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatRuntimeBridgeV2() {
|
||||
const { currentPage, focusedComponentId } = useAppStore();
|
||||
const [sessionId] = useState(() => getChatClientSessionId());
|
||||
const [, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
const currentContext: ChatViewContext = useMemo(
|
||||
() => ({
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void chatGateway.fetchRuntimeSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) {
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
chatConnectionGateway.useConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
797
src/app/main/ChatSourceChangesPage.tsx
Normal file
797
src/app/main/ChatSourceChangesPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchPlanActionHistories, fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { chatGateway } from './chatV2';
|
||||
import type { ChatMessage } from './mainChatPanel/types';
|
||||
import type { ChatConversationRequest } from './mainChatPanel/types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type ChatSourceChangeEntry = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
conversationTitle: string;
|
||||
requestId: string;
|
||||
requestTitle: string;
|
||||
questionText: string;
|
||||
answerText: string;
|
||||
status: ChatConversationRequest['status'];
|
||||
sourceChangedAt: string;
|
||||
updatedAt: string;
|
||||
featureTags: string[];
|
||||
changedFiles: string[];
|
||||
currentSourceFiles: string[];
|
||||
diffBlocks: string[];
|
||||
deploymentStatus: DeploymentFilterValue;
|
||||
currentSourceStatus: CurrentSourceStatus;
|
||||
};
|
||||
|
||||
type DeploymentFilterValue = 'all' | 'pre-deploy' | 'deployed';
|
||||
type CurrentSourceStatus = 'applied' | 'not-applied';
|
||||
type CurrentSourceFilterValue = 'all' | CurrentSourceStatus;
|
||||
|
||||
const DEPLOYMENT_FILTER_OPTIONS: Array<{ label: string; value: DeploymentFilterValue }> = [
|
||||
{ label: '전체', value: 'all' },
|
||||
{ label: '배포 전', value: 'pre-deploy' },
|
||||
{ label: '배포됨', value: 'deployed' },
|
||||
];
|
||||
|
||||
const CURRENT_SOURCE_FILTER_OPTIONS: Array<{ label: string; value: CurrentSourceFilterValue }> = [
|
||||
{ label: '전체', value: 'all' },
|
||||
{ label: '현재 소스 적용', value: 'applied' },
|
||||
{ label: '현재 소스 미적용', value: 'not-applied' },
|
||||
];
|
||||
|
||||
const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const;
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '미기록';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' });
|
||||
}
|
||||
|
||||
function createCompactText(value: string | null | undefined, limit = 88) {
|
||||
const normalized = String(value ?? '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized;
|
||||
}
|
||||
|
||||
function createRequestTitle(userText: string, fallback: string) {
|
||||
const compact = createCompactText(userText, 72);
|
||||
return compact || fallback;
|
||||
}
|
||||
|
||||
function extractDiffBlocks(text: string) {
|
||||
return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g))
|
||||
.map((match) => match[1]?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasSourceEvidenceText(text: string) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
/```diff[^\n]*\n[\s\S]*?```/m.test(normalized) ||
|
||||
/^(?:diff --git a\/[^\s]+ b\/[^\s]+|\+\+\+ b\/[^\s]+|--- a\/[^\s]+)$/m.test(normalized) ||
|
||||
/\/workspace\/main-project\/[^\s)]+/.test(normalized) ||
|
||||
/\/api\/chat\/resources\/[^\s)`]+/.test(normalized) ||
|
||||
/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/.test(normalized) ||
|
||||
/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeWorkspaceFilePath(value: string) {
|
||||
const normalized = String(value ?? '')
|
||||
.trim()
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^file:\/\//, '')
|
||||
.replace(/[)>.,]+$/, '')
|
||||
.replace(/:\d+(?::\d+)?$/, '');
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const resourceMarker = '/resource/';
|
||||
const resourceIndex = normalized.lastIndexOf(resourceMarker);
|
||||
|
||||
if (resourceIndex >= 0) {
|
||||
const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, '');
|
||||
return innerPath;
|
||||
}
|
||||
|
||||
const workspaceMarker = '/workspace/main-project/';
|
||||
const workspaceIndex = normalized.lastIndexOf(workspaceMarker);
|
||||
|
||||
if (workspaceIndex >= 0) {
|
||||
return normalized.slice(workspaceIndex + workspaceMarker.length);
|
||||
}
|
||||
|
||||
const apiResourceMarker = '/api/chat/resources/';
|
||||
const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker);
|
||||
|
||||
if (apiResourceIndex >= 0) {
|
||||
return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
return normalized.replace(/^\/+/, '').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function isCurrentSourcePath(path: string) {
|
||||
return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix));
|
||||
}
|
||||
|
||||
function extractCurrentSourceFiles(text: string) {
|
||||
const textWithoutChatResourcePaths = text
|
||||
.replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ')
|
||||
.replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' ');
|
||||
|
||||
const diffPathMatches = Array.from(
|
||||
text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
|
||||
)
|
||||
.flatMap((match) => [match[1], match[2], match[3]])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((item) => normalizeWorkspaceFilePath(item))
|
||||
.filter((path) => path && isCurrentSourcePath(path));
|
||||
|
||||
const workspacePathMatches = [
|
||||
...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []),
|
||||
]
|
||||
.map((item) => normalizeWorkspaceFilePath(item))
|
||||
.filter((path) => path && isCurrentSourcePath(path));
|
||||
|
||||
const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? [])
|
||||
.map((item) => normalizeWorkspaceFilePath(item))
|
||||
.filter((path) => path && isCurrentSourcePath(path));
|
||||
|
||||
return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60);
|
||||
}
|
||||
|
||||
function extractChangedFiles(text: string) {
|
||||
const diffPathMatches = Array.from(
|
||||
text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
|
||||
)
|
||||
.flatMap((match) => [match[1], match[2], match[3]])
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const matches = [
|
||||
...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []),
|
||||
...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []),
|
||||
...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []),
|
||||
...(text.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []),
|
||||
...diffPathMatches,
|
||||
];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
matches
|
||||
.map((item) => normalizeWorkspaceFilePath(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).slice(0, 60);
|
||||
}
|
||||
|
||||
function deriveFeatureTags(files: string[]) {
|
||||
const tags = new Set<string>();
|
||||
|
||||
files.forEach((file) => {
|
||||
const segments = file.split('/').filter(Boolean);
|
||||
|
||||
if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) {
|
||||
tags.add(`feature:${segments[2]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) {
|
||||
tags.add(`component:${segments[2]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) {
|
||||
tags.add(`widget:${segments[2]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[0] === 'docs' && segments[1]) {
|
||||
tags.add(`docs:${segments[1]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (segments[0]) {
|
||||
tags.add(segments[0]);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(tags);
|
||||
}
|
||||
|
||||
function isMeaningfulSourceChangeMessage(text: string) {
|
||||
const normalized = text.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized === '응답을 준비하고 있습니다...') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized.length >= 12 || hasSourceEvidenceText(normalized);
|
||||
}
|
||||
|
||||
function getTimeValue(value: string | null | undefined) {
|
||||
const parsed = new Date(value ?? '').getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function appendUniqueText(target: string[], value: string | null | undefined) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized || target.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.push(normalized);
|
||||
}
|
||||
|
||||
function resolveDeploymentStatus(updatedAt: string, latestReleaseCompletedAt: string | null): DeploymentFilterValue {
|
||||
if (!latestReleaseCompletedAt) {
|
||||
return 'pre-deploy';
|
||||
}
|
||||
|
||||
return getTimeValue(updatedAt) > getTimeValue(latestReleaseCompletedAt) ? 'pre-deploy' : 'deployed';
|
||||
}
|
||||
|
||||
function isTestReleaseTarget(value: string | null | undefined) {
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
normalized === 'test' ||
|
||||
normalized.includes('test.sm-home.cloud') ||
|
||||
normalized.includes('/test') ||
|
||||
normalized.startsWith('test-') ||
|
||||
normalized.endsWith('-test') ||
|
||||
normalized.includes(' test ')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSourceChangedAt(
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
) {
|
||||
const timestamps = collectRequestExplanationMessageTimestamps(request, messages, nextRequest);
|
||||
|
||||
return (
|
||||
timestamps[0] ??
|
||||
request.answeredAt ??
|
||||
request.terminalAt ??
|
||||
request.updatedAt ??
|
||||
request.createdAt
|
||||
);
|
||||
}
|
||||
|
||||
function getDeploymentStatusLabel(value: DeploymentFilterValue) {
|
||||
if (value === 'deployed') {
|
||||
return 'test 배포됨';
|
||||
}
|
||||
|
||||
if (value === 'pre-deploy') {
|
||||
return 'test 배포 전';
|
||||
}
|
||||
|
||||
return '전체';
|
||||
}
|
||||
|
||||
function collectRequestExplanationTexts(
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
) {
|
||||
const texts: string[] = [];
|
||||
appendUniqueText(texts, request.responseText);
|
||||
|
||||
if (request.responseMessageId != null) {
|
||||
const matchedMessage = messages.find((message) => message.id === request.responseMessageId);
|
||||
|
||||
if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) {
|
||||
appendUniqueText(texts, matchedMessage.text);
|
||||
}
|
||||
}
|
||||
|
||||
const directMessageMatches = messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.author === 'codex' &&
|
||||
message.clientRequestId === request.requestId &&
|
||||
isMeaningfulSourceChangeMessage(String(message.text ?? '')),
|
||||
);
|
||||
directMessageMatches.forEach((message) => {
|
||||
appendUniqueText(texts, message.text);
|
||||
});
|
||||
|
||||
const requestCreatedAt = getTimeValue(request.createdAt);
|
||||
const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt);
|
||||
const fallbackMessages = messages.filter((message) => {
|
||||
if (message.author !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageTimestamp = getTimeValue(message.timestamp);
|
||||
|
||||
if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isMeaningfulSourceChangeMessage(String(message.text ?? ''));
|
||||
});
|
||||
|
||||
fallbackMessages
|
||||
.filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId)
|
||||
.forEach((message) => {
|
||||
appendUniqueText(texts, message.text);
|
||||
});
|
||||
|
||||
appendUniqueText(texts, request.statusMessage);
|
||||
|
||||
return texts;
|
||||
}
|
||||
|
||||
function collectRequestExplanationMessageTimestamps(
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
) {
|
||||
const timestamps: string[] = [];
|
||||
|
||||
if (request.responseMessageId != null) {
|
||||
const matchedMessage = messages.find((message) => message.id === request.responseMessageId);
|
||||
|
||||
if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) {
|
||||
appendUniqueText(timestamps, matchedMessage.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.author === 'codex' &&
|
||||
message.clientRequestId === request.requestId &&
|
||||
isMeaningfulSourceChangeMessage(String(message.text ?? '')),
|
||||
)
|
||||
.forEach((message) => {
|
||||
appendUniqueText(timestamps, message.timestamp);
|
||||
});
|
||||
|
||||
const requestCreatedAt = getTimeValue(request.createdAt);
|
||||
const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt);
|
||||
messages
|
||||
.filter((message) => {
|
||||
if (message.author !== 'codex') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const messageTimestamp = getTimeValue(message.timestamp);
|
||||
|
||||
if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isMeaningfulSourceChangeMessage(String(message.text ?? ''));
|
||||
})
|
||||
.filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId)
|
||||
.forEach((message) => {
|
||||
appendUniqueText(timestamps, message.timestamp);
|
||||
});
|
||||
|
||||
return timestamps.sort((left, right) => getTimeValue(left) - getTimeValue(right));
|
||||
}
|
||||
|
||||
function resolveRequestExplanation(
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
) {
|
||||
return collectRequestExplanationTexts(request, messages, nextRequest).join('\n\n').trim();
|
||||
}
|
||||
|
||||
function resolveRequestQuestion(request: ChatConversationRequest, messages: ChatMessage[]) {
|
||||
const requestUserText = String(request.userText ?? '').trim();
|
||||
|
||||
if (requestUserText) {
|
||||
return requestUserText;
|
||||
}
|
||||
|
||||
if (request.userMessageId != null) {
|
||||
const matchedMessage = messages.find((message) => message.id === request.userMessageId);
|
||||
|
||||
if (matchedMessage?.author === 'user') {
|
||||
return String(matchedMessage.text ?? '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
const directMatch = messages.find(
|
||||
(message) => message.author === 'user' && message.clientRequestId === request.requestId,
|
||||
);
|
||||
|
||||
return String(directMatch?.text ?? '').trim();
|
||||
}
|
||||
|
||||
function buildSourceChangeEntry(
|
||||
conversationTitle: string,
|
||||
sessionId: string,
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
latestReleaseCompletedAt: string | null,
|
||||
) {
|
||||
const questionText = resolveRequestQuestion(request, messages);
|
||||
const answerText = resolveRequestExplanation(request, messages, nextRequest);
|
||||
const sourceChangedAt = resolveSourceChangedAt(request, messages, nextRequest);
|
||||
const diffBlocks = extractDiffBlocks(answerText);
|
||||
const changedFiles = extractChangedFiles(answerText);
|
||||
const currentSourceFiles = extractCurrentSourceFiles(answerText);
|
||||
const featureTags = deriveFeatureTags(changedFiles);
|
||||
const hasSourceEvidence = diffBlocks.length > 0 || changedFiles.length > 0;
|
||||
|
||||
if (!hasSourceEvidence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${sessionId}:${request.requestId}`,
|
||||
sessionId,
|
||||
conversationTitle,
|
||||
requestId: request.requestId,
|
||||
requestTitle: createRequestTitle(questionText, request.requestId),
|
||||
questionText,
|
||||
answerText,
|
||||
status: request.status,
|
||||
sourceChangedAt,
|
||||
updatedAt: request.updatedAt,
|
||||
featureTags,
|
||||
changedFiles,
|
||||
currentSourceFiles,
|
||||
diffBlocks,
|
||||
deploymentStatus: currentSourceFiles.length > 0
|
||||
? resolveDeploymentStatus(sourceChangedAt, latestReleaseCompletedAt)
|
||||
: 'pre-deploy',
|
||||
currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied',
|
||||
} satisfies ChatSourceChangeEntry;
|
||||
}
|
||||
|
||||
export function ChatSourceChangesPage() {
|
||||
const [entries, setEntries] = useState<ChatSourceChangeEntry[]>([]);
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [deploymentFilter, setDeploymentFilter] = useState<DeploymentFilterValue>('pre-deploy');
|
||||
const [currentSourceFilter, setCurrentSourceFilter] = useState<CurrentSourceFilterValue>('all');
|
||||
const [latestReleaseCompletedAt, setLatestReleaseCompletedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadChanges = async () => {
|
||||
setLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const planItems = await fetchPlanItems('all');
|
||||
const testPlanItems = planItems.filter((item) => isTestReleaseTarget(item.releaseTarget));
|
||||
const actionResults = await Promise.allSettled(planItems.map((item) => fetchPlanActionHistories(item.id)));
|
||||
const nextLatestReleaseCompletedAt =
|
||||
actionResults
|
||||
.flatMap((result, index) =>
|
||||
result.status === 'fulfilled' && testPlanItems.some((item) => item.id === planItems[index]?.id)
|
||||
? result.value
|
||||
: [],
|
||||
)
|
||||
.filter((history) => history.actionType === 'release반영완료')
|
||||
.map((history) => history.createdAt)
|
||||
.sort((left, right) => getTimeValue(right) - getTimeValue(left))[0] ?? null;
|
||||
const conversations = await chatGateway.listConversations();
|
||||
const details = await Promise.allSettled(
|
||||
conversations.map(async (conversation) => ({
|
||||
conversation,
|
||||
detail: await chatGateway.getConversationDetail(conversation.sessionId),
|
||||
})),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntries = details
|
||||
.flatMap((result) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { conversation, detail } = result.value;
|
||||
return detail.requests
|
||||
.map((request, index, requests) =>
|
||||
buildSourceChangeEntry(
|
||||
conversation.title || '새 대화',
|
||||
conversation.sessionId,
|
||||
request,
|
||||
detail.messages,
|
||||
requests[index + 1],
|
||||
nextLatestReleaseCompletedAt,
|
||||
),
|
||||
)
|
||||
.filter((item): item is ChatSourceChangeEntry => Boolean(item));
|
||||
})
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
||||
|
||||
setLatestReleaseCompletedAt(nextLatestReleaseCompletedAt);
|
||||
setEntries(nextEntries);
|
||||
setSelectedEntryId((previous) => {
|
||||
if (previous && nextEntries.some((entry) => entry.id === previous)) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return nextEntries[0]?.id ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Codex Live 변경 이력을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadChanges();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (deploymentFilter !== 'all' && entry.deploymentStatus !== deploymentFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSourceFilter !== 'all' && entry.currentSourceStatus !== currentSourceFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
entry.conversationTitle,
|
||||
entry.requestTitle,
|
||||
entry.questionText,
|
||||
entry.answerText,
|
||||
...entry.featureTags,
|
||||
...entry.changedFiles,
|
||||
...entry.currentSourceFiles,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [currentSourceFilter, deploymentFilter, entries, searchText]);
|
||||
|
||||
const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} className="chat-source-changes-page">
|
||||
<Card className="chat-source-changes-page__card" bordered={false}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Title level={4} className="chat-source-changes-page__title">
|
||||
Codex Live 변경 이력
|
||||
</Title>
|
||||
<Paragraph className="chat-source-changes-page__copy">
|
||||
채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 test 도메인 배포 반영 여부 기준으로 확인합니다.
|
||||
</Paragraph>
|
||||
<Input
|
||||
value={searchText}
|
||||
placeholder="채팅방명, 요청명, 기능, 파일 경로 검색"
|
||||
onChange={(event) => {
|
||||
setSearchText(event.target.value);
|
||||
}}
|
||||
/>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Select
|
||||
value={deploymentFilter}
|
||||
options={DEPLOYMENT_FILTER_OPTIONS}
|
||||
onChange={(value) => {
|
||||
setDeploymentFilter(value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={currentSourceFilter}
|
||||
options={CURRENT_SOURCE_FILTER_OPTIONS}
|
||||
onChange={(values) => {
|
||||
setCurrentSourceFilter(values);
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
{latestReleaseCompletedAt
|
||||
? `최근 test 도메인 배포 완료 시각 기준: ${formatDateTime(latestReleaseCompletedAt)}`
|
||||
: '기록된 test 도메인 배포 완료 이력이 없어 현재 항목은 모두 배포 전으로 표시됩니다.'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Card className="chat-source-changes-page__card" bordered={false}>
|
||||
<div className="chat-source-changes-page__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</Card>
|
||||
) : errorMessage ? (
|
||||
<Card className="chat-source-changes-page__card" bordered={false}>
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</Card>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
<Card className="chat-source-changes-page__card" bordered={false}>
|
||||
<Empty
|
||||
description={
|
||||
entries.length === 0
|
||||
? '소스 수정 흔적이 있는 Codex Live 요청이 아직 없습니다.'
|
||||
: '현재 검색어 또는 필터에 맞는 변경 이력이 없습니다.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="chat-source-changes-page__grid">
|
||||
<Card className="chat-source-changes-page__card chat-source-changes-page__list-card" bordered={false}>
|
||||
<List
|
||||
dataSource={filteredEntries}
|
||||
renderItem={(entry) => (
|
||||
<List.Item
|
||||
key={entry.id}
|
||||
className={entry.id === selectedEntry?.id ? 'chat-source-changes-page__list-item is-active' : 'chat-source-changes-page__list-item'}
|
||||
onClick={() => {
|
||||
setSelectedEntryId(entry.id);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{entry.conversationTitle}</Text>
|
||||
<Tag color={entry.status === 'failed' ? 'error' : 'blue'}>{entry.status}</Tag>
|
||||
<Tag color={entry.currentSourceStatus === 'applied' ? 'cyan' : 'default'}>
|
||||
{entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'}
|
||||
</Tag>
|
||||
<Tag color={entry.deploymentStatus === 'deployed' ? 'green' : 'orange'}>
|
||||
{getDeploymentStatusLabel(entry.deploymentStatus)}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text>{entry.requestTitle}</Text>
|
||||
<Text type="secondary">{formatDateTime(entry.updatedAt)}</Text>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{entry.featureTags.map((tag) => (
|
||||
<Tag key={`${entry.id}:${tag}`}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="chat-source-changes-page__card chat-source-changes-page__detail-card" bordered={false}>
|
||||
{selectedEntry ? (
|
||||
<Space direction="vertical" size={16} className="chat-source-changes-page__detail">
|
||||
<Space direction="vertical" size={4}>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
{selectedEntry.requestTitle}
|
||||
</Title>
|
||||
<Text type="secondary">
|
||||
{selectedEntry.conversationTitle} · 변경 반영 시각 {formatDateTime(selectedEntry.sourceChangedAt)}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={selectedEntry.status === 'failed' ? 'error' : 'blue'}>{selectedEntry.status}</Tag>
|
||||
<Tag color={selectedEntry.currentSourceStatus === 'applied' ? 'cyan' : 'default'}>
|
||||
{selectedEntry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'}
|
||||
</Tag>
|
||||
<Tag color={selectedEntry.deploymentStatus === 'deployed' ? 'green' : 'orange'}>
|
||||
{getDeploymentStatusLabel(selectedEntry.deploymentStatus)}
|
||||
</Tag>
|
||||
{selectedEntry.featureTags.map((tag) => (
|
||||
<Tag key={`detail:${selectedEntry.id}:${tag}`}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<Card size="small" title="질문">
|
||||
<Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{selectedEntry.questionText || '기록된 질문이 없습니다.'}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="답변">
|
||||
<Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>
|
||||
{selectedEntry.answerText || '기록된 답변이 없습니다.'}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={`변경 파일 (${selectedEntry.changedFiles.length})`}>
|
||||
{selectedEntry.changedFiles.length ? (
|
||||
<Space size={[8, 8]} wrap>
|
||||
{selectedEntry.changedFiles.map((file) => (
|
||||
<Tag key={`file:${selectedEntry.id}:${file}`}>{file}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">추출된 변경 파일이 없습니다.</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title={`현재 소스 반영 파일 (${selectedEntry.currentSourceFiles.length})`}>
|
||||
{selectedEntry.currentSourceFiles.length ? (
|
||||
<Space size={[8, 8]} wrap>
|
||||
{selectedEntry.currentSourceFiles.map((file) => (
|
||||
<Tag key={`current-file:${selectedEntry.id}:${file}`} color="cyan">
|
||||
{file}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">현재 소스 기준으로 바로 연결되는 파일은 아직 추출되지 않았습니다.</Text>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description="선택된 변경 이력이 없습니다." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
46
src/app/main/ChatTypeManagementPage.css
Executable file
46
src/app/main/ChatTypeManagementPage.css
Executable file
@@ -0,0 +1,46 @@
|
||||
.chat-type-management-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page .ant-card,
|
||||
.chat-type-management-page .ant-card-body,
|
||||
.chat-type-management-page__card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list,
|
||||
.chat-type-management-page__editor {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item {
|
||||
cursor: pointer;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item--active {
|
||||
border-color: #1677ff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-type-management-page__item-description.ant-typography {
|
||||
margin: 8px 0 10px;
|
||||
}
|
||||
290
src/app/main/ChatTypeManagementPage.tsx
Executable file
290
src/app/main/ChatTypeManagementPage.tsx
Executable file
@@ -0,0 +1,290 @@
|
||||
import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
canUseChatType,
|
||||
CHAT_PERMISSION_ROLE_LABELS,
|
||||
resolveCurrentChatPermissionRoles,
|
||||
upsertChatType,
|
||||
useChatTypeRegistry,
|
||||
type ChatPermissionRole,
|
||||
type ChatTypeRecord,
|
||||
} from './chatTypeAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import './ChatTypeManagementPage.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
type ChatTypeFormValue = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_FORM_VALUE: ChatTypeFormValue = {
|
||||
name: '',
|
||||
description: '',
|
||||
isTemplate: false,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
if (!chatType) {
|
||||
return EMPTY_FORM_VALUE;
|
||||
}
|
||||
|
||||
return {
|
||||
id: chatType.id,
|
||||
name: chatType.name,
|
||||
description: chatType.description,
|
||||
isTemplate: chatType.isTemplate,
|
||||
permissions: chatType.permissions,
|
||||
enabled: chatType.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
|
||||
const selectedChatType = useMemo(
|
||||
() => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null,
|
||||
[chatTypes, selectedChatTypeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(chatTypes[0]?.id ?? null);
|
||||
}, [chatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType));
|
||||
}, [form, isCreating, selectedChatType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailMode !== 'detail') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreating || selectedChatType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDetailMode('list');
|
||||
}, [detailMode, isCreating, selectedChatType]);
|
||||
|
||||
const openCreateForm = () => {
|
||||
setIsCreating(true);
|
||||
setSelectedChatTypeId(null);
|
||||
setDetailMode('detail');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
const openDetail = (chatTypeId: string) => {
|
||||
setIsCreating(false);
|
||||
setSelectedChatTypeId(chatTypeId);
|
||||
setDetailMode('detail');
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedChatType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChatTypes = chatTypes.filter((item) => item.id !== selectedChatType.id);
|
||||
setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(nextChatTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<Card title="컨텍스트 권한 관리" className="chat-type-management-page">
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="관리 페이지는 토큰 등록 사용자만 사용할 수 있습니다."
|
||||
description="설정 > 토큰 관리에서 권한 토큰을 등록한 뒤 컨텍스트와 권한을 관리하세요."
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-type-management-page">
|
||||
{detailMode === 'list' ? (
|
||||
<Card
|
||||
title="컨텍스트 권한 관리"
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateForm}>
|
||||
신규 컨텍스트
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 컨텍스트</Title>
|
||||
<Text type="secondary">{chatTypes.length}건</Text>
|
||||
</div>
|
||||
|
||||
{chatTypes.length > 0 ? (
|
||||
<List
|
||||
dataSource={chatTypes}
|
||||
renderItem={(item) => {
|
||||
const isCurrentUserAllowed = canUseChatType(item, userRoles);
|
||||
const itemClassName =
|
||||
item.id === selectedChatTypeId
|
||||
? 'chat-type-management-page__item chat-type-management-page__item--active'
|
||||
: 'chat-type-management-page__item';
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
className={itemClassName}
|
||||
onClick={() => {
|
||||
openDetail(item.id);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<div className="chat-type-management-page__item-main">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.name}</Text>
|
||||
<Tag color={item.enabled ? 'green' : 'default'}>{item.enabled ? '사용' : '중지'}</Tag>
|
||||
{item.isTemplate ? <Tag color="gold">템플릿</Tag> : null}
|
||||
<Tag color={isCurrentUserAllowed ? 'blue' : 'default'}>
|
||||
{isCurrentUserAllowed ? '현재 사용자 사용 가능' : '현재 사용자 사용 불가'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Paragraph className="chat-type-management-page__item-description">
|
||||
{item.description || '기본 문맥 설명 없음'}
|
||||
</Paragraph>
|
||||
<Space size={[6, 6]} wrap>
|
||||
{item.permissions.map((permission) => (
|
||||
<Tag key={`${item.id}-${permission}`}>{CHAT_PERMISSION_ROLE_LABELS[permission]}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="등록된 컨텍스트가 없습니다." />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card
|
||||
title={isCreating ? '컨텍스트 등록' : '컨텍스트 상세'}
|
||||
className="chat-type-management-page__card"
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={closeDetail}>
|
||||
목록
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
|
||||
<Text type="secondary">이름과 기본 문맥 설명, 권한 대상만 관리하면 채팅에 그대로 반영됩니다.</Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
onFinish={(values) => {
|
||||
const nextChatTypes = upsertChatType(chatTypes, values);
|
||||
setChatTypes(nextChatTypes);
|
||||
|
||||
const savedChatType = nextChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
setIsCreating(false);
|
||||
setSelectedChatTypeId(savedChatType?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
}}
|
||||
>
|
||||
<Form.Item name="id" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="컨텍스트명"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '컨텍스트명을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="예: 운영 문의" />
|
||||
</Form.Item>
|
||||
<Form.Item label="기본 문맥 설명" name="description">
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4, maxRows: 8 }}
|
||||
placeholder="이 컨텍스트에서 기본으로 참고해야 할 문맥을 입력하세요."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="템플릿 요청 여부" name="isTemplate" valuePropName="checked">
|
||||
<Switch checkedChildren="템플릿" unCheckedChildren="일반" />
|
||||
</Form.Item>
|
||||
<Form.Item label="권한 대상" name="permissions">
|
||||
<Checkbox.Group
|
||||
options={[
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS['token-user'], value: 'token-user' },
|
||||
{ label: CHAT_PERMISSION_ROLE_LABELS.guest, value: 'guest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="사용 여부" name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm}>새 입력</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/app/main/HeaderMessageCenter.css
Normal file
165
src/app/main/HeaderMessageCenter.css
Normal file
@@ -0,0 +1,165 @@
|
||||
.header-message-center__trigger.ant-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.header-message-center__summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.header-message-center__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.header-message-center__item {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.header-message-center__item + .header-message-center__item {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.header-message-center__swipe {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.header-message-center__delete-action {
|
||||
position: absolute;
|
||||
inset: 0 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%);
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.header-message-center__swipe.is-swiped .header-message-center__delete-action,
|
||||
.header-message-center__swipe.is-dragging .header-message-center__delete-action {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.header-message-center__item-button {
|
||||
display: block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 16px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
touch-action: pan-y;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.header-message-center__item-button:hover {
|
||||
border-color: rgba(37, 99, 235, 0.32);
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.header-message-center__item.is-unread .header-message-center__item-button {
|
||||
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.header-message-center__item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-message-center__item-time.ant-typography {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-message-center__item-title.ant-typography {
|
||||
margin-bottom: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.header-message-center__item-preview.ant-typography {
|
||||
margin-bottom: 0;
|
||||
color: #475467;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.header-message-center__swipe-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-message-center__detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-message-center__detail-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-message-center__detail-body.ant-typography {
|
||||
margin-bottom: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-message-center__item-button {
|
||||
padding: 12px 12px 34px;
|
||||
}
|
||||
|
||||
.header-message-center__swipe-hint {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
bottom: 10px;
|
||||
display: block;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-message-center__swipe.is-swiped .header-message-center__swipe-hint,
|
||||
.header-message-center__swipe.is-dragging .header-message-center__swipe-hint {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.header-message-center__item-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
509
src/app/main/HeaderMessageCenter.tsx
Normal file
509
src/app/main/HeaderMessageCenter.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import { BellOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Badge, Button, Drawer, Empty, List, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
type NotificationMessageItem,
|
||||
type NotificationMessagePriority,
|
||||
} from './notificationApi';
|
||||
import { useNotificationController } from './chatV2/hooks/useNotificationController';
|
||||
import './HeaderMessageCenter.css';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
const SWIPE_DELETE_THRESHOLD_PX = 88;
|
||||
const SWIPE_DELETE_LIMIT_PX = 132;
|
||||
const TOUCH_TAP_SLOP_PX = 10;
|
||||
|
||||
function getNotificationLinkMetadata(metadata: Record<string, unknown> | null | undefined) {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkUrl = typeof metadata.linkUrl === 'string' ? metadata.linkUrl.trim() : '';
|
||||
const linkLabel = typeof metadata.linkLabel === 'string' ? metadata.linkLabel.trim() : '';
|
||||
|
||||
if (!linkUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
linkUrl,
|
||||
linkLabel: linkLabel || '바로가기',
|
||||
};
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getPriorityTagColor(priority: NotificationMessagePriority) {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'red';
|
||||
case 'high':
|
||||
return 'volcano';
|
||||
case 'low':
|
||||
return 'default';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityLabel(priority: NotificationMessagePriority) {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return '긴급';
|
||||
case 'high':
|
||||
return '높음';
|
||||
case 'low':
|
||||
return '낮음';
|
||||
default:
|
||||
return '보통';
|
||||
}
|
||||
}
|
||||
|
||||
function getMetadataText(metadata: Record<string, unknown> | null | undefined, key: string) {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = metadata[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function getFirstMetadataText(metadata: Record<string, unknown> | null | undefined, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = getMetadataText(metadata, key);
|
||||
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractChatDetailSections(message: NotificationMessageItem | null) {
|
||||
if (!message || message.category !== 'chat') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const questionText = getFirstMetadataText(message.metadata, ['questionText', 'userText', 'requestText', 'question']);
|
||||
const answerText = getFirstMetadataText(message.metadata, ['answerText', 'responseText', 'replyText', 'answer']);
|
||||
|
||||
if (questionText || answerText) {
|
||||
return { questionText, answerText };
|
||||
}
|
||||
|
||||
const bodyLines = String(message.body ?? '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const questionLine = bodyLines.find((line) => line.startsWith('질문:'));
|
||||
const answerLine = bodyLines.find((line) => line.startsWith('답변:'));
|
||||
|
||||
const parsedQuestionText = questionLine ? questionLine.replace(/^질문:\s*/, '').trim() : '';
|
||||
const parsedAnswerText = answerLine ? answerLine.replace(/^답변:\s*/, '').trim() : '';
|
||||
|
||||
if (!parsedQuestionText && !parsedAnswerText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
questionText: parsedQuestionText,
|
||||
answerText: parsedAnswerText,
|
||||
};
|
||||
}
|
||||
|
||||
export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const swipeStartXRef = useRef<number | null>(null);
|
||||
const swipeStartYRef = useRef<number | null>(null);
|
||||
const swipeLockedIdRef = useRef<number | null>(null);
|
||||
const swipeMovedRef = useRef(false);
|
||||
const touchScrollDetectedRef = useRef(false);
|
||||
const suppressClickRef = useRef(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [swipedMessageId, setSwipedMessageId] = useState<number | null>(null);
|
||||
const [draggingMessageId, setDraggingMessageId] = useState<number | null>(null);
|
||||
const [dragOffsetX, setDragOffsetX] = useState(0);
|
||||
const {
|
||||
unreadCount,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
messages,
|
||||
selectedMessage,
|
||||
listLoading,
|
||||
detailLoading,
|
||||
toggleReadLoading,
|
||||
deletingMessageId,
|
||||
listError,
|
||||
detailError,
|
||||
loadMessages,
|
||||
openMessageDetail,
|
||||
handleToggleReadState,
|
||||
handleDeleteMessage: deleteMessage,
|
||||
} = useNotificationController(drawerOpen);
|
||||
|
||||
const resetSwipeState = () => {
|
||||
swipeStartXRef.current = null;
|
||||
swipeStartYRef.current = null;
|
||||
swipeLockedIdRef.current = null;
|
||||
swipeMovedRef.current = false;
|
||||
touchScrollDetectedRef.current = false;
|
||||
setDraggingMessageId(null);
|
||||
setDragOffsetX(0);
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (id: number) => {
|
||||
try {
|
||||
await deleteMessage(id);
|
||||
setSwipedMessageId((current) => (current === id ? null : current));
|
||||
} finally {
|
||||
resetSwipeState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwipeStart = (id: number, clientX: number, clientY: number) => {
|
||||
if (deletingMessageId === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
swipeStartXRef.current = clientX;
|
||||
swipeStartYRef.current = clientY;
|
||||
swipeLockedIdRef.current = id;
|
||||
swipeMovedRef.current = false;
|
||||
touchScrollDetectedRef.current = false;
|
||||
setDraggingMessageId(id);
|
||||
setDragOffsetX(0);
|
||||
setSwipedMessageId((current) => (current === id ? id : null));
|
||||
};
|
||||
|
||||
const handleSwipeMove = (id: number, clientX: number, clientY: number) => {
|
||||
if (swipeLockedIdRef.current !== id || swipeStartXRef.current === null || swipeStartYRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = clientX - swipeStartXRef.current;
|
||||
const deltaY = clientY - swipeStartYRef.current;
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (absDeltaY > TOUCH_TAP_SLOP_PX && absDeltaY > absDeltaX) {
|
||||
touchScrollDetectedRef.current = true;
|
||||
setSwipedMessageId(null);
|
||||
setDraggingMessageId(null);
|
||||
setDragOffsetX(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextOffset = deltaX >= 0 ? 0 : Math.max(deltaX, -SWIPE_DELETE_LIMIT_PX);
|
||||
if (Math.abs(nextOffset) >= 12) {
|
||||
swipeMovedRef.current = true;
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
setDraggingMessageId(id);
|
||||
setDragOffsetX(nextOffset);
|
||||
};
|
||||
|
||||
const handleSwipeEnd = (id: number) => {
|
||||
if (swipeLockedIdRef.current !== id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (touchScrollDetectedRef.current) {
|
||||
resetSwipeState();
|
||||
setSwipedMessageId(null);
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, 0);
|
||||
return false;
|
||||
}
|
||||
|
||||
const shouldDelete = dragOffsetX <= -SWIPE_DELETE_THRESHOLD_PX;
|
||||
const wasSwipeGesture = swipeMovedRef.current;
|
||||
resetSwipeState();
|
||||
|
||||
if (shouldDelete) {
|
||||
void handleDeleteMessage(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
setSwipedMessageId(null);
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, 0);
|
||||
return !wasSwipeGesture;
|
||||
};
|
||||
|
||||
const handleActivateMessage = (id: number) => {
|
||||
if (suppressClickRef.current || swipeMovedRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (draggingMessageId === id || swipedMessageId === id) {
|
||||
setSwipedMessageId(null);
|
||||
resetSwipeState();
|
||||
return;
|
||||
}
|
||||
|
||||
void openMessageDetail(id);
|
||||
};
|
||||
|
||||
const handleOpenLinkedPage = () => {
|
||||
const link = getNotificationLinkMetadata(selectedMessage?.metadata);
|
||||
|
||||
if (!link || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(link.linkUrl, window.location.origin);
|
||||
|
||||
if (url.origin === window.location.origin) {
|
||||
navigate(`${url.pathname}${url.search}${url.hash}`);
|
||||
setDetailOpen(false);
|
||||
setDrawerOpen(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to full navigation for invalid or external urls.
|
||||
}
|
||||
|
||||
window.location.assign(link.linkUrl);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) {
|
||||
resetSwipeState();
|
||||
setSwipedMessageId(null);
|
||||
}
|
||||
}, [drawerOpen]);
|
||||
|
||||
const selectedMessageLink = getNotificationLinkMetadata(selectedMessage?.metadata);
|
||||
const selectedChatDetail = extractChatDetailSections(selectedMessage);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge count={unreadCount} size="small" offset={[-2, 4]}>
|
||||
<Button
|
||||
type="text"
|
||||
className="header-message-center__trigger"
|
||||
icon={<BellOutlined />}
|
||||
aria-label="알림 메시지 열기"
|
||||
onClick={() => {
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
|
||||
<Drawer
|
||||
title="수신 알림"
|
||||
placement="right"
|
||||
width={isMobileViewport ? '100%' : 420}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
extra={
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ReloadOutlined />}
|
||||
aria-label="알림 목록 새로고침"
|
||||
onClick={() => {
|
||||
void loadMessages();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div className="header-message-center__summary">
|
||||
<Text strong>미확인 메시지 {unreadCount}건</Text>
|
||||
<Text type="secondary">최신순으로 최대 30건까지 표시합니다.</Text>
|
||||
</div>
|
||||
|
||||
{listError ? <Alert type="error" showIcon message={listError} /> : null}
|
||||
|
||||
{listLoading ? (
|
||||
<div className="header-message-center__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : messages.length ? (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={messages}
|
||||
renderItem={(item) => (
|
||||
<List.Item className={`header-message-center__item ${item.read ? '' : 'is-unread'}`}>
|
||||
<div
|
||||
className={`header-message-center__swipe ${swipedMessageId === item.id ? 'is-swiped' : ''} ${draggingMessageId === item.id ? 'is-dragging' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="header-message-center__delete-action"
|
||||
aria-label={`${item.title} 알림 삭제`}
|
||||
disabled={deletingMessageId === item.id}
|
||||
onClick={() => {
|
||||
void handleDeleteMessage(item.id);
|
||||
}}
|
||||
>
|
||||
{deletingMessageId === item.id ? '삭제 중' : '삭제'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="header-message-center__item-button"
|
||||
style={{
|
||||
transform:
|
||||
draggingMessageId === item.id
|
||||
? `translateX(${dragOffsetX}px)`
|
||||
: swipedMessageId === item.id
|
||||
? `translateX(-${SWIPE_DELETE_THRESHOLD_PX}px)`
|
||||
: undefined,
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
handleSwipeStart(
|
||||
item.id,
|
||||
event.changedTouches[0]?.clientX ?? 0,
|
||||
event.changedTouches[0]?.clientY ?? 0,
|
||||
);
|
||||
}}
|
||||
onTouchMove={(event) => {
|
||||
handleSwipeMove(
|
||||
item.id,
|
||||
event.changedTouches[0]?.clientX ?? 0,
|
||||
event.changedTouches[0]?.clientY ?? 0,
|
||||
);
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
const shouldOpenDetail = handleSwipeEnd(item.id);
|
||||
|
||||
if (shouldOpenDetail) {
|
||||
suppressClickRef.current = true;
|
||||
void openMessageDetail(item.id);
|
||||
}
|
||||
}}
|
||||
onTouchCancel={() => {
|
||||
handleSwipeEnd(item.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
handleActivateMessage(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="header-message-center__item-header">
|
||||
<Space size={8} wrap>
|
||||
<Tag color={getPriorityTagColor(item.priority)}>{getPriorityLabel(item.priority)}</Tag>
|
||||
<Tag>{item.category}</Tag>
|
||||
{!item.read ? <Tag color="gold">안읽음</Tag> : null}
|
||||
</Space>
|
||||
<Text type="secondary" className="header-message-center__item-time">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
<Title level={5} className="header-message-center__item-title" ellipsis={{ rows: 2 }}>
|
||||
{item.title}
|
||||
</Title>
|
||||
<Paragraph className="header-message-center__item-preview" ellipsis={{ rows: 2 }}>
|
||||
{item.preview}
|
||||
</Paragraph>
|
||||
</button>
|
||||
<div className="header-message-center__swipe-hint">왼쪽으로 밀어 삭제</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="수신된 알림이 없습니다." />
|
||||
)}
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="알림 상세"
|
||||
open={detailOpen}
|
||||
width={isMobileViewport ? 'calc(100vw - 24px)' : 680}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false);
|
||||
}}
|
||||
footer={[
|
||||
<Button key="open-link" disabled={!selectedMessageLink} onClick={handleOpenLinkedPage}>
|
||||
{selectedMessageLink?.linkLabel ?? '바로가기'}
|
||||
</Button>,
|
||||
<Button key="toggle-read" loading={toggleReadLoading} disabled={!selectedMessage} onClick={() => { void handleToggleReadState(); }}>
|
||||
{selectedMessage?.read ? '안읽음으로 변경' : '읽음 처리'}
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => { setDetailOpen(false); }}>
|
||||
닫기
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{detailError ? <Alert type="error" showIcon message={detailError} style={{ marginBottom: 16 }} /> : null}
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="header-message-center__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : selectedMessage ? (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space size={8} wrap>
|
||||
<Tag color={getPriorityTagColor(selectedMessage.priority)}>
|
||||
{getPriorityLabel(selectedMessage.priority)}
|
||||
</Tag>
|
||||
<Tag>{selectedMessage.category}</Tag>
|
||||
<Tag>{selectedMessage.source}</Tag>
|
||||
<Tag color={selectedMessage.read ? 'green' : 'gold'}>
|
||||
{selectedMessage.read ? '읽음' : '안읽음'}
|
||||
</Tag>
|
||||
</Space>
|
||||
<div className="header-message-center__detail-meta">
|
||||
<Text type="secondary">수신 시각 {formatDateTime(selectedMessage.createdAt)}</Text>
|
||||
<Text type="secondary">확인 시각 {formatDateTime(selectedMessage.readAt)}</Text>
|
||||
</div>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{selectedMessage.title}
|
||||
</Title>
|
||||
{selectedChatDetail ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{selectedChatDetail.questionText ? (
|
||||
<div className="header-message-center__detail-section">
|
||||
<Text type="secondary">요청내용</Text>
|
||||
<Paragraph className="header-message-center__detail-body">
|
||||
{selectedChatDetail.questionText}
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedChatDetail.answerText ? (
|
||||
<div className="header-message-center__detail-section">
|
||||
<Text type="secondary">답변</Text>
|
||||
<Paragraph className="header-message-center__detail-body">
|
||||
{selectedChatDetail.answerText}
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
) : (
|
||||
<Paragraph className="header-message-center__detail-body">
|
||||
{selectedMessage.body}
|
||||
</Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty description="표시할 알림이 없습니다." />
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
src/app/main/InitialLoadingOverlay.css
Executable file
144
src/app/main/InitialLoadingOverlay.css
Executable file
@@ -0,0 +1,144 @@
|
||||
.app-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(59, 130, 246, 0.24), transparent 30%),
|
||||
linear-gradient(135deg, rgba(2, 6, 23, 0.96), rgba(15, 23, 42, 0.98));
|
||||
animation: app-loading-overlay-fade 0.45s ease-out 1.45s forwards;
|
||||
}
|
||||
|
||||
.app-loading-panel {
|
||||
width: min(100%, 560px);
|
||||
padding: 28px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.22);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96)),
|
||||
rgba(15, 23, 42, 0.92);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.55),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.app-loading-panel__eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 10px;
|
||||
color: rgba(147, 197, 253, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.24em;
|
||||
}
|
||||
|
||||
.app-loading-panel__title {
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
color: #f8fafc;
|
||||
font-size: clamp(26px, 5vw, 40px);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.app-loading-panel__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 22px;
|
||||
color: rgba(191, 219, 254, 0.88);
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.app-loading-panel__pulse {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #38bdf8;
|
||||
box-shadow: 0 0 0 rgba(56, 189, 248, 0.5);
|
||||
animation: app-loading-pulse 1.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.app-loading-log {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-loading-log__line {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.14);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
color: #dbeafe;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
animation: app-loading-log-in 0.55s ease-out forwards;
|
||||
}
|
||||
|
||||
.app-loading-log__time {
|
||||
color: rgba(125, 211, 252, 0.72);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.app-loading-log__text {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
@keyframes app-loading-log-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-loading-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.46);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 12px rgba(56, 189, 248, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes app-loading-overlay-fade {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-loading-panel {
|
||||
padding: 22px 18px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.app-loading-log__line {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
36
src/app/main/InitialLoadingOverlay.tsx
Executable file
36
src/app/main/InitialLoadingOverlay.tsx
Executable file
@@ -0,0 +1,36 @@
|
||||
import './InitialLoadingOverlay.css';
|
||||
|
||||
const INITIAL_LOADING_LOGS = [
|
||||
'BOOT SEQUENCE :: app shell warmup',
|
||||
'CONFIG SYNC :: workspace profile applied',
|
||||
'SESSION LINK :: reconnecting realtime channel',
|
||||
'MODULE CHECK :: dashboard widgets online',
|
||||
'READY SIGNAL :: rendering main viewport',
|
||||
];
|
||||
|
||||
export function InitialLoadingOverlay() {
|
||||
return (
|
||||
<div className="app-loading-overlay" aria-hidden="true">
|
||||
<div className="app-loading-panel">
|
||||
<span className="app-loading-panel__eyebrow">INITIALIZING</span>
|
||||
<strong className="app-loading-panel__title">AI CODE APP</strong>
|
||||
<div className="app-loading-panel__status">
|
||||
<span className="app-loading-panel__pulse" />
|
||||
live startup log
|
||||
</div>
|
||||
<div className="app-loading-log" role="presentation">
|
||||
{INITIAL_LOADING_LOGS.map((log, index) => (
|
||||
<div
|
||||
key={log}
|
||||
className="app-loading-log__line"
|
||||
style={{ animationDelay: `${index * 0.18}s` }}
|
||||
>
|
||||
<span className="app-loading-log__time">0{index}:00</span>
|
||||
<span className="app-loading-log__text">{log}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
752
src/app/main/MainChatPanel.css
Executable file
752
src/app/main/MainChatPanel.css
Executable file
@@ -0,0 +1,752 @@
|
||||
.app-chat-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: calc(100dvh - 128px);
|
||||
max-height: calc(100dvh - 128px);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%),
|
||||
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(241, 247, 255, 0.98), rgba(230, 240, 252, 0.94));
|
||||
}
|
||||
|
||||
.app-chat-panel .ant-card-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel__stack {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__view-switcher {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-chat-panel__view-switcher .ant-btn {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-chat-panel__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.app-chat-panel__meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__meta-row--secondary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-chat-panel__meta-tag.ant-tag {
|
||||
margin-inline-end: 0;
|
||||
padding-inline: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid rgba(191, 219, 254, 0.8);
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.app-chat-panel__connection-dot {
|
||||
display: inline-flex;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.app-chat-panel__connection-dot--connected {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-chat-panel__connection-dot--disconnected {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.app-chat-panel__status-copy {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-chat-panel__messages {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 4px 2px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
max-width: min(78%, 720px);
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-chat-message--codex {
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(180deg, #e8f1ff, #f4f8ff);
|
||||
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.app-chat-message--system {
|
||||
align-self: center;
|
||||
max-width: min(92%, 760px);
|
||||
background: linear-gradient(180deg, #f8fafc, #f1f5f9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.app-chat-message--user {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(180deg, #e9fff3, #f3fff8);
|
||||
border: 1px solid rgba(22, 163, 74, 0.18);
|
||||
}
|
||||
|
||||
.app-chat-message__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-message__header-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-chat-message__header-meta .ant-btn {
|
||||
color: rgba(71, 85, 105, 0.88);
|
||||
}
|
||||
|
||||
.app-chat-message__body {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 18px 36px rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-type {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-type .ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer .ant-input-textarea {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer .ant-btn {
|
||||
flex: none;
|
||||
height: 44px;
|
||||
padding-inline: 18px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-layout {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
padding: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98));
|
||||
}
|
||||
|
||||
.app-chat-panel__error-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-toolbar-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-loading,
|
||||
.app-chat-panel__error-empty {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-list,
|
||||
.app-chat-panel__error-detail {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
max-height: min(78vh, 1120px);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.96));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item--active {
|
||||
border-color: rgba(37, 99, 235, 0.4);
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item-message.ant-typography {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-item-badges .ant-tag {
|
||||
margin-inline-end: 0;
|
||||
padding-inline: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 24px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-actions .ant-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-stage {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 240px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 720px;
|
||||
padding-right: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-item-snippet.ant-typography {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-item--active {
|
||||
border-color: rgba(37, 99, 235, 0.42);
|
||||
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.98));
|
||||
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-item-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main-meta-item .ant-typography:last-child {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-main-meta-item--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 32px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-header-meta {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-summary-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.96));
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-meta-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(241, 245, 249, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-line.ant-typography {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-tabs .ant-tabs-content-holder,
|
||||
.app-chat-panel__error-tabs .ant-tabs-content,
|
||||
.app-chat-panel__error-tabs .ant-tabs-tabpane {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-tab-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-tabs .previewer-ui {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-tabs .previewer-ui__body.previewer-ui__scroll {
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-modal .ant-modal {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-modal .ant-modal-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail--modal {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail--expanded {
|
||||
height: 100%;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98));
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(248, 250, 252, 0.88);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame-wrap {
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: linear-gradient(180deg, rgba(241, 245, 249, 0.8), rgba(255, 255, 255, 0.96));
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame {
|
||||
width: 100%;
|
||||
height: 460px;
|
||||
border: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-card--stage {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame--stage {
|
||||
height: min(78vh, 980px);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-url.ant-typography {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-source-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(248, 250, 252, 0.82);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-source-preview pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: #334155;
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.app-chat-panel__error-content {
|
||||
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-stage {
|
||||
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-chat-panel__error-content,
|
||||
.app-chat-panel__error-reference-stage,
|
||||
.app-chat-panel__error-reference-main-meta,
|
||||
.app-chat-panel__error-summary-strip,
|
||||
.app-chat-panel__error-detail-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame--stage {
|
||||
height: min(62vh, 720px);
|
||||
}
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-block pre {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-chat-panel {
|
||||
position: static;
|
||||
height: calc(100dvh - 112px);
|
||||
max-height: calc(100dvh - 112px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-chat-panel {
|
||||
height: calc(100dvh - 76px);
|
||||
max-height: calc(100dvh - 76px);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel.ant-card .ant-card-head {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel.ant-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-chat-message {
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
.app-chat-panel__meta-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-chat-message__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-toolbar,
|
||||
.app-chat-panel__error-item-top,
|
||||
.app-chat-panel__error-reference-main-top,
|
||||
.app-chat-panel__view-switcher {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-header {
|
||||
align-items: flex-start;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-content {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-meta {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-reference-stage {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-chat-panel__error-summary-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-actions {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-detail-actions .ant-btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame {
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.app-chat-panel__error-preview-frame--stage {
|
||||
height: 420px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
2723
src/app/main/MainChatPanel.hotfix.css
Normal file
2723
src/app/main/MainChatPanel.hotfix.css
Normal file
File diff suppressed because it is too large
Load Diff
2502
src/app/main/MainChatPanel.tsx
Normal file
2502
src/app/main/MainChatPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
328
src/app/main/MainContent.tsx
Executable file
328
src/app/main/MainContent.tsx
Executable file
@@ -0,0 +1,328 @@
|
||||
import { CompressOutlined } from '@ant-design/icons';
|
||||
import { Button, Layout, Modal, Space, Typography } from 'antd';
|
||||
import { useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { WindowUI, type WindowFrame } from '../../components/window';
|
||||
import { BoardPage } from '../../features/board';
|
||||
import { ComponentSamplesLayout } from '../../features/layout/component-sample-gallery';
|
||||
import { SampleWidgetsLayout } from '../../features/layout/widget-sample-gallery';
|
||||
import { HistoryPage } from '../../features/history';
|
||||
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../features/planBoard';
|
||||
import { useSearchLayer } from '../../layer';
|
||||
import { useAppStore } from '../../store';
|
||||
import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView';
|
||||
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
|
||||
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
|
||||
import { MainChatPanel } from './MainChatPanel';
|
||||
import { useMainLayoutContext } from './layout/MainLayoutContext';
|
||||
import {
|
||||
HIDDEN_COMPONENT_IDS,
|
||||
buildCascadeWindowFrames,
|
||||
buildGridWindowFrames,
|
||||
getDefaultWindowFrame,
|
||||
getPlanStatusFromWindowSelection,
|
||||
} from './mainContent/windowLayout';
|
||||
import type { MainContentProps } from './types';
|
||||
|
||||
const { Content } = Layout;
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
export function MainContent({
|
||||
contentExpanded,
|
||||
onToggleContentExpanded,
|
||||
children,
|
||||
}: MainContentProps) {
|
||||
const { setFocusedComponentId } = useAppStore();
|
||||
const { windowSelections, clearWindowSelection } = useSearchLayer();
|
||||
const {
|
||||
componentSampleEntries,
|
||||
widgetSampleEntries,
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
initialSelectedPlanId,
|
||||
initialSelectedWorkId,
|
||||
} = useMainLayoutContext();
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [windowFrames, setWindowFrames] = useState<Record<string, WindowFrame>>({});
|
||||
const [windowZIndexes, setWindowZIndexes] = useState<Record<string, number>>({});
|
||||
const [isWindowLayoutModalOpen, setIsWindowLayoutModalOpen] = useState(false);
|
||||
const sampleEntryMap = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...componentSamples, ...widgetSamples].map((entry) => [
|
||||
`${entry.modulePath.includes('/widgets/') ? 'widget' : 'component'}:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
entry,
|
||||
]),
|
||||
),
|
||||
[componentSamples, widgetSamples],
|
||||
);
|
||||
const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame =>
|
||||
windowFrames[instanceId] ?? getDefaultWindowFrame(selectionId, index);
|
||||
|
||||
const getWindowZIndex = (instanceId: string, index: number) => windowZIndexes[instanceId] ?? index + 1;
|
||||
|
||||
const activateWindow = (instanceId: string) => {
|
||||
setWindowZIndexes((previous) => {
|
||||
const highestZIndex = Math.max(0, ...Object.values(previous));
|
||||
return {
|
||||
...previous,
|
||||
[instanceId]: highestZIndex + 1,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getStageSize = () => {
|
||||
const stage = stageRef.current;
|
||||
|
||||
if (!stage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
width: stage.clientWidth,
|
||||
height: stage.clientHeight,
|
||||
};
|
||||
};
|
||||
|
||||
const applyCascadeWindowLayout = () => {
|
||||
const stageSize = getStageSize();
|
||||
|
||||
if (!stageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrames = buildCascadeWindowFrames(windowSelections, stageSize);
|
||||
|
||||
setWindowFrames((previous) => ({
|
||||
...previous,
|
||||
...nextFrames,
|
||||
}));
|
||||
setIsWindowLayoutModalOpen(false);
|
||||
};
|
||||
|
||||
const applyGridWindowLayout = () => {
|
||||
const stageSize = getStageSize();
|
||||
|
||||
if (!stageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFrames = buildGridWindowFrames(windowSelections, stageSize);
|
||||
|
||||
setWindowFrames((previous) => ({
|
||||
...previous,
|
||||
...nextFrames,
|
||||
}));
|
||||
setIsWindowLayoutModalOpen(false);
|
||||
};
|
||||
|
||||
const handleFocusCapture = (target: EventTarget | null) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusedElement = target.closest<HTMLElement>('[data-focus-id]');
|
||||
setFocusedComponentId(focusedElement?.dataset.focusId ?? null);
|
||||
};
|
||||
|
||||
const renderWindowSelectionContent = (selectionId: string, fallbackContent: ReactNode) => {
|
||||
if (selectionId === 'page:apis:components') {
|
||||
return (
|
||||
<ComponentSamplesLayout
|
||||
entries={componentSampleEntries}
|
||||
excludeComponentIds={HIDDEN_COMPONENT_IDS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectionId === 'page:apis:widgets') {
|
||||
return <SampleWidgetsLayout entries={widgetSampleEntries} />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:release') {
|
||||
return (
|
||||
<PlanBoardPage
|
||||
statusFilter="done"
|
||||
quickFilter="release-pending-main"
|
||||
initialSelectedPlanId={initialSelectedPlanId}
|
||||
initialSelectedWorkId={initialSelectedWorkId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:board') {
|
||||
return <BoardPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:release-review') {
|
||||
return <ReleaseReviewPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:charts') {
|
||||
return <PlanBoardChartsPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:schedule') {
|
||||
return <PlanSchedulePage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:plans:history') {
|
||||
return <HistoryPage />;
|
||||
}
|
||||
|
||||
const planStatus = getPlanStatusFromWindowSelection(selectionId);
|
||||
|
||||
if (planStatus) {
|
||||
return (
|
||||
<PlanBoardPage
|
||||
statusFilter={planStatus}
|
||||
quickFilter={null}
|
||||
initialSelectedPlanId={initialSelectedPlanId}
|
||||
initialSelectedWorkId={initialSelectedWorkId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:live') {
|
||||
return <MainChatPanel initialView="live" />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:errors') {
|
||||
return <MainChatPanel initialView="errors" />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:changes') {
|
||||
return <ChatSourceChangesPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:chat:manage') {
|
||||
return <ChatTypeManagementPage />;
|
||||
}
|
||||
|
||||
if (selectionId === 'page:play:layout') {
|
||||
return <LayoutPlaygroundView />;
|
||||
}
|
||||
|
||||
return fallbackContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<Content
|
||||
className={contentExpanded ? 'app-main-content app-main-content--expanded' : 'app-main-content'}
|
||||
onClickCapture={(event) => {
|
||||
handleFocusCapture(event.target);
|
||||
}}
|
||||
onFocusCapture={(event) => {
|
||||
handleFocusCapture(event.target);
|
||||
}}
|
||||
>
|
||||
{contentExpanded ? (
|
||||
<Button
|
||||
type="text"
|
||||
className="app-main-content__restore"
|
||||
aria-label="전체화면 종료"
|
||||
icon={<CompressOutlined />}
|
||||
onClick={onToggleContentExpanded}
|
||||
/>
|
||||
) : null}
|
||||
<div className="app-main-layout">{children}</div>
|
||||
{windowSelections.length > 0 ? (
|
||||
<div className="app-main-window-layer">
|
||||
<div ref={stageRef} className="app-main-window-layer__stage">
|
||||
{windowSelections.map((windowSelection, index) => {
|
||||
const selectedWindowSample = sampleEntryMap.get(windowSelection.id) ?? null;
|
||||
const SelectedWindowSample = selectedWindowSample?.Sample ?? null;
|
||||
const fallbackContent = (
|
||||
<div className="app-main-window-layer__fallback">
|
||||
<Text strong>{windowSelection.label}</Text>
|
||||
<div className="app-main-window-layer__keywords">
|
||||
{(windowSelection.keywords.length > 0 ? windowSelection.keywords : ['검색 선택']).map((keyword) => (
|
||||
<Text key={`${windowSelection.instanceId}-${keyword}`} code>
|
||||
{keyword}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isWidgetWindow = windowSelection.id.startsWith('widget:');
|
||||
|
||||
return (
|
||||
<WindowUI
|
||||
key={windowSelection.instanceId}
|
||||
title={windowSelection.label}
|
||||
subtitle={isWidgetWindow ? undefined : windowSelection.group}
|
||||
bodyClassName="app-main-window-layer__body"
|
||||
frame={getWindowFrame(windowSelection.instanceId, windowSelection.id, index)}
|
||||
onFrameChange={(nextFrame) => {
|
||||
setWindowFrames((previous) => ({
|
||||
...previous,
|
||||
[windowSelection.instanceId]: nextFrame,
|
||||
}));
|
||||
}}
|
||||
zIndex={getWindowZIndex(windowSelection.instanceId, index)}
|
||||
onActivate={() => {
|
||||
activateWindow(windowSelection.instanceId);
|
||||
}}
|
||||
onOpenLayoutControls={() => {
|
||||
setIsWindowLayoutModalOpen(true);
|
||||
}}
|
||||
className="app-main-window-layer__window"
|
||||
onClose={() => {
|
||||
clearWindowSelection(windowSelection.instanceId);
|
||||
}}
|
||||
>
|
||||
{SelectedWindowSample ? (
|
||||
<div
|
||||
className={`app-main-window-layer__sample ${
|
||||
isWidgetWindow
|
||||
? 'app-main-window-layer__sample--fill'
|
||||
: 'app-main-window-layer__sample--intrinsic'
|
||||
}`}
|
||||
>
|
||||
<SelectedWindowSample
|
||||
disableWidgetCardWrapper={isWidgetWindow}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-main-window-layer__sample">
|
||||
{renderWindowSelectionContent(windowSelection.id, fallbackContent)}
|
||||
</div>
|
||||
)}
|
||||
</WindowUI>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Modal
|
||||
title="윈도우 배치"
|
||||
open={isWindowLayoutModalOpen}
|
||||
onCancel={() => {
|
||||
setIsWindowLayoutModalOpen(false);
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Title level={5}>추천 배치</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
주요 창을 크게 두고 나머지 창은 겹치지 않게 순서대로 정렬합니다.
|
||||
</Paragraph>
|
||||
<Button block onClick={applyCascadeWindowLayout}>
|
||||
추천 배치 적용
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>가득 채우기</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
현재 열린 창을 작업 영역 기준 그리드로 꽉 차게 재배치합니다.
|
||||
</Paragraph>
|
||||
<Button block onClick={applyGridWindowLayout}>
|
||||
가득 채우기 적용
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
3231
src/app/main/MainHeader.tsx
Executable file
3231
src/app/main/MainHeader.tsx
Executable file
File diff suppressed because it is too large
Load Diff
727
src/app/main/MainLayout.css
Executable file
727
src/app/main/MainLayout.css
Executable file
@@ -0,0 +1,727 @@
|
||||
.app-shell {
|
||||
min-height: 100dvh;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-chat-panel) {
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell:has(.app-chat-panel) > .ant-layout {
|
||||
min-height: 0;
|
||||
height: calc(100dvh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-shell--docs-api {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(22, 93, 255, 0.12), transparent 26%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eff5ff 45%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
padding: 0 18px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.16);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.app-header__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header__menu-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-header__row .ant-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #182230;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator:hover {
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--connected {
|
||||
border-color: rgba(22, 163, 74, 0.2);
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--connecting {
|
||||
border-color: rgba(37, 99, 235, 0.24);
|
||||
background: rgba(239, 246, 255, 0.92);
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--disconnected {
|
||||
border-color: rgba(220, 38, 38, 0.22);
|
||||
background: rgba(254, 242, 242, 0.92);
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--busy {
|
||||
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.16);
|
||||
}
|
||||
|
||||
.app-header__connection-count-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
.app-header__connection-indicator--busy .app-header__connection-count-badge {
|
||||
animation: app-header-connection-badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.app-header__connection-count-badge--connecting {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-header__connection-count-badge--disconnected {
|
||||
background: #dc2626;
|
||||
box-shadow: 0 6px 16px rgba(220, 38, 38, 0.22);
|
||||
}
|
||||
|
||||
@keyframes app-header-connection-badge-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.24);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 10px 22px rgba(37, 99, 235, 0.34);
|
||||
}
|
||||
}
|
||||
|
||||
.app-header__settings-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 132px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
color: #182230;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-header__settings-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__settings-item:hover {
|
||||
background: #f3f7ff;
|
||||
}
|
||||
|
||||
.app-header__settings-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.app-header__status-dot {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.app-header__status-dot--active {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.app-header__status-dot--inactive {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.app-header__status-dot--warning {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.app-header__status-dot--progress {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-header__settings-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header__update-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.16);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(239, 245, 255, 0.96)),
|
||||
rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.app-header__update-progress-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.app-header__update-progress-task {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
}
|
||||
|
||||
.app-header__update-progress .ant-progress {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(239, 245, 255, 0.92));
|
||||
}
|
||||
|
||||
.app-header__runtime-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__runtime-list-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.app-header__runtime-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header__runtime-list-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header__runtime-list-copy .ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary-text.ant-typography {
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.app-header__runtime-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.app-header__runtime-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.app-header__menu-side .ant-segmented {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.app-sider--mobile.ant-layout-sider {
|
||||
position: fixed;
|
||||
inset: 72px 0 0;
|
||||
z-index: 40;
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
height: calc(100vh - 72px);
|
||||
border-right: 0;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
.app-sider__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.app-sider__intro {
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: calc(100dvh - 60px);
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content:has(.app-chat-panel) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-content--expanded.ant-layout-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-main-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main-panel--play {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-main-panel--play > * {
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.app-chat-panel) {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.app-main-content.ant-layout-content,
|
||||
.app-main-panel,
|
||||
.app-main-layout {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.app-main-content.ant-layout-content {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-main-panel,
|
||||
.app-main-layout {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel),
|
||||
.app-main-layout:has(.app-chat-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.app-header__row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.app-main-layout {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app-main-layout > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-main-layout--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.app-main-content--expanded .app-main-panel,
|
||||
.app-main-content--expanded .app-main-card {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-main-content__restore.ant-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 30;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.app-main-card {
|
||||
border-radius: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card,
|
||||
.app-chat-panel.ant-card {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.app-main-card.ant-card .ant-card-head {
|
||||
min-height: auto;
|
||||
padding: 16px 20px 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card .ant-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 12px 20px 20px;
|
||||
}
|
||||
|
||||
.app-main-copy.ant-typography {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.app-main-window-layer {
|
||||
position: absolute;
|
||||
inset: 16px;
|
||||
z-index: 25;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-main-window-layer__stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: calc(100dvh - 92px);
|
||||
overflow: hidden;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.app-main-window-layer__window {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.app-main-window-layer__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 0 !important;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-main-window-layer__fallback {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample--intrinsic {
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample--intrinsic > * {
|
||||
flex: 0 0 auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample--fill {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer__sample--fill > * {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-main-window-layer__keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-main-stack {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-main-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 60px);
|
||||
min-height: calc(100dvh - 60px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-main-layout:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 60px);
|
||||
min-height: calc(100dvh - 60px);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell:has(.app-chat-panel) > .ant-layout {
|
||||
height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 6px 10px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.app-header__row {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__menu-side {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-header__row .ant-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.app-header__runtime-summary {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-sider.ant-layout-sider {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.app-sider--mobile.ant-layout-sider {
|
||||
position: fixed;
|
||||
inset: 52px 0 0;
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content {
|
||||
padding: 0;
|
||||
min-height: calc(100dvh - 52px);
|
||||
}
|
||||
|
||||
.app-main-layout {
|
||||
min-height: calc(100dvh - 52px);
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-main-window-layer {
|
||||
inset: 8px;
|
||||
}
|
||||
|
||||
.app-main-window-layer__stage {
|
||||
min-height: calc(100dvh - 68px);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.app-main-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.app-main-card.ant-card .ant-card-head {
|
||||
padding: 14px 16px 0;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card .ant-card-head-title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-main-card.ant-card .ant-card-body {
|
||||
padding: 10px 16px 16px;
|
||||
}
|
||||
|
||||
.app-main-panel:has(.app-chat-panel) {
|
||||
height: calc(100dvh - 76px);
|
||||
min-height: calc(100dvh - 76px);
|
||||
}
|
||||
}
|
||||
119
src/app/main/MainSidebar.tsx
Executable file
119
src/app/main/MainSidebar.tsx
Executable file
@@ -0,0 +1,119 @@
|
||||
import { Layout, Menu, Space, Tag, Typography } from 'antd';
|
||||
import type { MainSidebarProps } from './types';
|
||||
|
||||
const { Sider } = Layout;
|
||||
const { Text } = Typography;
|
||||
|
||||
export function MainSidebar({
|
||||
activeTopMenu,
|
||||
hasAccess,
|
||||
sidebarCollapsed,
|
||||
isMobileViewport,
|
||||
openKeys: controlledOpenKeys,
|
||||
apiMenuItems,
|
||||
docsMenuItems,
|
||||
planMenuItems,
|
||||
chatMenuItems,
|
||||
playMenuItems,
|
||||
selectedApiMenu,
|
||||
selectedDocsMenu,
|
||||
selectedPlanMenu,
|
||||
selectedChatMenu,
|
||||
selectedPlayMenu,
|
||||
introColor,
|
||||
introTag,
|
||||
introDescription,
|
||||
onOpenKeysChange,
|
||||
onSelectApiMenu,
|
||||
onSelectDocsMenu,
|
||||
onSelectPlanMenu,
|
||||
onSelectChatMenu,
|
||||
onSelectPlayMenu,
|
||||
}: MainSidebarProps) {
|
||||
const effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu;
|
||||
const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis';
|
||||
const visibleOpenKeys = sidebarCollapsed
|
||||
? []
|
||||
: controlledOpenKeys.length
|
||||
? controlledOpenKeys
|
||||
: isDocsGroup
|
||||
? !hasAccess
|
||||
? ['docs-group']
|
||||
: ['docs-group', 'api-group']
|
||||
: effectiveTopMenu === 'play'
|
||||
? ['play-group', 'play-layout-group']
|
||||
: ['plan-group', 'codex-live-group', 'app-log-group', 'chat-manage-group'];
|
||||
const selectedKeys =
|
||||
effectiveTopMenu === 'docs'
|
||||
? [selectedDocsMenu]
|
||||
: effectiveTopMenu === 'apis'
|
||||
? [selectedApiMenu]
|
||||
: effectiveTopMenu === 'plans'
|
||||
? [selectedPlanMenu]
|
||||
: effectiveTopMenu === 'play'
|
||||
? [selectedPlayMenu]
|
||||
: [selectedChatMenu];
|
||||
const sidebarItems =
|
||||
isDocsGroup
|
||||
? !hasAccess
|
||||
? [...(docsMenuItems ?? [])]
|
||||
: [...(docsMenuItems ?? []), ...(apiMenuItems ?? [])]
|
||||
: effectiveTopMenu === 'play'
|
||||
? [...(playMenuItems ?? [])]
|
||||
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
|
||||
|
||||
return (
|
||||
<Sider
|
||||
width={isMobileViewport ? '100%' : 260}
|
||||
collapsed={sidebarCollapsed}
|
||||
collapsedWidth={isMobileViewport ? 0 : 72}
|
||||
className={isMobileViewport ? 'app-sider app-sider--mobile' : 'app-sider'}
|
||||
theme="light"
|
||||
>
|
||||
<div className="app-sider__inner">
|
||||
<Space direction="vertical" size={8} className="app-sider__intro">
|
||||
<Tag color={introColor}>{introTag}</Tag>
|
||||
{!sidebarCollapsed ? (
|
||||
<Text type="secondary">{introDescription}</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
inlineCollapsed={sidebarCollapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={visibleOpenKeys}
|
||||
items={sidebarItems}
|
||||
onOpenChange={(keys) => {
|
||||
onOpenKeysChange(keys as string[]);
|
||||
}}
|
||||
onClick={({ key, keyPath }) => {
|
||||
if (keyPath.includes('docs-group')) {
|
||||
onSelectDocsMenu(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('api-group')) {
|
||||
onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('plan-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')) {
|
||||
onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) {
|
||||
onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
}
|
||||
5
src/app/main/MainView.tsx
Executable file
5
src/app/main/MainView.tsx
Executable file
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
export function MainView() {
|
||||
return <AppShell />;
|
||||
}
|
||||
173
src/app/main/ReleasePendingMainModal.tsx
Executable file
173
src/app/main/ReleasePendingMainModal.tsx
Executable file
@@ -0,0 +1,173 @@
|
||||
import { Button, List, Modal, Space, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { isReleasePendingMainItem, normalizeWorkerStatus } from '../../features/planBoard/quickFilters';
|
||||
import type { PlanItem } from '../../features/planBoard/types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
const RELEASE_PENDING_MAIN_MODAL_SESSION_KEY = 'work-server.release-pending-main-modal:dismissed';
|
||||
type PendingMainItem = Pick<PlanItem, 'id' | 'workId' | 'note' | 'status' | 'workerStatus' | 'updatedAt'>;
|
||||
|
||||
function isReleaseServer() {
|
||||
if (import.meta.env.VITE_RELEASE_SERVER === 'true') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const explicitStage = (import.meta.env.VITE_DEPLOY_STAGE ?? import.meta.env.VITE_APP_STAGE ?? '').trim().toLowerCase();
|
||||
if (explicitStage === 'release') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.location.hostname.toLowerCase().includes('release');
|
||||
}
|
||||
|
||||
function summarizeNote(note: string) {
|
||||
const normalized = note.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '요약 메모가 없습니다.';
|
||||
}
|
||||
|
||||
if (normalized.length <= 72) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, 72)}...`;
|
||||
}
|
||||
|
||||
function describePendingReason(item: PendingMainItem) {
|
||||
const normalizedWorkerStatus = normalizeWorkerStatus(item.workerStatus);
|
||||
|
||||
if (normalizedWorkerStatus === 'main반영중') {
|
||||
return 'release 반영 후 main 병합을 진행 중입니다.';
|
||||
}
|
||||
|
||||
if (normalizedWorkerStatus === 'main반영실패') {
|
||||
return 'main 반영 단계에서 실패했습니다. 재시도가 필요합니다.';
|
||||
}
|
||||
|
||||
if (normalizedWorkerStatus === 'main반영대기') {
|
||||
return 'release 반영은 끝났고 main 반영 순서를 기다리는 중입니다.';
|
||||
}
|
||||
|
||||
return 'release 반영은 완료됐지만 main에는 아직 적용되지 않았습니다.';
|
||||
}
|
||||
|
||||
function formatUpdatedAt(value: string) {
|
||||
return new Date(value).toLocaleString('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
});
|
||||
}
|
||||
|
||||
export function ReleasePendingMainModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<PendingMainItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReleaseServer() || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.sessionStorage.getItem(RELEASE_PENDING_MAIN_MODAL_SESSION_KEY) === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void fetchPlanItems('all')
|
||||
.then((planItems) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = planItems
|
||||
.filter(isReleasePendingMainItem)
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||
.slice(0, 12)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
workId: item.workId,
|
||||
note: item.note,
|
||||
status: item.status,
|
||||
workerStatus: item.workerStatus,
|
||||
updatedAt: item.updatedAt,
|
||||
}));
|
||||
|
||||
if (nextItems.length === 0) {
|
||||
window.sessionStorage.setItem(RELEASE_PENDING_MAIN_MODAL_SESSION_KEY, 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
setItems(nextItems);
|
||||
setOpen(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep startup resilient even when the plan API is temporarily unavailable.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const title = useMemo(() => `main 미반영 항목 ${items.length}건`, [items.length]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem(RELEASE_PENDING_MAIN_MODAL_SESSION_KEY, 'true');
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
title={title}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
rootClassName="release-pending-main-modal"
|
||||
footer={[
|
||||
<Button key="confirm" type="primary" onClick={handleClose}>
|
||||
확인
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" size={16} className="release-pending-main-modal__body">
|
||||
<div>
|
||||
<Title level={5} className="release-pending-main-modal__title">
|
||||
release 서버 기준으로 main에 아직 반영되지 않은 작업입니다.
|
||||
</Title>
|
||||
<Paragraph type="secondary" className="release-pending-main-modal__copy">
|
||||
최신 반영 순서대로 최대 12건까지 표시합니다.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<List
|
||||
dataSource={items}
|
||||
className="release-pending-main-modal__list"
|
||||
renderItem={(item) => (
|
||||
<List.Item className="release-pending-main-modal__item">
|
||||
<Space direction="vertical" size={8} className="release-pending-main-modal__item-body">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Text strong>{item.workId.trim() || `#${item.id}`}</Text>
|
||||
<Tag color={item.workerStatus === 'main반영실패' ? 'error' : 'gold'}>
|
||||
{item.workerStatus ?? item.status}
|
||||
</Tag>
|
||||
<Text type="secondary">{formatUpdatedAt(item.updatedAt)}</Text>
|
||||
</Space>
|
||||
<Text className="release-pending-main-modal__summary">{summarizeNote(item.note)}</Text>
|
||||
<Text type="secondary">{describePendingReason(item)}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
654
src/app/main/appConfig.ts
Executable file
654
src/app/main/appConfig.ts
Executable file
@@ -0,0 +1,654 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
|
||||
|
||||
const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
|
||||
const APP_CONFIG_EVENT = 'work-server:app-config';
|
||||
const APP_CONFIG_API_PATH = '/app-config';
|
||||
const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';
|
||||
const APP_CONFIG_REQUEST_TIMEOUT_MS = 8000;
|
||||
let cachedConfig: AppConfig | null = null;
|
||||
let cachedRawConfig: string | null = null;
|
||||
|
||||
export type AutomationScheduleType = 'interval' | 'daily' | 'weekly';
|
||||
export type WeeklyScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
||||
export type PlanCostTimeUnit = 'hour' | 'minute' | 'second';
|
||||
|
||||
export type AppConfig = {
|
||||
chat: {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
automation: {
|
||||
autoRefreshEnabled: boolean;
|
||||
autoRefreshIntervalSeconds: number;
|
||||
autoReceiveScheduleType: AutomationScheduleType;
|
||||
autoReceiveIntervalSeconds: number;
|
||||
autoReceiveDailyTime: string;
|
||||
autoReceiveWeeklyDay: WeeklyScheduleDay;
|
||||
autoReceiveWeeklyTime: string;
|
||||
notifyOnAutomationStart: boolean;
|
||||
notifyOnAutomationProgress: boolean;
|
||||
notifyOnAutomationCompletion: boolean;
|
||||
notifyOnAutomationRelease: boolean;
|
||||
notifyOnAutomationMain: boolean;
|
||||
notifyOnAutomationFailure: boolean;
|
||||
notifyOnAutomationRestart: boolean;
|
||||
notifyOnAutomationIssueResolved: boolean;
|
||||
};
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog: boolean;
|
||||
dailyCreateTime: string;
|
||||
repeatRequestEnabled: boolean;
|
||||
repeatIntervalMinutes: number;
|
||||
includeScreenshots: boolean;
|
||||
includeChangedFiles: boolean;
|
||||
includeCommandLogs: boolean;
|
||||
template: 'simple' | 'detailed';
|
||||
};
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired: boolean;
|
||||
autoDeployToMain: boolean;
|
||||
openEditorAfterCreate: boolean;
|
||||
};
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: number;
|
||||
retryCostMultiplierPercent: number;
|
||||
hourlyCostMultiplierPercent: number;
|
||||
timeCostUnit: PlanCostTimeUnit;
|
||||
attentionCostThresholdMultiplier: number;
|
||||
warningCostThresholdMultiplier: number;
|
||||
highCostThresholdMultiplier: number;
|
||||
};
|
||||
gestureShortcuts: {
|
||||
openSearch: string;
|
||||
openWindowSearch: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_APP_CONFIG: AppConfig = {
|
||||
chat: {
|
||||
maxContextMessages: 12,
|
||||
maxContextChars: 3200,
|
||||
},
|
||||
automation: {
|
||||
autoRefreshEnabled: true,
|
||||
autoRefreshIntervalSeconds: 5,
|
||||
autoReceiveScheduleType: 'interval',
|
||||
autoReceiveIntervalSeconds: 30,
|
||||
autoReceiveDailyTime: '09:00',
|
||||
autoReceiveWeeklyDay: 'mon',
|
||||
autoReceiveWeeklyTime: '09:00',
|
||||
notifyOnAutomationStart: true,
|
||||
notifyOnAutomationProgress: true,
|
||||
notifyOnAutomationCompletion: true,
|
||||
notifyOnAutomationRelease: true,
|
||||
notifyOnAutomationMain: true,
|
||||
notifyOnAutomationFailure: true,
|
||||
notifyOnAutomationRestart: true,
|
||||
notifyOnAutomationIssueResolved: true,
|
||||
},
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog: false,
|
||||
dailyCreateTime: '18:00',
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: 60,
|
||||
includeScreenshots: true,
|
||||
includeChangedFiles: true,
|
||||
includeCommandLogs: true,
|
||||
template: 'detailed',
|
||||
},
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired: true,
|
||||
autoDeployToMain: true,
|
||||
openEditorAfterCreate: true,
|
||||
},
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: 10000,
|
||||
retryCostMultiplierPercent: 15,
|
||||
hourlyCostMultiplierPercent: 0,
|
||||
timeCostUnit: 'hour',
|
||||
attentionCostThresholdMultiplier: 1,
|
||||
warningCostThresholdMultiplier: 2,
|
||||
highCostThresholdMultiplier: 4,
|
||||
},
|
||||
gestureShortcuts: {
|
||||
openSearch: 'Mod+K',
|
||||
openWindowSearch: 'Mod+Shift+K',
|
||||
},
|
||||
};
|
||||
|
||||
const WEEKLY_DAY_LABELS: Record<WeeklyScheduleDay, string> = {
|
||||
mon: '월요일',
|
||||
tue: '화요일',
|
||||
wed: '수요일',
|
||||
thu: '목요일',
|
||||
fri: '금요일',
|
||||
sat: '토요일',
|
||||
sun: '일요일',
|
||||
};
|
||||
|
||||
const AUTOMATION_NOTIFICATION_KEYS = [
|
||||
'notifyOnAutomationStart',
|
||||
'notifyOnAutomationProgress',
|
||||
'notifyOnAutomationCompletion',
|
||||
'notifyOnAutomationRelease',
|
||||
'notifyOnAutomationMain',
|
||||
'notifyOnAutomationFailure',
|
||||
'notifyOnAutomationRestart',
|
||||
'notifyOnAutomationIssueResolved',
|
||||
] as const;
|
||||
|
||||
type AutomationNotificationSettings = Pick<AppConfig['automation'], (typeof AUTOMATION_NOTIFICATION_KEYS)[number]>;
|
||||
|
||||
function clampIntervalSeconds(value: number, fallback: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(3600, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeTimeValue(value: string, fallback: string) {
|
||||
if (/^\d{2}:\d{2}$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeScheduleType(value: string | undefined): AutomationScheduleType {
|
||||
if (value === 'daily' || value === 'weekly') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'interval';
|
||||
}
|
||||
|
||||
function normalizeWeeklyDay(value: string | undefined): WeeklyScheduleDay {
|
||||
if (value && value in WEEKLY_DAY_LABELS) {
|
||||
return value as WeeklyScheduleDay;
|
||||
}
|
||||
|
||||
return DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyDay;
|
||||
}
|
||||
|
||||
function normalizeWorklogTemplate(value: string | undefined): AppConfig['worklogAutomation']['template'] {
|
||||
if (value === 'simple') {
|
||||
return 'simple';
|
||||
}
|
||||
|
||||
return 'detailed';
|
||||
}
|
||||
|
||||
function normalizeShortcutValue(value: string | undefined, fallback: string) {
|
||||
const trimmed = value?.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split('+')
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.join('+');
|
||||
}
|
||||
|
||||
function normalizePlanCostValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(1_000_000, Math.max(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizePlanCostMultiplierValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0.1, Math.round(value * 10) / 10));
|
||||
}
|
||||
|
||||
function normalizePlanCostPercentValue(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(500, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizePlanCostTimeUnit(value: string | undefined): PlanCostTimeUnit {
|
||||
if (value === 'minute' || value === 'second') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'hour';
|
||||
}
|
||||
|
||||
function normalizeChatContextMessageLimit(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(50, Math.max(1, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeChatContextCharLimit(value: number | undefined, fallback: number) {
|
||||
if (value === undefined || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.min(20_000, Math.max(500, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
|
||||
const chat = raw?.chat;
|
||||
const automation = raw?.automation;
|
||||
const worklogAutomation = raw?.worklogAutomation;
|
||||
const planDefaults = raw?.planDefaults;
|
||||
const planCost = raw?.planCost;
|
||||
const gestureShortcuts = raw?.gestureShortcuts;
|
||||
|
||||
return {
|
||||
chat: {
|
||||
maxContextMessages: normalizeChatContextMessageLimit(
|
||||
chat?.maxContextMessages,
|
||||
DEFAULT_APP_CONFIG.chat.maxContextMessages,
|
||||
),
|
||||
maxContextChars: normalizeChatContextCharLimit(chat?.maxContextChars, DEFAULT_APP_CONFIG.chat.maxContextChars),
|
||||
},
|
||||
automation: {
|
||||
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,
|
||||
autoRefreshIntervalSeconds: clampIntervalSeconds(
|
||||
automation?.autoRefreshIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds,
|
||||
DEFAULT_APP_CONFIG.automation.autoRefreshIntervalSeconds,
|
||||
),
|
||||
autoReceiveScheduleType: normalizeScheduleType(automation?.autoReceiveScheduleType),
|
||||
autoReceiveIntervalSeconds: clampIntervalSeconds(
|
||||
automation?.autoReceiveIntervalSeconds ?? DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveIntervalSeconds,
|
||||
),
|
||||
autoReceiveDailyTime: normalizeTimeValue(
|
||||
automation?.autoReceiveDailyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveDailyTime,
|
||||
),
|
||||
autoReceiveWeeklyDay: normalizeWeeklyDay(automation?.autoReceiveWeeklyDay),
|
||||
autoReceiveWeeklyTime: normalizeTimeValue(
|
||||
automation?.autoReceiveWeeklyTime ?? DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime,
|
||||
DEFAULT_APP_CONFIG.automation.autoReceiveWeeklyTime,
|
||||
),
|
||||
notifyOnAutomationStart: automation?.notifyOnAutomationStart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationStart,
|
||||
notifyOnAutomationProgress:
|
||||
automation?.notifyOnAutomationProgress ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationProgress,
|
||||
notifyOnAutomationCompletion:
|
||||
automation?.notifyOnAutomationCompletion ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationCompletion,
|
||||
notifyOnAutomationRelease:
|
||||
automation?.notifyOnAutomationRelease ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRelease,
|
||||
notifyOnAutomationMain: automation?.notifyOnAutomationMain ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationMain,
|
||||
notifyOnAutomationFailure:
|
||||
automation?.notifyOnAutomationFailure ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationFailure,
|
||||
notifyOnAutomationRestart:
|
||||
automation?.notifyOnAutomationRestart ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationRestart,
|
||||
notifyOnAutomationIssueResolved:
|
||||
automation?.notifyOnAutomationIssueResolved ?? DEFAULT_APP_CONFIG.automation.notifyOnAutomationIssueResolved,
|
||||
},
|
||||
worklogAutomation: {
|
||||
autoCreateDailyWorklog:
|
||||
worklogAutomation?.autoCreateDailyWorklog ?? DEFAULT_APP_CONFIG.worklogAutomation.autoCreateDailyWorklog,
|
||||
dailyCreateTime: normalizeTimeValue(
|
||||
worklogAutomation?.dailyCreateTime ?? DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
|
||||
DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
|
||||
),
|
||||
repeatRequestEnabled: false,
|
||||
repeatIntervalMinutes: DEFAULT_APP_CONFIG.worklogAutomation.repeatIntervalMinutes,
|
||||
includeScreenshots:
|
||||
worklogAutomation?.includeScreenshots ?? DEFAULT_APP_CONFIG.worklogAutomation.includeScreenshots,
|
||||
includeChangedFiles:
|
||||
worklogAutomation?.includeChangedFiles ?? DEFAULT_APP_CONFIG.worklogAutomation.includeChangedFiles,
|
||||
includeCommandLogs:
|
||||
worklogAutomation?.includeCommandLogs ?? DEFAULT_APP_CONFIG.worklogAutomation.includeCommandLogs,
|
||||
template: normalizeWorklogTemplate(worklogAutomation?.template),
|
||||
},
|
||||
planDefaults: {
|
||||
jangsingProcessingRequired:
|
||||
planDefaults?.jangsingProcessingRequired ?? DEFAULT_APP_CONFIG.planDefaults.jangsingProcessingRequired,
|
||||
autoDeployToMain: planDefaults?.autoDeployToMain ?? DEFAULT_APP_CONFIG.planDefaults.autoDeployToMain,
|
||||
openEditorAfterCreate: planDefaults?.openEditorAfterCreate ?? DEFAULT_APP_CONFIG.planDefaults.openEditorAfterCreate,
|
||||
},
|
||||
planCost: {
|
||||
baseCostPerMillionTokens: normalizePlanCostValue(
|
||||
planCost?.baseCostPerMillionTokens,
|
||||
DEFAULT_APP_CONFIG.planCost.baseCostPerMillionTokens,
|
||||
),
|
||||
retryCostMultiplierPercent: normalizePlanCostPercentValue(
|
||||
planCost?.retryCostMultiplierPercent,
|
||||
DEFAULT_APP_CONFIG.planCost.retryCostMultiplierPercent,
|
||||
),
|
||||
hourlyCostMultiplierPercent: normalizePlanCostPercentValue(
|
||||
planCost?.hourlyCostMultiplierPercent,
|
||||
DEFAULT_APP_CONFIG.planCost.hourlyCostMultiplierPercent,
|
||||
),
|
||||
timeCostUnit: normalizePlanCostTimeUnit(planCost?.timeCostUnit),
|
||||
attentionCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.attentionCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.attentionCostThresholdMultiplier,
|
||||
),
|
||||
warningCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.warningCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.warningCostThresholdMultiplier,
|
||||
),
|
||||
highCostThresholdMultiplier: normalizePlanCostMultiplierValue(
|
||||
planCost?.highCostThresholdMultiplier,
|
||||
DEFAULT_APP_CONFIG.planCost.highCostThresholdMultiplier,
|
||||
),
|
||||
},
|
||||
gestureShortcuts: {
|
||||
openSearch: normalizeShortcutValue(
|
||||
gestureShortcuts?.openSearch,
|
||||
DEFAULT_APP_CONFIG.gestureShortcuts.openSearch,
|
||||
),
|
||||
openWindowSearch: normalizeShortcutValue(
|
||||
gestureShortcuts?.openWindowSearch,
|
||||
DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickAutomationNotificationSettings(automation: AppConfig['automation']): AutomationNotificationSettings {
|
||||
return AUTOMATION_NOTIFICATION_KEYS.reduce((picked, key) => {
|
||||
picked[key] = automation[key];
|
||||
return picked;
|
||||
}, {} as AutomationNotificationSettings);
|
||||
}
|
||||
|
||||
function mergeAutomationNotificationSettings(
|
||||
config: AppConfig,
|
||||
automation?: Partial<AutomationNotificationSettings> | null,
|
||||
) {
|
||||
if (!automation) {
|
||||
return config;
|
||||
}
|
||||
|
||||
return normalizeConfig({
|
||||
...config,
|
||||
automation: {
|
||||
...config.automation,
|
||||
...automation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emitConfigChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(APP_CONFIG_EVENT));
|
||||
}
|
||||
|
||||
function resolveAppConfigApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveAppConfigFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const APP_CONFIG_API_BASE_URL = resolveAppConfigApiBaseUrl();
|
||||
const APP_CONFIG_FALLBACK_BASE_URL = resolveAppConfigFallbackBaseUrl();
|
||||
|
||||
class AppConfigApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'AppConfigApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAppConfigOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), APP_CONFIG_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new AppConfigApiError('서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new AppConfigApiError(payload.message || '요청 처리에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new AppConfigApiError(text || '요청 처리에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().includes('application/json')) {
|
||||
const text = await response.text();
|
||||
throw new AppConfigApiError(text ? '서버 응답이 JSON이 아닙니다.' : '서버 응답을 확인할 수 없습니다.', 502);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestAppConfig<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestAppConfigOnce<T>(APP_CONFIG_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
APP_CONFIG_FALLBACK_BASE_URL &&
|
||||
APP_CONFIG_FALLBACK_BASE_URL !== APP_CONFIG_API_BASE_URL &&
|
||||
(error instanceof AppConfigApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestAppConfigOnce<T>(APP_CONFIG_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAppConfigFromServer() {
|
||||
try {
|
||||
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH);
|
||||
const config = normalizeConfig(response.config);
|
||||
const preference = await fetchAutomationNotificationPreferenceFromServer();
|
||||
return mergeAutomationNotificationSettings(config, preference);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAutomationNotificationPreferenceFromServer() {
|
||||
try {
|
||||
const target = getAutomationNotificationPreferenceTarget();
|
||||
const query = target ? `?${new URLSearchParams(target).toString()}` : '';
|
||||
const response = await requestAppConfig<{
|
||||
ok: boolean;
|
||||
automation?: Partial<AutomationNotificationSettings> | null;
|
||||
}>(`${AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH}${query}`);
|
||||
return response.automation ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveAppConfigToServer(config: AppConfig) {
|
||||
const response = await requestAppConfig<{ ok: boolean; config: Partial<AppConfig> }>(APP_CONFIG_API_PATH, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ config }),
|
||||
});
|
||||
|
||||
return normalizeConfig(response.config);
|
||||
}
|
||||
|
||||
export async function saveAutomationNotificationPreferenceToServer(config: AppConfig) {
|
||||
const target = getAutomationNotificationPreferenceTarget();
|
||||
const response = await requestAppConfig<{
|
||||
ok: boolean;
|
||||
automation: Partial<AutomationNotificationSettings>;
|
||||
}>(AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...(target ?? {}),
|
||||
automation: pickAutomationNotificationSettings(config.automation),
|
||||
}),
|
||||
});
|
||||
|
||||
return mergeAutomationNotificationSettings(config, response.automation);
|
||||
}
|
||||
|
||||
export async function syncAppConfigFromServer() {
|
||||
const config = await fetchAppConfigFromServer();
|
||||
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setStoredAppConfig(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getStoredAppConfig(): AppConfig {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(APP_CONFIG_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
cachedConfig = DEFAULT_APP_CONFIG;
|
||||
cachedRawConfig = null;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
|
||||
if (raw === cachedRawConfig && cachedConfig) {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
const normalized = normalizeConfig(JSON.parse(raw) as Partial<AppConfig>);
|
||||
cachedConfig = normalized;
|
||||
cachedRawConfig = raw;
|
||||
return normalized;
|
||||
} catch {
|
||||
cachedConfig = DEFAULT_APP_CONFIG;
|
||||
cachedRawConfig = null;
|
||||
return DEFAULT_APP_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredAppConfig(config: AppConfig) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeConfig(config);
|
||||
const raw = JSON.stringify(normalized);
|
||||
cachedConfig = normalized;
|
||||
cachedRawConfig = raw;
|
||||
window.localStorage.setItem(APP_CONFIG_STORAGE_KEY, raw);
|
||||
emitConfigChange();
|
||||
}
|
||||
|
||||
export function updateStoredAppConfig(updater: (current: AppConfig) => AppConfig) {
|
||||
const current = getStoredAppConfig();
|
||||
const next = updater(current);
|
||||
setStoredAppConfig(next);
|
||||
}
|
||||
|
||||
function subscribeToAppConfig(callback: () => void) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
callback();
|
||||
};
|
||||
|
||||
window.addEventListener(APP_CONFIG_EVENT, handleChange);
|
||||
window.addEventListener('storage', handleChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(APP_CONFIG_EVENT, handleChange);
|
||||
window.removeEventListener('storage', handleChange);
|
||||
};
|
||||
}
|
||||
|
||||
export function useAppConfig() {
|
||||
return useSyncExternalStore(subscribeToAppConfig, getStoredAppConfig, () => DEFAULT_APP_CONFIG);
|
||||
}
|
||||
|
||||
export function describeAutoReceiveSchedule(config: AppConfig) {
|
||||
const automation = config.automation;
|
||||
|
||||
if (automation.autoReceiveScheduleType === 'daily') {
|
||||
return `매일 ${automation.autoReceiveDailyTime}`;
|
||||
}
|
||||
|
||||
if (automation.autoReceiveScheduleType === 'weekly') {
|
||||
return `매주 ${WEEKLY_DAY_LABELS[automation.autoReceiveWeeklyDay]} ${automation.autoReceiveWeeklyTime}`;
|
||||
}
|
||||
|
||||
return `${automation.autoReceiveIntervalSeconds}초마다`;
|
||||
}
|
||||
|
||||
export function getWeeklyScheduleOptions() {
|
||||
return Object.entries(WEEKLY_DAY_LABELS).map(([value, label]) => ({
|
||||
value: value as WeeklyScheduleDay,
|
||||
label,
|
||||
}));
|
||||
}
|
||||
513
src/app/main/appUpdate.ts
Executable file
513
src/app/main/appUpdate.ts
Executable file
@@ -0,0 +1,513 @@
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
export type AppUpdateStatus = 'idle' | 'available' | 'updating' | 'ready';
|
||||
|
||||
export type AppUpdateSnapshot = {
|
||||
supported: boolean;
|
||||
status: AppUpdateStatus;
|
||||
progressPercent: number | null;
|
||||
currentTaskLabel: string | null;
|
||||
};
|
||||
|
||||
type Listener = (snapshot: AppUpdateSnapshot) => void;
|
||||
type ServiceWorkerUpdater = (reloadPage?: boolean) => Promise<void>;
|
||||
|
||||
const UPDATE_APPLY_TIMEOUT_MS = 15000;
|
||||
const UPDATE_DISCOVERY_TIMEOUT_MS = 6000;
|
||||
const REGISTRATION_LOOKUP_TIMEOUT_MS = 10000;
|
||||
const DEV_UPDATE_POLL_INTERVAL_MS = 4000;
|
||||
const DEV_UPDATE_ENDPOINT_PATH = '/__app-update';
|
||||
const APP_UPDATE_CACHE_BUST_PARAM = '__appUpdatedAt';
|
||||
|
||||
let initialized = false;
|
||||
let snapshot: AppUpdateSnapshot = {
|
||||
supported: false,
|
||||
status: 'idle',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
const listeners = new Set<Listener>();
|
||||
let devUpdateToken: string | null = null;
|
||||
let devUpdatePollTimerId: number | null = null;
|
||||
let serviceWorkerUpdater: ServiceWorkerUpdater | null = null;
|
||||
|
||||
function isAppUpdateSupported() {
|
||||
return typeof window !== 'undefined' && typeof navigator !== 'undefined' && 'serviceWorker' in navigator;
|
||||
}
|
||||
|
||||
function isAppUpdateDisabled() {
|
||||
return import.meta.env.VITE_DISABLE_APP_UPDATE === 'true';
|
||||
}
|
||||
|
||||
function isDevelopmentUpdateMode() {
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) {
|
||||
listener(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAppUpdateReloadUrl() {
|
||||
const targetUrl = new URL(window.location.href);
|
||||
targetUrl.searchParams.set(APP_UPDATE_CACHE_BUST_PARAM, `${Date.now()}`);
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
function reloadAppWithCacheBuster() {
|
||||
window.location.replace(buildAppUpdateReloadUrl());
|
||||
}
|
||||
|
||||
async function resetServiceWorkersAndReload() {
|
||||
if (!isAppUpdateSupported()) {
|
||||
reloadAppWithCacheBuster();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(
|
||||
registrations.map((registration) =>
|
||||
registration.unregister().catch(() => false),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// ignore and continue reload
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && 'caches' in window) {
|
||||
try {
|
||||
const cacheKeys = await caches.keys();
|
||||
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey).catch(() => false)));
|
||||
} catch {
|
||||
// ignore and continue reload
|
||||
}
|
||||
}
|
||||
|
||||
reloadAppWithCacheBuster();
|
||||
}
|
||||
|
||||
async function unregisterServiceWorkers() {
|
||||
if (!isAppUpdateSupported()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)));
|
||||
} catch {
|
||||
// ignore cleanup failure
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDevelopmentUpdateToken() {
|
||||
const response = await fetch(DEV_UPDATE_ENDPOINT_PATH, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('개발 업데이트 상태를 확인하지 못했습니다.');
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { token?: string };
|
||||
return typeof payload.token === 'string' && payload.token ? payload.token : null;
|
||||
}
|
||||
|
||||
async function pollDevelopmentUpdateStatus() {
|
||||
try {
|
||||
const nextToken = await fetchDevelopmentUpdateToken();
|
||||
|
||||
if (!nextToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!devUpdateToken) {
|
||||
devUpdateToken = nextToken;
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextToken !== devUpdateToken && snapshot.status !== 'available' && snapshot.status !== 'updating') {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'available',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: '개발 서버의 변경 사항이 준비되었습니다. 설정의 업데이트 버튼으로 반영할 수 있습니다.',
|
||||
};
|
||||
emit();
|
||||
}
|
||||
} catch {
|
||||
if (snapshot.status === 'idle') {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startDevelopmentUpdatePolling() {
|
||||
void pollDevelopmentUpdateStatus();
|
||||
|
||||
if (devUpdatePollTimerId !== null) {
|
||||
window.clearInterval(devUpdatePollTimerId);
|
||||
}
|
||||
|
||||
devUpdatePollTimerId = window.setInterval(() => {
|
||||
void pollDevelopmentUpdateStatus();
|
||||
}, DEV_UPDATE_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function getAppServiceWorkerRegistration() {
|
||||
if (!isAppUpdateSupported()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js';
|
||||
let registeredRegistration: ServiceWorkerRegistration | null = null;
|
||||
|
||||
try {
|
||||
registeredRegistration = await navigator.serviceWorker.register(
|
||||
serviceWorkerUrl,
|
||||
import.meta.env.DEV ? { type: 'module' } : undefined,
|
||||
);
|
||||
} catch {
|
||||
// 기존 등록이 있거나 설치 중 충돌이 있어도 아래 조회 흐름으로 이어갑니다.
|
||||
}
|
||||
|
||||
if (registeredRegistration) {
|
||||
return registeredRegistration;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < REGISTRATION_LOOKUP_TIMEOUT_MS) {
|
||||
const directRegistration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (directRegistration) {
|
||||
return directRegistration;
|
||||
}
|
||||
|
||||
try {
|
||||
const readyRegistration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<null>((resolve) => {
|
||||
window.setTimeout(() => resolve(null), 750);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (readyRegistration) {
|
||||
return readyRegistration;
|
||||
}
|
||||
} catch {
|
||||
// keep polling until timeout
|
||||
}
|
||||
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations[0] ?? null;
|
||||
}
|
||||
|
||||
function waitForControllerActivation(timeoutMs: number) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let completed = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
const onControllerChange = () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForWaitingServiceWorker(registration: ServiceWorkerRegistration, timeoutMs: number) {
|
||||
return new Promise<ServiceWorker | null>((resolve) => {
|
||||
if (registration.waiting) {
|
||||
resolve(registration.waiting);
|
||||
return;
|
||||
}
|
||||
|
||||
let completed = false;
|
||||
let currentInstallingWorker: ServiceWorker | null = registration.installing ?? null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (completed) {
|
||||
return;
|
||||
}
|
||||
|
||||
completed = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
registration.removeEventListener('updatefound', onUpdateFound);
|
||||
currentInstallingWorker?.removeEventListener('statechange', onStateChange);
|
||||
};
|
||||
|
||||
const resolveWith = (worker: ServiceWorker | null) => {
|
||||
cleanup();
|
||||
resolve(worker);
|
||||
};
|
||||
|
||||
const onStateChange = () => {
|
||||
const waitingWorker = registration.waiting;
|
||||
|
||||
if (waitingWorker) {
|
||||
resolveWith(waitingWorker);
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdateFound = () => {
|
||||
currentInstallingWorker?.removeEventListener('statechange', onStateChange);
|
||||
currentInstallingWorker = registration.installing ?? null;
|
||||
currentInstallingWorker?.addEventListener('statechange', onStateChange);
|
||||
};
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
resolveWith(registration.waiting ?? null);
|
||||
}, timeoutMs);
|
||||
|
||||
registration.addEventListener('updatefound', onUpdateFound);
|
||||
|
||||
if (currentInstallingWorker) {
|
||||
currentInstallingWorker.addEventListener('statechange', onStateChange);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initializeAppUpdate() {
|
||||
if (initialized || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
if (isAppUpdateDisabled()) {
|
||||
snapshot = {
|
||||
supported: false,
|
||||
status: 'idle',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
void unregisterServiceWorkers();
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
const supported = isDevelopmentUpdateMode() ? true : isAppUpdateSupported();
|
||||
snapshot = {
|
||||
supported,
|
||||
status: supported ? 'ready' : 'idle',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
|
||||
if (isDevelopmentUpdateMode()) {
|
||||
startDevelopmentUpdatePolling();
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!snapshot.supported) {
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
|
||||
serviceWorkerUpdater = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'available',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegistered() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegisterError() {
|
||||
snapshot = {
|
||||
supported: isAppUpdateSupported(),
|
||||
status: isAppUpdateSupported() ? 'ready' : 'idle',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
});
|
||||
|
||||
emit();
|
||||
}
|
||||
|
||||
export function subscribeAppUpdate(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
listener(snapshot);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getAppUpdateSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function applyAppUpdate() {
|
||||
if (isDevelopmentUpdateMode()) {
|
||||
if (snapshot.status !== 'available') {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'updating',
|
||||
progressPercent: 100,
|
||||
currentTaskLabel: '개발 서버 변경 사항을 적용하기 위해 화면을 새로고침합니다.',
|
||||
};
|
||||
emit();
|
||||
reloadAppWithCacheBuster();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isAppUpdateSupported()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'updating',
|
||||
progressPercent: 20,
|
||||
currentTaskLabel: '새 버전 적용을 준비하고 있습니다.',
|
||||
};
|
||||
emit();
|
||||
|
||||
try {
|
||||
if (serviceWorkerUpdater && snapshot.status === 'available') {
|
||||
try {
|
||||
await serviceWorkerUpdater(true);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to manual service worker activation below when the helper path fails.
|
||||
}
|
||||
}
|
||||
|
||||
const registration = await getAppServiceWorkerRegistration();
|
||||
|
||||
if (!registration) {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
return false;
|
||||
}
|
||||
|
||||
let waitingWorker = registration.waiting;
|
||||
|
||||
if (!waitingWorker) {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'updating',
|
||||
progressPercent: 45,
|
||||
currentTaskLabel: '새 버전 서비스 워커를 확인하고 있습니다.',
|
||||
};
|
||||
emit();
|
||||
|
||||
await registration.update();
|
||||
waitingWorker = await waitForWaitingServiceWorker(registration, UPDATE_DISCOVERY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
if (!waitingWorker) {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'updating',
|
||||
progressPercent: 80,
|
||||
currentTaskLabel: '서비스 워커를 새 버전으로 교체하고 있습니다.',
|
||||
};
|
||||
emit();
|
||||
|
||||
const activationPromise = waitForControllerActivation(UPDATE_APPLY_TIMEOUT_MS);
|
||||
waitingWorker.postMessage({ type: 'SKIP_WAITING' });
|
||||
const activated = await activationPromise;
|
||||
|
||||
if (!activated) {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'updating',
|
||||
progressPercent: 95,
|
||||
currentTaskLabel: '서비스 워커 재적용이 지연되어 캐시를 정리한 뒤 다시 불러옵니다.',
|
||||
};
|
||||
emit();
|
||||
await resetServiceWorkersAndReload();
|
||||
return true;
|
||||
}
|
||||
|
||||
reloadAppWithCacheBuster();
|
||||
return true;
|
||||
} catch (error) {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'available',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
218
src/app/main/chatTypeAccess.ts
Executable file
218
src/app/main/chatTypeAccess.ts
Executable file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type ChatPermissionRole = 'guest' | 'token-user';
|
||||
|
||||
export type ChatTypeRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled: boolean;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ChatTypeInput = {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isTemplate?: boolean;
|
||||
permissions: ChatPermissionRole[];
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
|
||||
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
|
||||
|
||||
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
|
||||
guest: '게스트',
|
||||
'token-user': '토큰 사용자',
|
||||
};
|
||||
|
||||
const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: '일반 Codex Live 요청입니다. 현재 로컬 main 작업본 기준으로 바로 확인하고 필요 시 소스를 수정합니다.',
|
||||
isTemplate: false,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-20T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
name: 'API요청',
|
||||
description: 'API요청만 진행 (자동화, 작업요청, 스케줄 등 호출 가능한 API)',
|
||||
isTemplate: true,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-16T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function normalizePermissions(permissions: ChatPermissionRole[] | null | undefined): ChatPermissionRole[] {
|
||||
const nextPermissions = Array.from(
|
||||
new Set(
|
||||
(permissions ?? []).filter(
|
||||
(permission): permission is ChatPermissionRole => permission === 'guest' || permission === 'token-user',
|
||||
),
|
||||
),
|
||||
);
|
||||
return nextPermissions.length > 0 ? nextPermissions : (['token-user'] as ChatPermissionRole[]);
|
||||
}
|
||||
|
||||
function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | null {
|
||||
const name = normalizeText(record.name);
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const id =
|
||||
normalizeText(record.id) ||
|
||||
`chat-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: normalizeText(record.description),
|
||||
isTemplate: record.isTemplate === true,
|
||||
permissions: normalizePermissions(record.permissions),
|
||||
enabled: record.enabled !== false,
|
||||
updatedAt: typeof record.updatedAt === 'string' && record.updatedAt ? record.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultChatTypes(chatTypes: ChatTypeRecord[]) {
|
||||
const defaultsById = new Map(DEFAULT_CHAT_TYPES.map((item) => [item.id, item]));
|
||||
const merged = chatTypes.map((item) => {
|
||||
const defaultItem = defaultsById.get(item.id);
|
||||
|
||||
if (!defaultItem) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const storedUpdatedAt = Date.parse(item.updatedAt);
|
||||
const defaultUpdatedAt = Date.parse(defaultItem.updatedAt);
|
||||
|
||||
if (Number.isFinite(storedUpdatedAt) && Number.isFinite(defaultUpdatedAt) && storedUpdatedAt >= defaultUpdatedAt) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
...defaultItem,
|
||||
};
|
||||
});
|
||||
|
||||
const existingIds = new Set(merged.map((item) => item.id));
|
||||
const missingDefaults = DEFAULT_CHAT_TYPES.filter((item) => !existingIds.has(item.id));
|
||||
|
||||
return [...merged, ...missingDefaults].sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
}
|
||||
|
||||
export function loadChatTypes() {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHAT_TYPE_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
}
|
||||
|
||||
const normalized = parsed
|
||||
.map((item) => normalizeChatType(item))
|
||||
.filter((item): item is ChatTypeRecord => Boolean(item));
|
||||
|
||||
const resolved = normalized.length > 0 ? ensureDefaultChatTypes(normalized) : DEFAULT_CHAT_TYPES;
|
||||
|
||||
if (JSON.stringify(resolved) !== JSON.stringify(normalized)) {
|
||||
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(resolved));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
} catch {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChatTypes(chatTypes: ChatTypeRecord[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(chatTypes));
|
||||
window.dispatchEvent(new CustomEvent(CHAT_TYPE_SYNC_EVENT));
|
||||
}
|
||||
|
||||
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
||||
const nextRecord = normalizeChatType({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
isTemplate: input.isTemplate,
|
||||
permissions: input.permissions,
|
||||
enabled: input.enabled,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!nextRecord) {
|
||||
return chatTypes;
|
||||
}
|
||||
|
||||
const nextChatTypes = chatTypes.filter((item) => item.id !== nextRecord.id);
|
||||
nextChatTypes.push(nextRecord);
|
||||
nextChatTypes.sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
return nextChatTypes;
|
||||
}
|
||||
|
||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||
return hasTokenAccess ? ['token-user'] : ['guest'];
|
||||
}
|
||||
|
||||
export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRole[]) {
|
||||
return chatType.enabled && chatType.permissions.some((permission) => roles.includes(permission));
|
||||
}
|
||||
|
||||
export function useChatTypeRegistry() {
|
||||
const [chatTypes, setChatTypes] = useState<ChatTypeRecord[]>(() => loadChatTypes());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const syncChatTypes = () => {
|
||||
setChatTypes(loadChatTypes());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', syncChatTypes);
|
||||
window.addEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncChatTypes);
|
||||
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatTypes,
|
||||
setChatTypes: (nextChatTypes: ChatTypeRecord[]) => {
|
||||
saveChatTypes(nextChatTypes);
|
||||
setChatTypes(nextChatTypes);
|
||||
},
|
||||
};
|
||||
}
|
||||
116
src/app/main/chatV2/ChatWorkspaceV2.tsx
Normal file
116
src/app/main/chatV2/ChatWorkspaceV2.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Segmented } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ConversationListPane } from './components/ConversationListPane';
|
||||
import { ConversationRoomPane } from './components/ConversationRoomPane';
|
||||
import { ErrorPane } from './components/ErrorPane';
|
||||
import { RuntimePane } from './components/RuntimePane';
|
||||
import type {
|
||||
ChatWorkspaceSelection,
|
||||
ConversationListState,
|
||||
ConversationRoomState,
|
||||
ErrorPaneState,
|
||||
RuntimePaneState,
|
||||
ChatWorkspaceView,
|
||||
} from './types';
|
||||
|
||||
type ChatWorkspaceV2Props = {
|
||||
selection: ChatWorkspaceSelection;
|
||||
conversationList: ConversationListState;
|
||||
conversationRoom: ConversationRoomState;
|
||||
runtimePane: RuntimePaneState;
|
||||
errorPane: ErrorPaneState;
|
||||
onSelectView: (view: ChatWorkspaceView) => void;
|
||||
onSearchConversation: (value: string) => void;
|
||||
onSelectConversation: (sessionId: string) => void;
|
||||
onCreateConversation: () => void;
|
||||
showToolbar?: boolean;
|
||||
chatListPane?: ReactNode;
|
||||
chatRoomPane?: ReactNode;
|
||||
runtimePaneContent?: ReactNode;
|
||||
errorPaneContent?: ReactNode;
|
||||
};
|
||||
|
||||
export function ChatWorkspaceV2({
|
||||
selection,
|
||||
conversationList,
|
||||
conversationRoom,
|
||||
runtimePane,
|
||||
errorPane,
|
||||
onSelectView,
|
||||
onSearchConversation,
|
||||
onSelectConversation,
|
||||
onCreateConversation,
|
||||
showToolbar = true,
|
||||
chatListPane,
|
||||
chatRoomPane,
|
||||
runtimePaneContent,
|
||||
errorPaneContent,
|
||||
}: ChatWorkspaceV2Props) {
|
||||
return (
|
||||
<div className="chat-v2">
|
||||
{showToolbar ? (
|
||||
<div className="chat-v2__toolbar">
|
||||
<Segmented
|
||||
value={selection.activeView}
|
||||
options={[
|
||||
{ label: '채팅', value: 'chat' },
|
||||
{ label: '런타임', value: 'runtime' },
|
||||
{ label: '에러', value: 'errors' },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
onSelectView(value as ChatWorkspaceView);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection.activeView === 'chat' ? (
|
||||
<div className="chat-v2__chat-layout">
|
||||
{chatListPane ?? (
|
||||
<ConversationListPane
|
||||
items={conversationList.items}
|
||||
isLoading={conversationList.isLoading}
|
||||
errorMessage={conversationList.errorMessage}
|
||||
searchText={conversationList.searchText}
|
||||
activeSessionId={selection.activeSessionId}
|
||||
onSearchChange={onSearchConversation}
|
||||
onSelectSession={onSelectConversation}
|
||||
onCreateConversation={onCreateConversation}
|
||||
/>
|
||||
)}
|
||||
{chatRoomPane ?? (
|
||||
<ConversationRoomPane
|
||||
sessionId={conversationRoom.sessionId}
|
||||
messages={conversationRoom.messages}
|
||||
requests={conversationRoom.requests}
|
||||
isLoading={conversationRoom.isLoading}
|
||||
loadingLabel={conversationRoom.loadingLabel}
|
||||
errorMessage={conversationRoom.errorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection.activeView === 'runtime' ? (
|
||||
runtimePaneContent ?? (
|
||||
<RuntimePane
|
||||
snapshot={runtimePane.snapshot}
|
||||
isLoading={runtimePane.isLoading}
|
||||
errorMessage={runtimePane.errorMessage}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{selection.activeView === 'errors' ? (
|
||||
errorPaneContent ?? (
|
||||
<ErrorPane
|
||||
items={errorPane.items}
|
||||
selectedItem={errorPane.selectedItem}
|
||||
isLoading={errorPane.isLoading}
|
||||
errorMessage={errorPane.errorMessage}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/app/main/chatV2/components/ConversationListPane.tsx
Normal file
87
src/app/main/chatV2/components/ConversationListPane.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConversationListPaneProps = {
|
||||
items: ChatConversationSummary[];
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
searchText: string;
|
||||
activeSessionId: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onCreateConversation: () => void;
|
||||
};
|
||||
|
||||
export function ConversationListPane({
|
||||
items,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
searchText,
|
||||
activeSessionId,
|
||||
onSearchChange,
|
||||
onSelectSession,
|
||||
onCreateConversation,
|
||||
}: ConversationListPaneProps) {
|
||||
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>
|
||||
</div>
|
||||
<Button type="primary" onClick={onCreateConversation}>
|
||||
새 채팅
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input.Search
|
||||
value={searchText}
|
||||
placeholder="채팅방 검색"
|
||||
allowClear
|
||||
onChange={(event) => {
|
||||
onSearchChange(event.target.value);
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="chat-v2__state">
|
||||
<Spin />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="chat-v2__state">
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="대화가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
className="chat-v2__conversation-list"
|
||||
dataSource={items}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
item.sessionId === activeSessionId
|
||||
? 'chat-v2__conversation-item chat-v2__conversation-item--active'
|
||||
: 'chat-v2__conversation-item'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
<span className="chat-v2__conversation-title">{item.title || item.sessionId}</span>
|
||||
<span className="chat-v2__conversation-preview">{item.lastMessagePreview || '대화가 아직 없습니다.'}</span>
|
||||
</button>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
src/app/main/chatV2/components/ConversationRoomPane.tsx
Normal file
85
src/app/main/chatV2/components/ConversationRoomPane.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Empty, Spin, Typography } from 'antd';
|
||||
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConversationRoomPaneProps = {
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
requests: ChatConversationRequest[];
|
||||
isLoading: boolean;
|
||||
loadingLabel: string;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function ConversationRoomPane({
|
||||
sessionId,
|
||||
messages,
|
||||
requests,
|
||||
isLoading,
|
||||
loadingLabel,
|
||||
errorMessage,
|
||||
}: ConversationRoomPaneProps) {
|
||||
if (!sessionId) {
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--room">
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="채팅방을 선택해 주세요." />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--room">
|
||||
<div className="chat-v2__state">
|
||||
<Spin />
|
||||
<Text type="secondary">{loadingLabel}</Text>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--room">
|
||||
<div className="chat-v2__state">
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--room">
|
||||
<div className="chat-v2__pane-header">
|
||||
<div>
|
||||
<Text strong>{sessionId}</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
메시지 {messages.length}개 · 요청 {requests.length}개
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-v2__room-stream">
|
||||
{messages.length === 0 ? (
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="메시지가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<article key={`${message.id}-${message.timestamp}`} className={`chat-v2__message chat-v2__message--${message.author}`}>
|
||||
<div className="chat-v2__message-meta">
|
||||
<Text strong>{message.author}</Text>
|
||||
<Text type="secondary">{message.timestamp}</Text>
|
||||
</div>
|
||||
<div className="chat-v2__message-body">{message.text}</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
56
src/app/main/chatV2/components/ErrorPane.tsx
Normal file
56
src/app/main/chatV2/components/ErrorPane.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Empty, Spin, Typography } from 'antd';
|
||||
import type { ErrorLogItem } from '../../errorLogApi';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ErrorPaneProps = {
|
||||
items: ErrorLogItem[];
|
||||
selectedItem: ErrorLogItem | null;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function ErrorPane({ items, selectedItem, isLoading, errorMessage }: ErrorPaneProps) {
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--errors">
|
||||
<div className="chat-v2__pane-header">
|
||||
<Text strong>에러 로그</Text>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="chat-v2__state">
|
||||
<Spin />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="chat-v2__state">
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="에러 로그가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-v2__error-layout">
|
||||
<div className="chat-v2__error-list">
|
||||
{items.map((item) => (
|
||||
<article key={item.id} className="chat-v2__error-item">
|
||||
<Text strong>{item.errorType}</Text>
|
||||
<Text type="secondary">{item.errorMessage}</Text>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="chat-v2__error-detail">
|
||||
{selectedItem ? (
|
||||
<>
|
||||
<Text strong>{selectedItem.errorType}</Text>
|
||||
<Text>{selectedItem.errorMessage}</Text>
|
||||
</>
|
||||
) : (
|
||||
<Empty description="에러를 선택해 주세요." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
49
src/app/main/chatV2/components/RuntimePane.tsx
Normal file
49
src/app/main/chatV2/components/RuntimePane.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Empty, Spin, Typography } from 'antd';
|
||||
import type { ChatRuntimeSnapshot } from '../../mainChatPanel/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type RuntimePaneProps = {
|
||||
snapshot: ChatRuntimeSnapshot | null;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export function RuntimePane({ snapshot, isLoading, errorMessage }: RuntimePaneProps) {
|
||||
return (
|
||||
<section className="chat-v2__pane chat-v2__pane--runtime">
|
||||
<div className="chat-v2__pane-header">
|
||||
<Text strong>런타임</Text>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="chat-v2__state">
|
||||
<Spin />
|
||||
</div>
|
||||
) : errorMessage ? (
|
||||
<div className="chat-v2__state">
|
||||
<Text type="danger">{errorMessage}</Text>
|
||||
</div>
|
||||
) : !snapshot ? (
|
||||
<div className="chat-v2__state">
|
||||
<Empty description="런타임 데이터가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-v2__summary-grid">
|
||||
<div className="chat-v2__summary-card">
|
||||
<Text type="secondary">실행 중</Text>
|
||||
<Text strong>{snapshot.runningCount}</Text>
|
||||
</div>
|
||||
<div className="chat-v2__summary-card">
|
||||
<Text type="secondary">대기 중</Text>
|
||||
<Text strong>{snapshot.queuedCount}</Text>
|
||||
</div>
|
||||
<div className="chat-v2__summary-card">
|
||||
<Text type="secondary">세션 수</Text>
|
||||
<Text strong>{snapshot.sessionCount}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/app/main/chatV2/data/chatClientEvents.ts
Normal file
34
src/app/main/chatV2/data/chatClientEvents.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
|
||||
export const CHAT_CONVERSATIONS_UPDATED_EVENT = 'work-server.chat-conversations-updated';
|
||||
|
||||
type ChatConversationsUpdatedDetail = {
|
||||
items: ChatConversationSummary[];
|
||||
};
|
||||
|
||||
export function emitChatConversationsUpdated(items: ChatConversationSummary[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ChatConversationsUpdatedDetail>(CHAT_CONVERSATIONS_UPDATED_EVENT, {
|
||||
detail: { items },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function readChatConversationsUpdatedEvent(
|
||||
event: Event,
|
||||
): ChatConversationsUpdatedDetail | null {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = event.detail;
|
||||
if (!detail || typeof detail !== 'object' || !Array.isArray((detail as { items?: unknown[] }).items)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return detail as ChatConversationsUpdatedDetail;
|
||||
}
|
||||
17
src/app/main/chatV2/data/chatConnectionGateway.ts
Normal file
17
src/app/main/chatV2/data/chatConnectionGateway.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
getChatConnectionSnapshot,
|
||||
getSharedChatRuntimeSnapshot,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
subscribeChatConnection,
|
||||
useChatConnection,
|
||||
} from '../../mainChatPanel/useChatConnection';
|
||||
import { resetLastReceivedChatEventId } from '../../mainChatPanel/chatUtils';
|
||||
|
||||
export const chatConnectionGateway = {
|
||||
useConnection: useChatConnection,
|
||||
subscribe: subscribeChatConnection,
|
||||
getSnapshot: getChatConnectionSnapshot,
|
||||
getSharedRuntimeSnapshot: getSharedChatRuntimeSnapshot,
|
||||
setSharedRuntimeSnapshot: setSharedChatRuntimeSnapshot,
|
||||
resetLastReceivedEventId: resetLastReceivedChatEventId,
|
||||
};
|
||||
83
src/app/main/chatV2/data/chatGateway.ts
Normal file
83
src/app/main/chatV2/data/chatGateway.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
createChatConversationRoom,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
fetchChatConversations,
|
||||
fetchChatRuntimeJobDetail,
|
||||
fetchChatRuntimeSnapshot,
|
||||
markChatConversationResponsesRead,
|
||||
renameChatConversationRoom,
|
||||
updateChatConversationRoom,
|
||||
uploadChatComposerFile,
|
||||
} from '../../mainChatPanel';
|
||||
import {
|
||||
dismissChatWebPushNotifications,
|
||||
markChatNotificationMessagesAsRead,
|
||||
} from '../../notificationApi';
|
||||
import type {
|
||||
ChatComposerAttachment,
|
||||
ChatConversationDetailResponse,
|
||||
ChatConversationSummary,
|
||||
ChatRuntimeJobDetail,
|
||||
ChatRuntimeSnapshot,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
export type ChatGateway = {
|
||||
listConversations: () => Promise<ChatConversationSummary[]>;
|
||||
getConversationDetail: (
|
||||
sessionId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
beforeMessageId?: number | null;
|
||||
},
|
||||
) => Promise<ChatConversationDetailResponse>;
|
||||
createConversation: (args: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
}) => Promise<ChatConversationSummary>;
|
||||
renameConversation: (sessionId: string, title: string) => Promise<ChatConversationSummary>;
|
||||
updateConversation: (
|
||||
sessionId: string,
|
||||
payload: Partial<
|
||||
Pick<ChatConversationSummary, 'title' | 'notifyOffline' | 'hasUnreadResponse'>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
deleteConversation: (sessionId: string) => Promise<void>;
|
||||
deleteConversationRequest: (sessionId: string, requestId: string) => Promise<void>;
|
||||
markConversationRead: (sessionId: string) => Promise<void>;
|
||||
fetchRuntimeSnapshot: () => Promise<ChatRuntimeSnapshot>;
|
||||
fetchRuntimeJobDetail: (requestId: string) => Promise<ChatRuntimeJobDetail>;
|
||||
uploadComposerFile: (sessionId: string, file: File) => Promise<ChatComposerAttachment>;
|
||||
dismissRoomNotifications: (sessionId: string) => Promise<void>;
|
||||
markRoomNotificationMessagesRead: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const chatGateway: ChatGateway = {
|
||||
listConversations: fetchChatConversations,
|
||||
getConversationDetail: fetchChatConversationDetail,
|
||||
createConversation: createChatConversationRoom,
|
||||
renameConversation: renameChatConversationRoom,
|
||||
updateConversation: updateChatConversationRoom,
|
||||
deleteConversation: async (sessionId) => {
|
||||
await deleteChatConversationRoom(sessionId);
|
||||
},
|
||||
deleteConversationRequest: async (sessionId, requestId) => {
|
||||
await deleteChatConversationRequest(sessionId, requestId);
|
||||
},
|
||||
markConversationRead: async (sessionId) => {
|
||||
await markChatConversationResponsesRead(sessionId);
|
||||
},
|
||||
fetchRuntimeSnapshot: fetchChatRuntimeSnapshot,
|
||||
fetchRuntimeJobDetail: fetchChatRuntimeJobDetail,
|
||||
uploadComposerFile: uploadChatComposerFile,
|
||||
dismissRoomNotifications: async (sessionId) => {
|
||||
await dismissChatWebPushNotifications(sessionId);
|
||||
},
|
||||
markRoomNotificationMessagesRead: async (sessionId) => {
|
||||
await markChatNotificationMessagesAsRead(sessionId);
|
||||
},
|
||||
};
|
||||
403
src/app/main/chatV2/hooks/useConversationComposerController.ts
Normal file
403
src/app/main/chatV2/hooks/useConversationComposerController.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { useCallback } from 'react';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
type PendingChatRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
type PendingContextConfirm = {
|
||||
mode: 'queue' | 'direct';
|
||||
text: string;
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
includedContextCount: number;
|
||||
omittedContextCount: number;
|
||||
};
|
||||
|
||||
type SelectedChatType = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isTemplate: boolean;
|
||||
} | null;
|
||||
|
||||
type RecentContextSummary = {
|
||||
includedCount: number;
|
||||
omittedCount: number;
|
||||
};
|
||||
|
||||
type UseConversationComposerControllerOptions = {
|
||||
activeSessionId: string;
|
||||
appConfigChat: {
|
||||
maxContextMessages: number;
|
||||
maxContextChars: number;
|
||||
};
|
||||
draft: string;
|
||||
composerAttachments: ChatComposerAttachment[];
|
||||
isComposerAttachmentUploading: boolean;
|
||||
selectedChatType: SelectedChatType;
|
||||
socketRef: { current: WebSocket | null };
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
messagesRef: { current: ChatMessage[] };
|
||||
pendingRequestsRef: { current: PendingChatRequest[] };
|
||||
shouldStickToBottomRef: { current: boolean };
|
||||
setDraft: (value: string) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setIsComposerAttachmentUploading: (value: boolean) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setShowScrollToBottom: (value: boolean) => void;
|
||||
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
|
||||
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
|
||||
upsertRequestItem: (request: ChatConversationRequest) => void;
|
||||
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
|
||||
createActivityLogPlaceholder: (requestId: string, lines: string[]) => ChatMessage | null;
|
||||
buildOutgoingMessageText: (text: string, attachments: ChatComposerAttachment[]) => string;
|
||||
summarizeRecentContext: (messages: ChatMessage[], maxMessages: number, maxChars: number) => RecentContextSummary;
|
||||
mergeComposerAttachments: (
|
||||
previous: ChatComposerAttachment[],
|
||||
next: ChatComposerAttachment[],
|
||||
) => ChatComposerAttachment[];
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
scrollViewportToBottom: () => void;
|
||||
};
|
||||
|
||||
export function useConversationComposerController({
|
||||
activeSessionId,
|
||||
appConfigChat,
|
||||
draft,
|
||||
composerAttachments,
|
||||
isComposerAttachmentUploading,
|
||||
selectedChatType,
|
||||
socketRef,
|
||||
composerRef,
|
||||
messagesRef,
|
||||
pendingRequestsRef,
|
||||
shouldStickToBottomRef,
|
||||
setDraft,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
setPendingContextConfirm,
|
||||
setStoredChatSessionLastTypeId,
|
||||
upsertRequestItem,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
createLocalMessage,
|
||||
createChatMessage,
|
||||
createActivityLogPlaceholder,
|
||||
buildOutgoingMessageText,
|
||||
summarizeRecentContext,
|
||||
mergeComposerAttachments,
|
||||
sendChatRequest,
|
||||
scrollViewportToBottom,
|
||||
}: UseConversationComposerControllerOptions) {
|
||||
const handleComposerFilesPicked = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0 || isComposerAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(true);
|
||||
const uploadResults = await Promise.allSettled(
|
||||
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
|
||||
);
|
||||
const uploadedItems: ChatComposerAttachment[] = [];
|
||||
const failedFileNames: string[] = [];
|
||||
|
||||
uploadResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
uploadedItems.push(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
|
||||
});
|
||||
|
||||
if (uploadedItems.length > 0) {
|
||||
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
}
|
||||
|
||||
if (failedFileNames.length > 0) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
|
||||
]);
|
||||
}
|
||||
|
||||
setIsComposerAttachmentUploading(false);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
isComposerAttachmentUploading,
|
||||
mergeComposerAttachments,
|
||||
setComposerAttachments,
|
||||
setIsComposerAttachmentUploading,
|
||||
setMessages,
|
||||
setShowScrollToBottom,
|
||||
shouldStickToBottomRef,
|
||||
],
|
||||
);
|
||||
|
||||
const focusComposerAfterSend = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
scrollViewportToBottom();
|
||||
}, 0);
|
||||
}, [composerRef, scrollViewportToBottom]);
|
||||
|
||||
const executeSendMessage = useCallback(
|
||||
(request: PendingContextConfirm) => {
|
||||
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
|
||||
const requestId = `client-${Date.now().toString(36)}`;
|
||||
const outgoingRequest: PendingChatRequest = {
|
||||
sessionId: activeSessionId,
|
||||
requestId,
|
||||
text,
|
||||
mode,
|
||||
chatTypeId,
|
||||
chatTypeLabel,
|
||||
chatTypeDescription,
|
||||
chatTypeIsTemplate,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
|
||||
|
||||
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,
|
||||
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);
|
||||
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
focusComposerAfterSend();
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createActivityLogPlaceholder,
|
||||
createChatMessage,
|
||||
focusComposerAfterSend,
|
||||
pendingRequestsRef,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setDraft,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setShowScrollToBottom,
|
||||
setStoredChatSessionLastTypeId,
|
||||
shouldStickToBottomRef,
|
||||
socketRef,
|
||||
syncConversationPreviewForRequest,
|
||||
updatePendingMessageStatus,
|
||||
upsertRequestItem,
|
||||
],
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(mode: 'queue' | 'direct') => {
|
||||
if (isComposerAttachmentUploading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChatType) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedChatType.isTemplate) {
|
||||
const recentContext = summarizeRecentContext(
|
||||
messagesRef.current,
|
||||
appConfigChat.maxContextMessages,
|
||||
appConfigChat.maxContextChars,
|
||||
);
|
||||
|
||||
if (recentContext.omittedCount > 0) {
|
||||
setPendingContextConfirm({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: false,
|
||||
includedContextCount: recentContext.includedCount,
|
||||
omittedContextCount: recentContext.omittedCount,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
executeSendMessage({
|
||||
mode,
|
||||
text: trimmed,
|
||||
chatTypeId: selectedChatType.id,
|
||||
chatTypeLabel: selectedChatType.name,
|
||||
chatTypeDescription: selectedChatType.description,
|
||||
chatTypeIsTemplate: selectedChatType.isTemplate,
|
||||
includedContextCount: 0,
|
||||
omittedContextCount: 0,
|
||||
});
|
||||
},
|
||||
[
|
||||
appConfigChat.maxContextChars,
|
||||
appConfigChat.maxContextMessages,
|
||||
buildOutgoingMessageText,
|
||||
composerAttachments,
|
||||
createLocalMessage,
|
||||
draft,
|
||||
executeSendMessage,
|
||||
isComposerAttachmentUploading,
|
||||
messagesRef,
|
||||
selectedChatType,
|
||||
setMessages,
|
||||
setPendingContextConfirm,
|
||||
summarizeRecentContext,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
sendMessage('queue');
|
||||
}, [sendMessage]);
|
||||
|
||||
const handleSendImmediate = useCallback(() => {
|
||||
sendMessage('direct');
|
||||
}, [sendMessage]);
|
||||
|
||||
return {
|
||||
executeSendMessage,
|
||||
handleComposerFilesPicked,
|
||||
handleSend,
|
||||
handleSendImmediate,
|
||||
sendMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useConversationListData as useConversationListController } from './useConversationListData';
|
||||
135
src/app/main/chatV2/hooks/useConversationListData.ts
Normal file
135
src/app/main/chatV2/hooks/useConversationListData.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import {
|
||||
CHAT_CONVERSATIONS_UPDATED_EVENT,
|
||||
readChatConversationsUpdatedEvent,
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
requestedSessionId: string;
|
||||
};
|
||||
|
||||
type UseConversationListDataResult = {
|
||||
conversationItems: ChatConversationSummary[];
|
||||
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
|
||||
isConversationListLoading: boolean;
|
||||
reloadConversationItems: () => Promise<void>;
|
||||
conversationSearch: string;
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export function useConversationListData({
|
||||
requestedSessionId,
|
||||
}: UseConversationListDataOptions): UseConversationListDataResult {
|
||||
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
|
||||
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
|
||||
const [conversationSearch, setConversationSearch] = useState('');
|
||||
|
||||
const loadConversationItems = async () => {
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
setConversationItems(items);
|
||||
} catch {
|
||||
setConversationItems([]);
|
||||
} finally {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
void chatGateway
|
||||
.listConversations()
|
||||
.then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems(items);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
setIsConversationListLoading(true);
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedSessionId || isConversationListLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const loadRequestedConversation = async () => {
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId);
|
||||
|
||||
if (isCancelled || response.item.sessionId !== requestedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
} catch {
|
||||
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
|
||||
}
|
||||
};
|
||||
|
||||
void loadRequestedConversation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [conversationItems, isConversationListLoading, requestedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConversationsUpdated = (event: Event) => {
|
||||
const detail = readChatConversationsUpdatedEvent(event);
|
||||
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems(detail.items);
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
isConversationListLoading,
|
||||
reloadConversationItems: loadConversationItems,
|
||||
conversationSearch,
|
||||
setConversationSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useCallback } from 'react';
|
||||
import { removeChatRuntimeJob } from '../../mainChatPanel';
|
||||
import { chatConnectionGateway } from '../data/chatConnectionGateway';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatComposerAttachment,
|
||||
ChatConversationRequest,
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
type PendingChatRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
text: string;
|
||||
mode: 'queue' | 'direct';
|
||||
chatTypeId: string;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
retryCount: number;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
type UseConversationRoomActionsControllerOptions = {
|
||||
activeSessionId: string;
|
||||
requestedSessionId: string;
|
||||
conversationItems: ChatConversationSummary[];
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
editingConversationTitle: string;
|
||||
isMobileViewport: boolean;
|
||||
pendingRequestsRef: { current: PendingChatRequest[] };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
socketRef: { current: WebSocket | null };
|
||||
setConversationItems: React.Dispatch<React.SetStateAction<ChatConversationSummary[]>>;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
setRequestItems: React.Dispatch<React.SetStateAction<ChatConversationRequest[]>>;
|
||||
setActiveSessionId: (value: string) => void;
|
||||
setDraft: (value: string) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setCopiedMessageId: (value: number | null) => void;
|
||||
setActivePreviewId: (value: string | null) => void;
|
||||
setIsPreviewModalOpen: (value: boolean) => void;
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
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;
|
||||
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
|
||||
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
|
||||
createLocalMessage: (text: string) => ChatMessage;
|
||||
replaceChatSessionInUrl: (sessionId: string) => void;
|
||||
messageApi: {
|
||||
error: (content: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function useConversationRoomActionsController({
|
||||
activeSessionId,
|
||||
requestedSessionId,
|
||||
conversationItems,
|
||||
activeConversation,
|
||||
editingConversationTitle,
|
||||
isMobileViewport,
|
||||
pendingRequestsRef,
|
||||
sessionMessageCacheRef,
|
||||
socketRef,
|
||||
setConversationItems,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
setActiveSessionId,
|
||||
setDraft,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setActivePreviewId,
|
||||
setIsPreviewModalOpen,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setIsResourceStripOpen,
|
||||
setIsConversationPaneClosed,
|
||||
setIsMobileConversationView,
|
||||
setRenamingConversationSessionId,
|
||||
setEditingConversationTitle,
|
||||
setIsEditingConversationTitle,
|
||||
updatePendingMessageStatus,
|
||||
sendChatRequest,
|
||||
createLocalMessage,
|
||||
replaceChatSessionInUrl,
|
||||
messageApi,
|
||||
}: UseConversationRoomActionsControllerOptions) {
|
||||
const removeOptimisticRequestMessages = useCallback(
|
||||
(requestId: string) => {
|
||||
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
|
||||
},
|
||||
[setMessages],
|
||||
);
|
||||
|
||||
const retryPendingRequest = useCallback(
|
||||
(requestId: string) => {
|
||||
const currentRequest = pendingRequestsRef.current.find(
|
||||
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
|
||||
);
|
||||
|
||||
if (!currentRequest) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage('재전송할 요청 정보를 찾지 못했습니다. 같은 내용을 다시 보내 주세요.'),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const resetRequest: PendingChatRequest = {
|
||||
...currentRequest,
|
||||
retryCount: 0,
|
||||
failed: false,
|
||||
};
|
||||
|
||||
setActiveSystemStatus('전송 재시도 중...');
|
||||
setIsSystemStatusPending(true);
|
||||
|
||||
const socket = socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
|
||||
resetRequest,
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
sendChatRequest(socket, resetRequest);
|
||||
updatePendingMessageStatus(requestId, null, 0);
|
||||
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
|
||||
} catch {
|
||||
updatePendingMessageStatus(requestId, 'retrying', 0);
|
||||
pendingRequestsRef.current = [
|
||||
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
|
||||
resetRequest,
|
||||
];
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
pendingRequestsRef,
|
||||
sendChatRequest,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
socketRef,
|
||||
updatePendingMessageStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelPendingRequest = useCallback(
|
||||
(requestId: string) => {
|
||||
const currentRequest = pendingRequestsRef.current.find(
|
||||
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
|
||||
);
|
||||
|
||||
if (!currentRequest) {
|
||||
removeOptimisticRequestMessages(requestId);
|
||||
setActiveSystemStatus('미접수 요청을 화면에서 제거했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
|
||||
removeOptimisticRequestMessages(requestId);
|
||||
setRequestItems((previous) =>
|
||||
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
|
||||
);
|
||||
setActiveSystemStatus('실패한 요청을 취소했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
pendingRequestsRef,
|
||||
removeOptimisticRequestMessages,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
const removeQueuedComposerRequest = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await removeChatRuntimeJob(requestId);
|
||||
setRequestItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId && item.requestId === requestId
|
||||
? {
|
||||
...item,
|
||||
status: 'removed',
|
||||
statusMessage: '대기열에서 제거되었습니다.',
|
||||
terminalAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setActiveSystemStatus('대기 요청을 삭제했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
} catch (error) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(error instanceof Error ? error.message : '대기 요청 제거 중 오류가 발생했습니다.'),
|
||||
]);
|
||||
}
|
||||
},
|
||||
[activeSessionId, createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages, setRequestItems],
|
||||
);
|
||||
|
||||
const deleteStoredRequest = useCallback(
|
||||
async (requestId: string) => {
|
||||
try {
|
||||
await chatGateway.deleteConversationRequest(activeSessionId, requestId);
|
||||
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
|
||||
setRequestItems((previous) =>
|
||||
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
|
||||
);
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId
|
||||
? {
|
||||
...item,
|
||||
currentRequestId: item.currentRequestId === requestId ? null : item.currentRequestId,
|
||||
currentJobStatus: item.currentRequestId === requestId ? null : item.currentJobStatus,
|
||||
currentJobMessage: item.currentRequestId === requestId ? null : item.currentJobMessage,
|
||||
currentQueueSize: item.currentRequestId === requestId ? 0 : item.currentQueueSize,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
setActiveSystemStatus('요청을 삭제했습니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
} catch (error) {
|
||||
setMessages((previous) => [
|
||||
...previous.slice(-39),
|
||||
createLocalMessage(error instanceof Error ? error.message : '요청 삭제 중 오류가 발생했습니다.'),
|
||||
]);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
createLocalMessage,
|
||||
setActiveSystemStatus,
|
||||
setConversationItems,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
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 = 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 {
|
||||
await chatGateway.deleteConversation(sessionId);
|
||||
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
|
||||
sessionMessageCacheRef.current.delete(sessionId);
|
||||
setConversationItems(remaining);
|
||||
|
||||
if (sessionId === activeSessionId) {
|
||||
replaceChatSessionInUrl('');
|
||||
chatConnectionGateway.resetLastReceivedEventId('');
|
||||
setActiveSessionId('');
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
setIsConversationPaneClosed(false);
|
||||
setIsMobileConversationView(!isMobileViewport);
|
||||
} else if (requestedSessionId === sessionId) {
|
||||
replaceChatSessionInUrl(activeSessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '대화방 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
},
|
||||
[
|
||||
activeSessionId,
|
||||
conversationItems,
|
||||
isMobileViewport,
|
||||
messageApi,
|
||||
replaceChatSessionInUrl,
|
||||
requestedSessionId,
|
||||
sessionMessageCacheRef,
|
||||
setActivePreviewId,
|
||||
setActiveSessionId,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setConversationItems,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsConversationPaneClosed,
|
||||
setIsMobileConversationView,
|
||||
setIsPreviewModalOpen,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
cancelPendingRequest,
|
||||
deleteStoredRequest,
|
||||
handleDeleteConversation,
|
||||
handleRenameConversation,
|
||||
removeQueuedComposerRequest,
|
||||
retryPendingRequest,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useConversationRoomData as useConversationRoomController } from './useConversationRoomData';
|
||||
307
src/app/main/chatV2/hooks/useConversationRoomData.ts
Normal file
307
src/app/main/chatV2/hooks/useConversationRoomData.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatConversationRequest,
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3;
|
||||
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20;
|
||||
|
||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
return cache.get(normalizedSessionId) ?? [];
|
||||
}
|
||||
|
||||
function getBestAvailableSessionMessages(
|
||||
cache: Map<string, ChatMessage[]>,
|
||||
sessionId: string,
|
||||
currentSessionId: string,
|
||||
currentMessages: ChatMessage[],
|
||||
) {
|
||||
const cachedMessages = getCachedSessionMessages(cache, sessionId);
|
||||
|
||||
if (sessionId !== currentSessionId || currentMessages.length === 0) {
|
||||
return cachedMessages;
|
||||
}
|
||||
|
||||
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
|
||||
}
|
||||
|
||||
type UseConversationRoomDataOptions = {
|
||||
activeSessionId: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
shouldBlockConversationWhileLoading: (sessionId: string) => boolean;
|
||||
captureViewportRestoreSnapshot: () => void;
|
||||
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
|
||||
messagesRef: MutableRefObject<ChatMessage[]>;
|
||||
pendingViewportRestoreRef: MutableRefObject<boolean>;
|
||||
shouldRestoreConversationAfterReconnectRef: MutableRefObject<boolean>;
|
||||
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setRequestItems: Dispatch<SetStateAction<ChatConversationRequest[]>>;
|
||||
setConversationLoadingLabel: Dispatch<SetStateAction<string>>;
|
||||
setIsConversationContentLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setIsDeferringAuxiliaryChatRequests: Dispatch<SetStateAction<boolean>>;
|
||||
setHasOlderMessages: Dispatch<SetStateAction<boolean>>;
|
||||
setOldestLoadedMessageId: Dispatch<SetStateAction<number | null>>;
|
||||
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
|
||||
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
|
||||
viewportRef: MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export function useConversationRoomData({
|
||||
activeSessionId,
|
||||
connectionState,
|
||||
shouldBlockConversationWhileLoading,
|
||||
captureViewportRestoreSnapshot,
|
||||
sessionMessageCacheRef,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
shouldRestoreConversationAfterReconnectRef,
|
||||
setConversationItems,
|
||||
setMessages,
|
||||
setRequestItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setIsLoadingOlderMessages,
|
||||
queueViewportPrependRestore,
|
||||
viewportRef,
|
||||
}: UseConversationRoomDataOptions) {
|
||||
useEffect(() => {
|
||||
if (!activeSessionId.trim()) {
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setIsConversationContentLoading(false);
|
||||
setIsLoadingOlderMessages(false);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const loadConversationDetail = async () => {
|
||||
captureViewportRestoreSnapshot();
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId));
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
const cachedMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
setIsDeferringAuxiliaryChatRequests(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadConversationDetail();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
captureViewportRestoreSnapshot,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldBlockConversationWhileLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const restoreConversationAfterReconnect = async () => {
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0),
|
||||
});
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
const hasMessageDiff = nextMessages !== baseMessages;
|
||||
|
||||
if (hasMessageDiff) {
|
||||
captureViewportRestoreSnapshot();
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.');
|
||||
setIsConversationContentLoading(true);
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
|
||||
if (hasMessageDiff) {
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
shouldRestoreConversationAfterReconnectRef.current = false;
|
||||
if (!pendingViewportRestoreRef.current) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
setIsDeferringAuxiliaryChatRequests(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void restoreConversationAfterReconnect();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
captureViewportRestoreSnapshot,
|
||||
connectionState,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldRestoreConversationAfterReconnectRef,
|
||||
]);
|
||||
|
||||
const loadOlderMessages = async () => {
|
||||
const requestedSessionId = activeSessionId.trim();
|
||||
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null;
|
||||
|
||||
if (!requestedSessionId || oldestVisibleMessageId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingOlderMessages(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE,
|
||||
beforeMessageId: oldestVisibleMessageId,
|
||||
});
|
||||
|
||||
if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) {
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const previousScrollHeight = viewport?.scrollHeight ?? 0;
|
||||
const previousScrollTop = viewport?.scrollTop ?? 0;
|
||||
const nextMessages = mergeRecoveredChatMessages(messagesRef.current, response.messages);
|
||||
|
||||
queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
} finally {
|
||||
setIsLoadingOlderMessages(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loadOlderMessages,
|
||||
};
|
||||
}
|
||||
206
src/app/main/chatV2/hooks/useConversationViewController.ts
Normal file
206
src/app/main/chatV2/hooks/useConversationViewController.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
type PreviewItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
source: 'message' | 'context';
|
||||
};
|
||||
|
||||
type UseConversationViewControllerOptions = {
|
||||
activeSessionId: string;
|
||||
activeView: 'chat' | 'runtime' | 'errors';
|
||||
previewItems: PreviewItem[];
|
||||
selectedChatTypeId: string | null;
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setCopiedMessageId: (value: number | null) => void;
|
||||
setDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||
setIsResourceStripOpen: (value: boolean) => void;
|
||||
setIsSystemStatusPending: (value: boolean) => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
||||
};
|
||||
|
||||
export function useConversationViewController({
|
||||
activeSessionId,
|
||||
activeView,
|
||||
composerRef,
|
||||
previewItems,
|
||||
selectedChatTypeId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
}: UseConversationViewControllerOptions) {
|
||||
const previousSessionIdRef = useRef(activeSessionId);
|
||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
|
||||
const [previewText, setPreviewText] = useState('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState('');
|
||||
const [previewContentType, setPreviewContentType] = useState('');
|
||||
|
||||
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
|
||||
|
||||
if (!hasSessionChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousSessionIdRef.current = activeSessionId;
|
||||
|
||||
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
setIsResourceStripOpen(false);
|
||||
}, [
|
||||
activeSessionId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
setDraft,
|
||||
setIsResourceStripOpen,
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePreviewId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewItems.some((item) => item.id === activePreviewId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
}, [activePreviewId, previewItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen || !activePreview) {
|
||||
setPreviewText('');
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
setIsPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activePreview.kind === 'image' || activePreview.kind === 'video' || activePreview.kind === 'pdf') {
|
||||
setPreviewText('');
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
setIsPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setIsPreviewLoading(true);
|
||||
setPreviewError('');
|
||||
setPreviewContentType('');
|
||||
|
||||
fetch(activePreview.url, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
setPreviewContentType(response.headers.get('content-type') ?? '');
|
||||
const text = await response.text();
|
||||
setPreviewText(text.slice(0, 20000));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPreviewText('');
|
||||
setPreviewContentType('');
|
||||
setPreviewError(error instanceof Error ? error.message : 'preview를 가져오지 못했습니다.');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [activePreview, isPreviewModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
}, [activeView, composerRef, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
const isTextEntryTarget =
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target.isContentEditable ||
|
||||
['input', 'textarea', 'select'].includes(event.target.tagName.toLowerCase()) ||
|
||||
event.target.closest('[contenteditable="true"]'));
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.altKey || isTextEntryTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === '/') {
|
||||
event.preventDefault();
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.length === 1) {
|
||||
event.preventDefault();
|
||||
composerRef.current?.focus({ cursor: 'end' });
|
||||
setDraft((previous) => previous + event.key);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
};
|
||||
}, [activeView, composerRef, setDraft]);
|
||||
|
||||
return {
|
||||
activePreview,
|
||||
activePreviewId,
|
||||
isPreviewLoading,
|
||||
isPreviewModalOpen,
|
||||
previewContentType,
|
||||
previewError,
|
||||
previewText,
|
||||
setActivePreviewId,
|
||||
setIsPreviewModalOpen,
|
||||
};
|
||||
}
|
||||
406
src/app/main/chatV2/hooks/useConversationViewportController.ts
Normal file
406
src/app/main/chatV2/hooks/useConversationViewportController.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useRef, useState, type TouchEvent } from 'react';
|
||||
import type {
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
ChatRuntimeSnapshot,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
type UseConversationViewportControllerOptions = {
|
||||
activeConversation: ChatConversationSummary | null;
|
||||
activeQueuedComposerRequestsCount: number;
|
||||
activeRuntimeStatus: string | null;
|
||||
chatMessageCount: number;
|
||||
chatMessageSyncKey: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
isConversationContentLoading: boolean;
|
||||
messages: ChatMessage[];
|
||||
runtimeSnapshot: ChatRuntimeSnapshot | null;
|
||||
viewportRef: { current: HTMLDivElement | null };
|
||||
hasOlderMessages: boolean;
|
||||
isLoadingOlderMessages: boolean;
|
||||
onLoadOlderMessages: () => void | Promise<void>;
|
||||
mapJobStatusLabel: (
|
||||
item: Pick<ChatConversationSummary, 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize'>,
|
||||
) => string;
|
||||
mapSystemStatusMessage: (text: string) => string | null;
|
||||
isActivityLogMessage: (message: ChatMessage) => boolean;
|
||||
};
|
||||
|
||||
export function useConversationViewportController({
|
||||
activeConversation,
|
||||
activeQueuedComposerRequestsCount,
|
||||
activeRuntimeStatus,
|
||||
chatMessageCount,
|
||||
chatMessageSyncKey,
|
||||
connectionState,
|
||||
isConversationContentLoading,
|
||||
messages,
|
||||
runtimeSnapshot,
|
||||
viewportRef,
|
||||
hasOlderMessages,
|
||||
isLoadingOlderMessages,
|
||||
onLoadOlderMessages,
|
||||
mapJobStatusLabel,
|
||||
mapSystemStatusMessage,
|
||||
isActivityLogMessage,
|
||||
}: UseConversationViewportControllerOptions) {
|
||||
const [activeSystemStatus, setActiveSystemStatus] = useState<string | null>(null);
|
||||
const [isSystemStatusPending, setIsSystemStatusPending] = useState(false);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
|
||||
const systemStatusTimerRef = useRef<number | null>(null);
|
||||
const restoreAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const pendingViewportRestoreRef = useRef(false);
|
||||
const pendingPrependRestoreRef = useRef<{
|
||||
previousScrollHeight: number;
|
||||
previousScrollTop: number;
|
||||
} | null>(null);
|
||||
const viewportRestoreSnapshotRef = useRef<{
|
||||
shouldStickToBottom: boolean;
|
||||
offsetFromBottom: number;
|
||||
} | null>(null);
|
||||
const touchStartYRef = useRef<number | null>(null);
|
||||
const touchPullActiveRef = useRef(false);
|
||||
const [pullToLoadDistance, setPullToLoadDistance] = useState(0);
|
||||
const [isPullToLoadArmed, setIsPullToLoadArmed] = useState(false);
|
||||
|
||||
const clearSystemStatusTimer = useCallback(() => {
|
||||
if (systemStatusTimerRef.current !== null) {
|
||||
window.clearTimeout(systemStatusTimerRef.current);
|
||||
systemStatusTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollViewportToBottom = useCallback(
|
||||
(behavior: ScrollBehavior = 'smooth') => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior,
|
||||
});
|
||||
},
|
||||
[viewportRef],
|
||||
);
|
||||
|
||||
const scheduleViewportBottomSync = useCallback(
|
||||
(frameCount = 6) => {
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
}
|
||||
|
||||
const run = (remainingFrames: number) => {
|
||||
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
if (!shouldStickToBottomRef.current || isConversationContentLoading) {
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
|
||||
if (remainingFrames <= 1) {
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
run(remainingFrames - 1);
|
||||
});
|
||||
};
|
||||
|
||||
run(frameCount);
|
||||
},
|
||||
[isConversationContentLoading, scrollViewportToBottom],
|
||||
);
|
||||
|
||||
const handleViewportScroll = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||
const isNearBottom = remainingDistance <= 24;
|
||||
|
||||
shouldStickToBottomRef.current = isNearBottom;
|
||||
setShowScrollToBottom(!isNearBottom);
|
||||
}, [viewportRef]);
|
||||
|
||||
const captureViewportRestoreSnapshot = useCallback(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
viewportRestoreSnapshotRef.current = {
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
offsetFromBottom: 0,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
viewportRestoreSnapshotRef.current = {
|
||||
shouldStickToBottom: shouldStickToBottomRef.current,
|
||||
offsetFromBottom: Math.max(0, viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight),
|
||||
};
|
||||
}, [viewportRef]);
|
||||
|
||||
const queueViewportPrependRestore = useCallback((previousScrollHeight: number, previousScrollTop: number) => {
|
||||
pendingPrependRestoreRef.current = {
|
||||
previousScrollHeight,
|
||||
previousScrollTop,
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto');
|
||||
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConversationContentLoading || !shouldStickToBottomRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleViewportBottomSync();
|
||||
|
||||
return () => {
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
restoreAutoScrollFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]);
|
||||
|
||||
useEffect(() => {
|
||||
const pendingPrependRestore = pendingPrependRestoreRef.current;
|
||||
|
||||
if (!pendingPrependRestore) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPrependRestoreRef.current = null;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScrollTop =
|
||||
pendingPrependRestore.previousScrollTop + (viewport.scrollHeight - pendingPrependRestore.previousScrollHeight);
|
||||
viewport.scrollTop = Math.max(0, nextScrollTop);
|
||||
handleViewportScroll();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [chatMessageCount, chatMessageSyncKey, handleViewportScroll, viewportRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isConversationContentLoading || !pendingViewportRestoreRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const restoreSnapshot = viewportRestoreSnapshotRef.current;
|
||||
|
||||
pendingViewportRestoreRef.current = false;
|
||||
viewportRestoreSnapshotRef.current = null;
|
||||
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
scrollViewportToBottom('auto');
|
||||
return;
|
||||
}
|
||||
|
||||
shouldStickToBottomRef.current = false;
|
||||
viewport.scrollTop = Math.max(
|
||||
0,
|
||||
viewport.scrollHeight - viewport.clientHeight - restoreSnapshot.offsetFromBottom,
|
||||
);
|
||||
handleViewportScroll();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [
|
||||
chatMessageCount,
|
||||
handleViewportScroll,
|
||||
isConversationContentLoading,
|
||||
scrollViewportToBottom,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
|
||||
touchStartYRef.current = null;
|
||||
touchPullActiveRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
touchPullActiveRef.current = true;
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
|
||||
|
||||
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
|
||||
if (!touchPullActiveRef.current || touchStartYRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const currentY = event.touches[0]?.clientY ?? null;
|
||||
|
||||
if (!viewport || currentY == null || viewport.scrollTop > 0) {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
setIsPullToLoadArmed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = Math.max(0, currentY - touchStartYRef.current);
|
||||
const nextDistance = Math.min(96, deltaY * 0.45);
|
||||
|
||||
if (nextDistance > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
setPullToLoadDistance(nextDistance);
|
||||
setIsPullToLoadArmed(nextDistance >= 52);
|
||||
}, [viewportRef]);
|
||||
|
||||
const resetPullToLoad = useCallback(() => {
|
||||
touchPullActiveRef.current = false;
|
||||
touchStartYRef.current = null;
|
||||
setPullToLoadDistance(0);
|
||||
setIsPullToLoadArmed(false);
|
||||
}, []);
|
||||
|
||||
const handleViewportTouchEnd = useCallback(() => {
|
||||
const shouldLoadOlder = touchPullActiveRef.current && isPullToLoadArmed && hasOlderMessages && !isLoadingOlderMessages;
|
||||
|
||||
resetPullToLoad();
|
||||
|
||||
if (shouldLoadOlder) {
|
||||
void onLoadOlderMessages();
|
||||
}
|
||||
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState === 'disconnected') {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus('워크서버 연결이 끊어졌습니다. 실제 처리 상태를 다시 확인하는 중입니다.');
|
||||
setIsSystemStatusPending(false);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeRuntimeStatus) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(activeRuntimeStatus);
|
||||
setIsSystemStatusPending(true);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeConversation?.currentJobStatus && !runtimeSnapshot) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(mapJobStatusLabel(activeConversation));
|
||||
setIsSystemStatusPending(
|
||||
activeConversation.currentJobStatus === 'queued' || activeConversation.currentJobStatus === 'started',
|
||||
);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
if (activeQueuedComposerRequestsCount > 0) {
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(`실제 대기열 ${activeQueuedComposerRequestsCount}건`);
|
||||
setIsSystemStatusPending(true);
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
clearSystemStatusTimer();
|
||||
setActiveSystemStatus(null);
|
||||
setIsSystemStatusPending(false);
|
||||
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') {
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
const isTerminalStatus =
|
||||
latestMessage.text.startsWith('요청 처리가 끝났습니다.') ||
|
||||
latestMessage.text.startsWith('즉시 요청 처리가 끝났습니다.') ||
|
||||
latestMessage.text.startsWith('요청 처리 중 오류가 발생했습니다.');
|
||||
|
||||
const nextStatus = mapSystemStatusMessage(latestMessage.text);
|
||||
if (!nextStatus || isTerminalStatus) {
|
||||
return clearSystemStatusTimer;
|
||||
}
|
||||
|
||||
setActiveSystemStatus(nextStatus);
|
||||
setIsSystemStatusPending(true);
|
||||
|
||||
return clearSystemStatusTimer;
|
||||
}, [
|
||||
activeConversation,
|
||||
activeQueuedComposerRequestsCount,
|
||||
activeRuntimeStatus,
|
||||
clearSystemStatusTimer,
|
||||
connectionState,
|
||||
isActivityLogMessage,
|
||||
mapJobStatusLabel,
|
||||
mapSystemStatusMessage,
|
||||
messages,
|
||||
runtimeSnapshot,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSystemStatusTimer();
|
||||
|
||||
if (restoreAutoScrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [clearSystemStatusTimer]);
|
||||
|
||||
return {
|
||||
activeSystemStatus,
|
||||
captureViewportRestoreSnapshot,
|
||||
handleViewportScroll,
|
||||
isSystemStatusPending,
|
||||
pendingViewportRestoreRef,
|
||||
scrollViewportToBottom,
|
||||
setActiveSystemStatus,
|
||||
setIsSystemStatusPending,
|
||||
setShowScrollToBottom,
|
||||
shouldStickToBottomRef,
|
||||
showScrollToBottom,
|
||||
handleViewportTouchEnd,
|
||||
handleViewportTouchMove,
|
||||
handleViewportTouchStart,
|
||||
isPullToLoadArmed,
|
||||
pullToLoadDistance,
|
||||
queueViewportPrependRestore,
|
||||
};
|
||||
}
|
||||
171
src/app/main/chatV2/hooks/useNotificationCenterData.ts
Normal file
171
src/app/main/chatV2/hooks/useNotificationCenterData.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
NOTIFICATION_MESSAGES_UPDATED_EVENT,
|
||||
deleteNotificationMessage,
|
||||
fetchNotificationMessage,
|
||||
fetchNotificationMessages,
|
||||
updateNotificationMessageReadState,
|
||||
type NotificationMessageItem,
|
||||
} from '../../notificationApi';
|
||||
import { useUnreadCounts } from './useUnreadCounts';
|
||||
|
||||
function mergeMessageItem(items: NotificationMessageItem[], nextItem: NotificationMessageItem) {
|
||||
const hasItem = items.some((item) => item.id === nextItem.id);
|
||||
|
||||
if (!hasItem) {
|
||||
return [nextItem, ...items];
|
||||
}
|
||||
|
||||
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
|
||||
}
|
||||
|
||||
export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
|
||||
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [toggleReadLoading, setToggleReadLoading] = useState(false);
|
||||
const [deletingMessageId, setDeletingMessageId] = useState<number | null>(null);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [detailError, setDetailError] = useState<string | null>(null);
|
||||
const { notificationUnreadCount: unreadCount, refreshNotificationUnreadCount } = useUnreadCounts();
|
||||
|
||||
const refreshUnreadCount = async () => {
|
||||
await refreshNotificationUnreadCount();
|
||||
};
|
||||
|
||||
const loadMessages = async () => {
|
||||
setListLoading(true);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchNotificationMessages({ limit: 30 });
|
||||
setMessages(response.items);
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyUpdatedMessage = (nextItem: NotificationMessageItem) => {
|
||||
setSelectedMessage(nextItem);
|
||||
setMessages((current) => {
|
||||
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false;
|
||||
const nextItems = mergeMessageItem(current, nextItem);
|
||||
|
||||
if (wasUnread !== nextItem.read) {
|
||||
void refreshNotificationUnreadCount();
|
||||
}
|
||||
|
||||
return nextItems;
|
||||
});
|
||||
};
|
||||
|
||||
const openMessageDetail = async (id: number) => {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetailError(null);
|
||||
setSelectedMessage(null);
|
||||
|
||||
try {
|
||||
const detail = await fetchNotificationMessage(id);
|
||||
setSelectedMessage(detail);
|
||||
|
||||
if (!detail.read) {
|
||||
const updated = await updateNotificationMessageReadState(id, true);
|
||||
applyUpdatedMessage(updated);
|
||||
}
|
||||
} catch (error) {
|
||||
setDetailError(error instanceof Error ? error.message : '알림 상세를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleReadState = async () => {
|
||||
if (!selectedMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
setToggleReadLoading(true);
|
||||
|
||||
try {
|
||||
const updated = await updateNotificationMessageReadState(selectedMessage.id, !selectedMessage.read);
|
||||
applyUpdatedMessage(updated);
|
||||
} catch (error) {
|
||||
setDetailError(error instanceof Error ? error.message : '읽음 상태를 변경하지 못했습니다.');
|
||||
} finally {
|
||||
setToggleReadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (id: number) => {
|
||||
if (deletingMessageId === id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingMessageId(id);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
await deleteNotificationMessage(id);
|
||||
setMessages((current) => current.filter((item) => item.id !== id));
|
||||
void refreshNotificationUnreadCount();
|
||||
|
||||
if (selectedMessage?.id === id) {
|
||||
setSelectedMessage(null);
|
||||
setDetailOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : '알림을 삭제하지 못했습니다.');
|
||||
} finally {
|
||||
setDeletingMessageId((current) => (current === id ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUnreadCount();
|
||||
|
||||
const handleMessagesUpdated = () => {
|
||||
void refreshUnreadCount();
|
||||
|
||||
if (drawerOpen) {
|
||||
void loadMessages();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
|
||||
};
|
||||
}, [drawerOpen, refreshNotificationUnreadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadMessages();
|
||||
}, [drawerOpen]);
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
messages,
|
||||
selectedMessage,
|
||||
listLoading,
|
||||
detailLoading,
|
||||
toggleReadLoading,
|
||||
deletingMessageId,
|
||||
listError,
|
||||
detailError,
|
||||
loadMessages,
|
||||
openMessageDetail,
|
||||
handleToggleReadState,
|
||||
handleDeleteMessage,
|
||||
};
|
||||
}
|
||||
1
src/app/main/chatV2/hooks/useNotificationController.ts
Normal file
1
src/app/main/chatV2/hooks/useNotificationController.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useNotificationCenterData as useNotificationController } from './useNotificationCenterData';
|
||||
1
src/app/main/chatV2/hooks/useRuntimeController.ts
Normal file
1
src/app/main/chatV2/hooks/useRuntimeController.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useRuntimeData as useRuntimeController } from './useRuntimeData';
|
||||
94
src/app/main/chatV2/hooks/useRuntimeData.ts
Normal file
94
src/app/main/chatV2/hooks/useRuntimeData.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { chatConnectionGateway } from '../data/chatConnectionGateway';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from '../../mainChatPanel/types';
|
||||
|
||||
type UseRuntimeDataOptions = {
|
||||
activeSessionId: string;
|
||||
isDeferringAuxiliaryChatRequests: boolean;
|
||||
};
|
||||
|
||||
export function useRuntimeData({
|
||||
activeSessionId,
|
||||
isDeferringAuxiliaryChatRequests,
|
||||
}: UseRuntimeDataOptions) {
|
||||
const [runtimeSnapshot, setRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(null);
|
||||
const [runtimeJobDetail, setRuntimeJobDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const loadRuntimeSnapshot = async () => {
|
||||
try {
|
||||
const snapshot = await chatGateway.fetchRuntimeSnapshot();
|
||||
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(null);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadRuntimeSnapshot();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeferringAuxiliaryChatRequests) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const syncRuntimeSnapshotForActiveSession = async () => {
|
||||
try {
|
||||
const snapshot = await chatGateway.fetchRuntimeSnapshot();
|
||||
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setRuntimeSnapshot(null);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void syncRuntimeSnapshotForActiveSession();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [activeSessionId, isDeferringAuxiliaryChatRequests]);
|
||||
|
||||
const handleRuntimeEvent = (snapshot: ChatRuntimeSnapshot) => {
|
||||
setRuntimeSnapshot(snapshot);
|
||||
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
|
||||
};
|
||||
|
||||
const handleRuntimeDetailEvent = (detail: ChatRuntimeJobDetail) => {
|
||||
setRuntimeJobDetail(detail);
|
||||
};
|
||||
|
||||
return {
|
||||
runtimeSnapshot,
|
||||
runtimeJobDetail,
|
||||
setRuntimeSnapshot,
|
||||
handleRuntimeEvent,
|
||||
handleRuntimeDetailEvent,
|
||||
};
|
||||
}
|
||||
143
src/app/main/chatV2/hooks/useUnreadCounts.ts
Normal file
143
src/app/main/chatV2/hooks/useUnreadCounts.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchNotificationMessages,
|
||||
NOTIFICATION_MESSAGES_UPDATED_EVENT,
|
||||
} from '../../notificationApi';
|
||||
import {
|
||||
CHAT_CONVERSATIONS_UPDATED_EVENT,
|
||||
readChatConversationsUpdatedEvent,
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseUnreadCountsResult = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
refreshChatUnreadCount: () => Promise<void>;
|
||||
refreshNotificationUnreadCount: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SharedUnreadCountsState = {
|
||||
chatUnreadCount: number;
|
||||
notificationUnreadCount: number;
|
||||
hasLoaded: boolean;
|
||||
chatRequestPromise: Promise<void> | null;
|
||||
notificationRequestPromise: Promise<void> | null;
|
||||
subscribers: Set<() => void>;
|
||||
};
|
||||
|
||||
const sharedUnreadCountsState: SharedUnreadCountsState = {
|
||||
chatUnreadCount: 0,
|
||||
notificationUnreadCount: 0,
|
||||
hasLoaded: false,
|
||||
chatRequestPromise: null,
|
||||
notificationRequestPromise: null,
|
||||
subscribers: new Set(),
|
||||
};
|
||||
|
||||
function emitUnreadCounts() {
|
||||
sharedUnreadCountsState.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeUnreadCounts(listener: () => void) {
|
||||
sharedUnreadCountsState.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedUnreadCountsState.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshChatUnreadCount() {
|
||||
if (sharedUnreadCountsState.chatRequestPromise) {
|
||||
return sharedUnreadCountsState.chatRequestPromise;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.chatRequestPromise = (async () => {
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
sharedUnreadCountsState.chatUnreadCount = items.filter((item) => item.hasUnreadResponse).length;
|
||||
} catch {
|
||||
sharedUnreadCountsState.chatUnreadCount = 0;
|
||||
} finally {
|
||||
sharedUnreadCountsState.chatRequestPromise = null;
|
||||
emitUnreadCounts();
|
||||
}
|
||||
})();
|
||||
|
||||
return sharedUnreadCountsState.chatRequestPromise;
|
||||
}
|
||||
|
||||
async function refreshNotificationUnreadCount() {
|
||||
if (sharedUnreadCountsState.notificationRequestPromise) {
|
||||
return sharedUnreadCountsState.notificationRequestPromise;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.notificationRequestPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchNotificationMessages({ limit: 1 });
|
||||
sharedUnreadCountsState.notificationUnreadCount = response.unreadCount;
|
||||
} catch {
|
||||
// Keep the previous badge count when the server is temporarily unavailable.
|
||||
} finally {
|
||||
sharedUnreadCountsState.notificationRequestPromise = null;
|
||||
emitUnreadCounts();
|
||||
}
|
||||
})();
|
||||
|
||||
return sharedUnreadCountsState.notificationRequestPromise;
|
||||
}
|
||||
|
||||
export function useUnreadCounts(): UseUnreadCountsResult {
|
||||
const [chatUnreadCount, setChatUnreadCount] = useState(sharedUnreadCountsState.chatUnreadCount);
|
||||
const [notificationUnreadCount, setNotificationUnreadCount] = useState(sharedUnreadCountsState.notificationUnreadCount);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeUnreadCounts(() => {
|
||||
setChatUnreadCount(sharedUnreadCountsState.chatUnreadCount);
|
||||
setNotificationUnreadCount(sharedUnreadCountsState.notificationUnreadCount);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharedUnreadCountsState.hasLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.hasLoaded = true;
|
||||
void refreshChatUnreadCount();
|
||||
void refreshNotificationUnreadCount();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleNotificationMessagesUpdated = () => {
|
||||
void refreshNotificationUnreadCount();
|
||||
};
|
||||
|
||||
const handleChatConversationsUpdated = (event: Event) => {
|
||||
const detail = readChatConversationsUpdatedEvent(event);
|
||||
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedUnreadCountsState.chatUnreadCount = detail.items.filter((item) => item.hasUnreadResponse).length;
|
||||
emitUnreadCounts();
|
||||
};
|
||||
|
||||
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
|
||||
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
|
||||
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatUnreadCount,
|
||||
notificationUnreadCount,
|
||||
refreshChatUnreadCount,
|
||||
refreshNotificationUnreadCount,
|
||||
};
|
||||
}
|
||||
11
src/app/main/chatV2/index.ts
Normal file
11
src/app/main/chatV2/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { ChatWorkspaceV2 } from './ChatWorkspaceV2';
|
||||
export { chatConnectionGateway } from './data/chatConnectionGateway';
|
||||
export { chatGateway } from './data/chatGateway';
|
||||
export type {
|
||||
ChatWorkspaceSelection,
|
||||
ChatWorkspaceView,
|
||||
ConversationListState,
|
||||
ConversationRoomState,
|
||||
RuntimePaneState,
|
||||
ErrorPaneState,
|
||||
} from './types';
|
||||
45
src/app/main/chatV2/types.ts
Normal file
45
src/app/main/chatV2/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
ChatConversationSummary,
|
||||
ChatMessage,
|
||||
ChatConversationRequest,
|
||||
ChatRuntimeSnapshot,
|
||||
} from '../mainChatPanel/types';
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatWorkspaceView = 'chat' | 'runtime' | 'errors';
|
||||
|
||||
export type ChatWorkspaceSelection = {
|
||||
activeSessionId: string;
|
||||
activeView: ChatWorkspaceView;
|
||||
isConversationPaneClosed: boolean;
|
||||
isMobileConversationView: boolean;
|
||||
};
|
||||
|
||||
export type ConversationListState = {
|
||||
items: ChatConversationSummary[];
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
export type ConversationRoomState = {
|
||||
sessionId: string;
|
||||
messages: ChatMessage[];
|
||||
requests: ChatConversationRequest[];
|
||||
isLoading: boolean;
|
||||
loadingLabel: string;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type RuntimePaneState = {
|
||||
snapshot: ChatRuntimeSnapshot | null;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
export type ErrorPaneState = {
|
||||
items: ErrorLogItem[];
|
||||
selectedItem: ErrorLogItem | null;
|
||||
isLoading: boolean;
|
||||
errorMessage: string;
|
||||
};
|
||||
102
src/app/main/clientIdentity.ts
Executable file
102
src/app/main/clientIdentity.ts
Executable file
@@ -0,0 +1,102 @@
|
||||
import type { AppPageDescriptor } from '../../store/appStore/types';
|
||||
|
||||
export const CLIENT_ID_STORAGE_KEY = 'work-app.visitor.client-id';
|
||||
|
||||
function generateFallbackClientId() {
|
||||
return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function generateClientId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return generateFallbackClientId();
|
||||
}
|
||||
|
||||
export function getClientId() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(CLIENT_ID_STORAGE_KEY)?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function clearClientId() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(CLIENT_ID_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getOrCreateClientId() {
|
||||
const existingClientId = getClientId();
|
||||
|
||||
if (existingClientId) {
|
||||
return existingClientId;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const nextClientId = generateClientId();
|
||||
window.localStorage.setItem(CLIENT_ID_STORAGE_KEY, nextClientId);
|
||||
return nextClientId;
|
||||
}
|
||||
|
||||
export function appendClientIdHeader(headersInit?: HeadersInit) {
|
||||
const headers = new Headers(headersInit);
|
||||
const clientId = getOrCreateClientId();
|
||||
|
||||
if (clientId && !headers.has('X-Client-Id')) {
|
||||
headers.set('X-Client-Id', clientId);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function buildTrackedPageUrl(page: AppPageDescriptor) {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (page.topMenu === 'plans') {
|
||||
url.searchParams.set('topMenu', 'plans');
|
||||
url.searchParams.set('planFilter', page.section);
|
||||
url.searchParams.delete('planSection');
|
||||
} else {
|
||||
url.searchParams.set('topMenu', page.topMenu);
|
||||
url.searchParams.delete('planSection');
|
||||
url.searchParams.delete('planFilter');
|
||||
}
|
||||
|
||||
if (page.topMenu === 'docs') {
|
||||
url.searchParams.set('docsSection', page.section);
|
||||
} else {
|
||||
url.searchParams.delete('docsSection');
|
||||
}
|
||||
|
||||
if (page.topMenu === 'apis') {
|
||||
url.searchParams.set('apiSection', page.section);
|
||||
} else {
|
||||
url.searchParams.delete('apiSection');
|
||||
}
|
||||
|
||||
if (page.topMenu === 'chat') {
|
||||
url.searchParams.set('chatSection', page.section);
|
||||
} else {
|
||||
url.searchParams.delete('chatSection');
|
||||
}
|
||||
|
||||
if (page.topMenu === 'play') {
|
||||
url.searchParams.set('playSection', page.section);
|
||||
} else {
|
||||
url.searchParams.delete('playSection');
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
160
src/app/main/errorLogApi.ts
Executable file
160
src/app/main/errorLogApi.ts
Executable file
@@ -0,0 +1,160 @@
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import { getRegisteredAccessToken } from './tokenAccess';
|
||||
|
||||
export type ErrorLogItem = {
|
||||
id: number;
|
||||
source: 'server' | 'client' | string;
|
||||
sourceLabel: string | null;
|
||||
errorType: string;
|
||||
errorName: string | null;
|
||||
errorMessage: string;
|
||||
detail: string | null;
|
||||
stackTrace: string | null;
|
||||
statusCode: number | null;
|
||||
requestMethod: string | null;
|
||||
requestPath: string | null;
|
||||
relatedPlanId: number | null;
|
||||
relatedWorkId: string | null;
|
||||
context: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ReportClientErrorPayload = {
|
||||
errorType: string;
|
||||
errorName?: string | null;
|
||||
errorMessage: string;
|
||||
detail?: string | null;
|
||||
stackTrace?: string | null;
|
||||
statusCode?: number | null;
|
||||
requestMethod?: string | null;
|
||||
requestPath?: string | null;
|
||||
context?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
class ErrorLogApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'ErrorLogApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorLogApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveWorkServerFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const ERROR_LOG_API_BASE_URL = resolveErrorLogApiBaseUrl();
|
||||
const ERROR_LOG_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && ERROR_LOG_API_BASE_URL === '/api'
|
||||
? resolveWorkServerFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new ErrorLogApiError(payload.message || '에러 로그 요청에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new ErrorLogApiError(text || '에러 로그 요청에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(ERROR_LOG_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
ERROR_LOG_API_FALLBACK_BASE_URL &&
|
||||
ERROR_LOG_API_FALLBACK_BASE_URL !== ERROR_LOG_API_BASE_URL &&
|
||||
(error instanceof ErrorLogApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|Failed to fetch|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(ERROR_LOG_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchErrorLogs(limit = 50) {
|
||||
const token = getRegisteredAccessToken();
|
||||
const response = await request<{ ok: boolean; items: ErrorLogItem[] }>(`/error-logs?limit=${limit}`, {
|
||||
headers: {
|
||||
'X-Access-Token': token,
|
||||
},
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function reportClientError(payload: ReportClientErrorPayload) {
|
||||
try {
|
||||
await request('/error-logs/report', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
source: 'client',
|
||||
sourceLabel: '프론트엔드',
|
||||
errorType: payload.errorType,
|
||||
errorName: payload.errorName ?? null,
|
||||
errorMessage: payload.errorMessage,
|
||||
detail: payload.detail ?? null,
|
||||
stackTrace: payload.stackTrace ?? null,
|
||||
statusCode: payload.statusCode ?? null,
|
||||
requestMethod: payload.requestMethod ?? null,
|
||||
requestPath: payload.requestPath ?? null,
|
||||
context: payload.context ?? null,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// ignore client-side logging failures
|
||||
}
|
||||
}
|
||||
1
src/app/main/index.ts
Executable file
1
src/app/main/index.ts
Executable file
@@ -0,0 +1 @@
|
||||
export { AppShell } from './AppShell';
|
||||
469
src/app/main/layout/MainLayout.tsx
Executable file
469
src/app/main/layout/MainLayout.tsx
Executable file
@@ -0,0 +1,469 @@
|
||||
import { Layout } from 'antd';
|
||||
import { 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 { useAppConfig } from '../appConfig';
|
||||
import { ChatNotificationBridgeV2 } from '../ChatNotificationBridgeV2';
|
||||
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
||||
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
|
||||
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
||||
import { MainContent } from '../MainContent';
|
||||
import { MainHeader } from '../MainHeader';
|
||||
import { MainSidebar } from '../MainSidebar';
|
||||
import { buildSearchOptions } from './buildSearchOptions';
|
||||
import { MainLayoutContextProvider } from './MainLayoutContext';
|
||||
import { useMainLayoutData } from './useMainLayoutData';
|
||||
import '../MainLayout.css';
|
||||
import {
|
||||
buildApiMenuItems,
|
||||
buildApisPath,
|
||||
buildChatMenuItems,
|
||||
buildChatPath,
|
||||
buildDocsMenuItems,
|
||||
buildDocsPath,
|
||||
buildPlanMenuItems,
|
||||
buildPlansPath,
|
||||
buildPlayMenuItems,
|
||||
buildPlayPath,
|
||||
buildSavedLayoutPath,
|
||||
DOCS_DEFAULT_FOLDER,
|
||||
PLAN_MENU_ANCHOR_IDS,
|
||||
renderSidebarIntro,
|
||||
resolveCurrentPageDescriptor,
|
||||
resolvePlanOpenKeys,
|
||||
resolvePlanQuickFilterMenu,
|
||||
resolvePlayOpenKeys,
|
||||
resolveSavedLayoutIdFromMenuKey,
|
||||
resolveTopMenuPath,
|
||||
type ApiSectionKey,
|
||||
type ChatSectionKey,
|
||||
type PlanSectionKey,
|
||||
type PlaySidebarKey,
|
||||
type TopMenuKey,
|
||||
} from '../routes';
|
||||
|
||||
function parseRoute(pathname: string): {
|
||||
topMenu: TopMenuKey;
|
||||
docsMenu: string;
|
||||
apiMenu: ApiSectionKey;
|
||||
planMenu: PlanSectionKey;
|
||||
chatMenu: ChatSectionKey;
|
||||
playMenu: PlaySidebarKey;
|
||||
} {
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const [top, first, second] = segments;
|
||||
|
||||
if (top === 'docs') {
|
||||
return {
|
||||
topMenu: 'docs',
|
||||
docsMenu: first || DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'apis' && (first === 'components' || first === 'widgets')) {
|
||||
return {
|
||||
topMenu: 'apis',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: first,
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
top === 'plans' &&
|
||||
(first === 'all' ||
|
||||
first === 'in-progress' ||
|
||||
first === 'done' ||
|
||||
first === 'error' ||
|
||||
first === 'release' ||
|
||||
first === 'release-review' ||
|
||||
first === 'board' ||
|
||||
first === 'charts' ||
|
||||
first === 'schedule' ||
|
||||
first === 'history' ||
|
||||
first === 'server-command')
|
||||
) {
|
||||
return {
|
||||
topMenu: 'plans',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: first,
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'chat' && (first === 'live' || first === 'changes' || first === 'errors' || first === 'manage')) {
|
||||
return {
|
||||
topMenu: 'chat',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: first,
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'play' && first === 'layout') {
|
||||
return {
|
||||
topMenu: 'play',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: first,
|
||||
};
|
||||
}
|
||||
|
||||
if (top === 'play' && first === 'layout-record' && second) {
|
||||
return {
|
||||
topMenu: 'play',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: `layout-record:${second}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
topMenu: 'plans',
|
||||
docsMenu: DOCS_DEFAULT_FOLDER,
|
||||
apiMenu: 'components',
|
||||
planMenu: 'all',
|
||||
chatMenu: 'live',
|
||||
playMenu: 'layout',
|
||||
};
|
||||
}
|
||||
|
||||
function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
return !hasAccess && topMenu !== 'docs';
|
||||
}
|
||||
|
||||
function resolveSidebarOpenKeys(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
if (!hasAccess) {
|
||||
return ['docs-group'];
|
||||
}
|
||||
|
||||
if (topMenu === 'docs' || topMenu === 'apis') {
|
||||
return ['docs-group', 'api-group'];
|
||||
}
|
||||
|
||||
return topMenu === 'play' ? resolvePlayOpenKeys() : resolvePlanOpenKeys();
|
||||
}
|
||||
|
||||
export function MainLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setCurrentPage, setFocusedComponentId } = useAppStore();
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const appConfig = useAppConfig();
|
||||
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
|
||||
const layoutData = useMainLayoutData();
|
||||
const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
const [activePlanQuickFilter, setActivePlanQuickFilter] = useState<
|
||||
'working' | 'release-pending-main' | 'automation-failed' | null
|
||||
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
|
||||
const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0);
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
|
||||
const { chatUnreadCount } = useUnreadCounts();
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const updateViewport = () => {
|
||||
setIsMobileViewport(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
mediaQuery.addEventListener('change', updateViewport);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}, [isMobileViewport]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
}, [hasAccess, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) {
|
||||
navigate(buildDocsPath(docFolders[0]), { replace: true });
|
||||
}
|
||||
}, [docFolders, navigate, routeState.docsMenu, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
|
||||
|
||||
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) {
|
||||
navigate(buildPlayPath('layout'), { replace: true });
|
||||
}
|
||||
}, [navigate, routeState.playMenu, savedLayouts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(`${buildDocsPath(DOCS_DEFAULT_FOLDER)}${location.search}`, { replace: true });
|
||||
}, [hasAccess, location.search, navigate, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routeState.topMenu !== 'plans') {
|
||||
setActivePlanQuickFilter(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (routeState.planMenu === 'release') {
|
||||
setActivePlanQuickFilter('release-pending-main');
|
||||
return;
|
||||
}
|
||||
|
||||
setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null));
|
||||
}, [routeState.planMenu, routeState.topMenu]);
|
||||
|
||||
useGesturePageState('anyway');
|
||||
useGestureLayer({
|
||||
id: 'main-layout',
|
||||
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
||||
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',
|
||||
onTrigger: () => {
|
||||
openSearch('window');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.repeat || isTypingTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
|
||||
event.preventDefault();
|
||||
openSearch('window');
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
|
||||
event.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown);
|
||||
};
|
||||
}, [appConfig.gestureShortcuts.openSearch, appConfig.gestureShortcuts.openWindowSearch, openSearch]);
|
||||
|
||||
const selectedDocs = useMemo(
|
||||
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
|
||||
[docsDocuments, routeState.docsMenu],
|
||||
);
|
||||
|
||||
const searchOptions = useMemo(
|
||||
() =>
|
||||
buildSearchOptions({
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docFolders,
|
||||
docsDocuments,
|
||||
hasAccess,
|
||||
navigateTo: (path) => {
|
||||
navigate(path);
|
||||
},
|
||||
setFocusedComponentId,
|
||||
requestPlanQuickFilter: (filter) => {
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
},
|
||||
}),
|
||||
[componentSamples, docFolders, docsDocuments, hasAccess, navigate, setFocusedComponentId, widgetSamples],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchOptions(searchOptions);
|
||||
}, [searchOptions, setSearchOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(
|
||||
resolveCurrentPageDescriptor({
|
||||
topMenu: routeState.topMenu,
|
||||
docsMenu: routeState.docsMenu,
|
||||
apiMenu: routeState.apiMenu,
|
||||
planMenu: routeState.planMenu,
|
||||
chatMenu: routeState.chatMenu,
|
||||
playMenu: routeState.playMenu,
|
||||
savedLayouts,
|
||||
}),
|
||||
);
|
||||
}, [routeState, savedLayouts, setCurrentPage]);
|
||||
|
||||
const currentDocsFolder = docFolders.includes(routeState.docsMenu) ? routeState.docsMenu : docFolders[0] ?? DOCS_DEFAULT_FOLDER;
|
||||
const sidebarIntro = renderSidebarIntro(routeState.topMenu);
|
||||
const apiMenuItems = useMemo(() => buildApiMenuItems(), []);
|
||||
const docsMenuItems = useMemo(() => buildDocsMenuItems(docFolders), [docFolders]);
|
||||
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
|
||||
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
|
||||
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
|
||||
const initialSelectedPlanId = Number(searchParams.get('planId'));
|
||||
const initialSelectedWorkId = searchParams.get('workId');
|
||||
|
||||
return (
|
||||
<MainLayoutContextProvider
|
||||
value={{
|
||||
topMenu: routeState.topMenu,
|
||||
selectedDocsMenu: routeState.docsMenu,
|
||||
selectedApiMenu: routeState.apiMenu,
|
||||
selectedPlanMenu: routeState.planMenu,
|
||||
selectedChatMenu: routeState.chatMenu,
|
||||
selectedPlayMenu: routeState.playMenu,
|
||||
activePlanQuickFilter,
|
||||
planQuickFilterRequestKey,
|
||||
initialSelectedPlanId: Number.isFinite(initialSelectedPlanId) ? initialSelectedPlanId : null,
|
||||
initialSelectedWorkId,
|
||||
selectedDocs,
|
||||
docsDocuments,
|
||||
componentSampleEntries,
|
||||
widgetSampleEntries,
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
savedLayouts,
|
||||
setSavedLayouts,
|
||||
searchOptions,
|
||||
}}
|
||||
>
|
||||
<Layout className="app-shell app-shell--docs-api">
|
||||
<ChatRuntimeBridgeV2 />
|
||||
<ChatNotificationBridgeV2 />
|
||||
{contentExpanded ? null : (
|
||||
<MainHeader
|
||||
activeTopMenu={routeState.topMenu}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
contentExpanded={contentExpanded}
|
||||
isMobileViewport={isMobileViewport}
|
||||
onToggleSidebar={() => {
|
||||
setSidebarCollapsed((previous) => !previous);
|
||||
}}
|
||||
onToggleContentExpanded={() => {
|
||||
setContentExpanded((previous) => !previous);
|
||||
}}
|
||||
onChangeTopMenu={(menu) => {
|
||||
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
||||
setSidebarCollapsed(false);
|
||||
}}
|
||||
onOpenPlanQuickFilter={(filter) => {
|
||||
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
||||
setActivePlanQuickFilter(filter);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(targetPlanMenu));
|
||||
setSidebarCollapsed(isMobileViewport);
|
||||
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
isMobileViewport={isMobileViewport}
|
||||
openKeys={sidebarOpenKeys}
|
||||
apiMenuItems={apiMenuItems}
|
||||
docsMenuItems={docsMenuItems}
|
||||
planMenuItems={planMenuItems}
|
||||
chatMenuItems={chatMenuItems}
|
||||
playMenuItems={playMenuItems}
|
||||
selectedApiMenu={routeState.apiMenu}
|
||||
selectedDocsMenu={routeState.docsMenu}
|
||||
selectedPlanMenu={routeState.planMenu}
|
||||
selectedChatMenu={routeState.chatMenu}
|
||||
selectedPlayMenu={routeState.playMenu}
|
||||
onOpenKeysChange={setSidebarOpenKeys}
|
||||
onSelectApiMenu={(key) => {
|
||||
navigate(buildApisPath(key as ApiSectionKey));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlanMenu={(key) => {
|
||||
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
|
||||
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
||||
navigate(buildPlansPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectChatMenu={(key) => {
|
||||
navigate(buildChatPath(key));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
onSelectPlayMenu={(key) => {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
||||
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
introColor={sidebarIntro.color}
|
||||
introTag={sidebarIntro.tag}
|
||||
introDescription={sidebarIntro.description}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
)}
|
||||
</Layout>
|
||||
</Layout>
|
||||
</MainLayoutContextProvider>
|
||||
);
|
||||
}
|
||||
48
src/app/main/layout/MainLayoutContext.ts
Executable file
48
src/app/main/layout/MainLayoutContext.ts
Executable file
@@ -0,0 +1,48 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { SearchKeywordOption } from '../../../components/search';
|
||||
import type { MarkdownDocument } from '../../../components/markdownPreview';
|
||||
import type { LoadedSampleEntry, SampleEntry } from '../../../samples/registry';
|
||||
import type { SavedLayoutRecord } from '../../../views/play/layoutStorage';
|
||||
import type {
|
||||
ApiSectionKey,
|
||||
ChatSectionKey,
|
||||
PlanSectionKey,
|
||||
PlaySidebarKey,
|
||||
TopMenuKey,
|
||||
} from '../routes';
|
||||
|
||||
export type MainLayoutContextValue = {
|
||||
topMenu: TopMenuKey;
|
||||
selectedDocsMenu: string;
|
||||
selectedApiMenu: ApiSectionKey;
|
||||
selectedPlanMenu: PlanSectionKey;
|
||||
selectedChatMenu: ChatSectionKey;
|
||||
selectedPlayMenu: PlaySidebarKey;
|
||||
activePlanQuickFilter: 'working' | 'release-pending-main' | 'automation-failed' | null;
|
||||
planQuickFilterRequestKey: number;
|
||||
initialSelectedPlanId: number | null;
|
||||
initialSelectedWorkId: string | null;
|
||||
selectedDocs: MarkdownDocument[];
|
||||
docsDocuments: MarkdownDocument[];
|
||||
componentSampleEntries: SampleEntry[];
|
||||
widgetSampleEntries: SampleEntry[];
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
savedLayouts: SavedLayoutRecord[];
|
||||
setSavedLayouts: (layouts: SavedLayoutRecord[]) => void;
|
||||
searchOptions: SearchKeywordOption[];
|
||||
};
|
||||
|
||||
const MainLayoutContext = createContext<MainLayoutContextValue | null>(null);
|
||||
|
||||
export const MainLayoutContextProvider = MainLayoutContext.Provider;
|
||||
|
||||
export function useMainLayoutContext() {
|
||||
const context = useContext(MainLayoutContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useMainLayoutContext must be used within MainLayoutContextProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
280
src/app/main/layout/buildSearchOptions.ts
Executable file
280
src/app/main/layout/buildSearchOptions.ts
Executable file
@@ -0,0 +1,280 @@
|
||||
import type { SearchKeywordOption } from '../../../components/search';
|
||||
import type { LoadedSampleEntry } from '../../../samples/registry';
|
||||
import {
|
||||
buildApisPath,
|
||||
buildChatPath,
|
||||
buildDocsPath,
|
||||
buildPlansPath,
|
||||
getDocsSectionLabel,
|
||||
PLAN_FILTER_LABELS,
|
||||
PLAN_GROUP_LABEL,
|
||||
PLAN_SIDEBAR_LABELS,
|
||||
} from '../routes';
|
||||
import { compactKeywords, scrollToElement } from '../mainView/utils';
|
||||
|
||||
type BuildSearchOptionsParams = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
docFolders: string[];
|
||||
docsDocuments: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
preview?: string;
|
||||
}>;
|
||||
hasAccess: boolean;
|
||||
navigateTo: (path: string) => void;
|
||||
setFocusedComponentId: (value: string | null) => void;
|
||||
requestPlanQuickFilter: (filter: 'working' | 'release-pending-main' | 'automation-failed' | null) => void;
|
||||
};
|
||||
|
||||
export function buildSearchOptions({
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docFolders,
|
||||
docsDocuments,
|
||||
hasAccess,
|
||||
navigateTo,
|
||||
setFocusedComponentId,
|
||||
requestPlanQuickFilter,
|
||||
}: BuildSearchOptionsParams): SearchKeywordOption[] {
|
||||
const onSelectWindow = () => {
|
||||
setFocusedComponentId(null);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'page:apis:components',
|
||||
label: 'APIs / Components',
|
||||
group: 'Page',
|
||||
keywords: ['api', 'components', '컴포넌트'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildApisPath('components'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:apis:widgets',
|
||||
label: 'APIs / Widgets',
|
||||
group: 'Page',
|
||||
keywords: ['api', 'widgets', '위젯'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildApisPath('widgets'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:all',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_FILTER_LABELS.all}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'automation', '자동화', '실행', '현황', 'worker'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('all'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
id: 'page:plans:board',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.board}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', '플랜', '게시판', 'board', '요청', '작업 요청', '접수', '계획'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('board'));
|
||||
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: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('server-command'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'page:plans:release-review',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['release-review']}`,
|
||||
group: 'Page',
|
||||
keywords: ['release', 'review', '검수', '릴리즈', 'release 검수'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('release-review'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:charts',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.charts}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', '차트', 'chart', '스케줄 차트'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('charts'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:schedule',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.schedule}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', '스케줄', 'schedule', '반복 작업'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('schedule'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
id: 'page:plans:history',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.history}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', '히스토리', 'history', '방문자 이력'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildPlansPath('history'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'page:chat:live',
|
||||
label: 'Codex Live / Codex Live',
|
||||
group: 'Page',
|
||||
keywords: ['codex live', 'chat', 'codex', '채팅', '대화', '메시지'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildChatPath('live'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:chat:changes',
|
||||
label: 'Codex Live / 변경 이력',
|
||||
group: 'Page',
|
||||
keywords: ['codex live', 'changes', 'source', 'diff', '변경', '소스', '채팅 변경', '채팅 diff'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildChatPath('changes'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:chat:errors',
|
||||
label: '앱로그 / 에러 로그',
|
||||
group: 'Page',
|
||||
keywords: ['app log', 'applog', '앱로그', 'error', 'errors', '에러', '로그'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildChatPath('errors'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
id: 'page:chat:manage',
|
||||
label: '채팅 관리 / 유형 권한 관리',
|
||||
group: 'Page',
|
||||
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildChatPath('manage'));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
]
|
||||
: []),
|
||||
...docFolders.map((folder) => ({
|
||||
id: `docs-folder:${folder}`,
|
||||
label: `Docs / ${getDocsSectionLabel(folder)}`,
|
||||
group: 'Docs Folder',
|
||||
keywords: [folder, 'docs', '문서'],
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildDocsPath(folder));
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...docsDocuments.map((document) => ({
|
||||
id: `doc:${document.id}`,
|
||||
label: document.title,
|
||||
group: `Docs / ${getDocsSectionLabel(document.folder)}`,
|
||||
keywords: [document.folder, document.id, document.title],
|
||||
description: document.preview,
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildDocsPath(document.folder));
|
||||
setFocusedComponentId(`doc:${document.id}`);
|
||||
scrollToElement(`document-preview-${document.id}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...componentSamples.map((entry) => ({
|
||||
id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
label: entry.sampleMeta.title,
|
||||
group: 'Component',
|
||||
keywords: compactKeywords([
|
||||
entry.sampleMeta.componentId,
|
||||
entry.sampleMeta.id,
|
||||
entry.sampleMeta.category,
|
||||
entry.sampleMeta.kind,
|
||||
entry.sampleMeta.variantLabel ?? '',
|
||||
]),
|
||||
description: entry.sampleMeta.description,
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildApisPath('components'));
|
||||
setFocusedComponentId(`component:${entry.sampleMeta.componentId}`);
|
||||
scrollToElement(`component-sample-${entry.sampleMeta.componentId}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...widgetSamples.map((entry) => ({
|
||||
id: `widget:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
label: entry.sampleMeta.title,
|
||||
group: 'Widget',
|
||||
keywords: compactKeywords([
|
||||
entry.sampleMeta.componentId,
|
||||
entry.sampleMeta.id,
|
||||
entry.sampleMeta.category,
|
||||
entry.sampleMeta.kind,
|
||||
entry.sampleMeta.variantLabel ?? '',
|
||||
]),
|
||||
description: entry.sampleMeta.description,
|
||||
onSelect: () => {
|
||||
requestPlanQuickFilter(null);
|
||||
navigateTo(buildApisPath('widgets'));
|
||||
setFocusedComponentId(`widget:${entry.sampleMeta.componentId}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
];
|
||||
}
|
||||
104
src/app/main/layout/useMainLayoutData.ts
Executable file
104
src/app/main/layout/useMainLayoutData.ts
Executable file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { resolveMarkdownDocuments } from '../../../components/markdownPreview';
|
||||
import { resolveSampleEntries, type LoadedSampleEntry } from '../../../samples/registry';
|
||||
import { listSavedLayouts, type SavedLayoutRecord } from '../../../views/play/layoutStorage';
|
||||
import { docsMarkdownEntries } from '../../manifests/docs.manifest';
|
||||
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
||||
import { DOCS_DEFAULT_FOLDER } from '../routes';
|
||||
|
||||
const DOCS_FOLDER_ORDER = ['worklogs', 'features', 'components', 'templates'] as const;
|
||||
|
||||
export function useMainLayoutData() {
|
||||
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
|
||||
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveMarkdownDocuments(docsMarkdownEntries, '/docs/').then((documents) => {
|
||||
if (mounted) {
|
||||
setDocsDocuments(documents);
|
||||
}
|
||||
});
|
||||
|
||||
void resolveSampleEntries(componentSampleEntries, '/components/').then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setComponentSamples(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
void resolveSampleEntries(widgetSampleEntries, '/widgets/').then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setWidgetSamples(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void listSavedLayouts()
|
||||
.then((layouts) => {
|
||||
if (mounted) {
|
||||
setSavedLayouts(layouts);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setSavedLayouts([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const docFolders = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(docsDocuments.map((document) => document.folder))).sort((left, right) => {
|
||||
const leftIndex = DOCS_FOLDER_ORDER.indexOf(left as (typeof DOCS_FOLDER_ORDER)[number]);
|
||||
const rightIndex = DOCS_FOLDER_ORDER.indexOf(right as (typeof DOCS_FOLDER_ORDER)[number]);
|
||||
|
||||
if (leftIndex >= 0 || rightIndex >= 0) {
|
||||
if (leftIndex < 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (rightIndex < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
|
||||
if (left === DOCS_DEFAULT_FOLDER) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (right === DOCS_DEFAULT_FOLDER) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
}),
|
||||
[docsDocuments],
|
||||
);
|
||||
|
||||
return {
|
||||
componentSampleEntries,
|
||||
widgetSampleEntries,
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docsDocuments,
|
||||
savedLayouts,
|
||||
setSavedLayouts,
|
||||
docFolders,
|
||||
};
|
||||
}
|
||||
1149
src/app/main/mainChatPanel/ChatConversationView.tsx
Executable file
1149
src/app/main/mainChatPanel/ChatConversationView.tsx
Executable file
File diff suppressed because it is too large
Load Diff
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
303
src/app/main/mainChatPanel/ChatPreviewBody.tsx
Executable file
@@ -0,0 +1,303 @@
|
||||
import { DownloadOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
|
||||
import '../../../components/previewer/PreviewerUI.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
|
||||
export type ChatPreviewTarget = {
|
||||
label: string;
|
||||
url: string;
|
||||
kind: ChatPreviewKind;
|
||||
};
|
||||
|
||||
function resolvePreviewErrorMessage(previewError: string) {
|
||||
const normalized = previewError.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^\s*403\b/.test(normalized) || normalized.includes('권한으로 열 수 없습니다')) {
|
||||
return '권한이 없거나 허용되지 않은 경로입니다. 세션 리소스 경로와 접근 권한을 확인해 주세요.';
|
||||
}
|
||||
|
||||
if (/^\s*404\b/.test(normalized) || normalized.includes('찾을 수 없습니다')) {
|
||||
return '파일이 이동되었거나 아직 세션 리소스 경로에 생성되지 않았습니다. 경로를 다시 확인해 주세요.';
|
||||
}
|
||||
|
||||
if (/^\s*401\b/.test(normalized) || normalized.includes('인증이 필요합니다')) {
|
||||
return '인증 정보가 없어서 문서를 열 수 없습니다.';
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePreviewExtension(target: ChatPreviewTarget) {
|
||||
const raw = target.label || target.url;
|
||||
const normalized = raw.toLowerCase().split('?')[0] ?? '';
|
||||
const match = normalized.match(/\.([a-z0-9]+)$/i);
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
function resolveCodeLanguage(target: ChatPreviewTarget, previewText: string) {
|
||||
const extension = resolvePreviewExtension(target);
|
||||
|
||||
if (extension === 'tsx' || extension === 'ts') {
|
||||
return 'typescript';
|
||||
}
|
||||
|
||||
if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') {
|
||||
return 'javascript';
|
||||
}
|
||||
|
||||
if (extension === 'json') {
|
||||
return 'json';
|
||||
}
|
||||
|
||||
if (extension === 'css') {
|
||||
return 'css';
|
||||
}
|
||||
|
||||
if (extension === 'scss') {
|
||||
return 'scss';
|
||||
}
|
||||
|
||||
if (extension === 'html' || extension === 'htm') {
|
||||
return 'html';
|
||||
}
|
||||
|
||||
if (extension === 'md' || extension === 'markdown') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
if (extension === 'java') {
|
||||
return 'java';
|
||||
}
|
||||
|
||||
if (extension === 'kt') {
|
||||
return 'kotlin';
|
||||
}
|
||||
|
||||
if (extension === 'py') {
|
||||
return 'python';
|
||||
}
|
||||
|
||||
if (extension === 'go') {
|
||||
return 'go';
|
||||
}
|
||||
|
||||
if (extension === 'rs') {
|
||||
return 'rust';
|
||||
}
|
||||
|
||||
if (extension === 'sql') {
|
||||
return 'sql';
|
||||
}
|
||||
|
||||
if (extension === 'sh' || extension === 'bash' || extension === 'zsh') {
|
||||
return 'bash';
|
||||
}
|
||||
|
||||
if (extension === 'yml' || extension === 'yaml') {
|
||||
return 'yaml';
|
||||
}
|
||||
|
||||
if (extension === 'xml') {
|
||||
return 'xml';
|
||||
}
|
||||
|
||||
if (extension === 'diff' || extension === 'patch') {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
if (/^(diff --git|@@\s|--- a\/|\+\+\+ b\/)/m.test(previewText)) {
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
return inferCodeLanguage(extension || 'text');
|
||||
}
|
||||
|
||||
function isAppRouteUrl(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
const pathname = parsed.pathname.toLowerCase();
|
||||
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
|
||||
return parsed.origin === window.location.origin && !hasKnownFileExtension;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canRenderFramePreview(url: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url, window.location.href);
|
||||
return parsed.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ChatPreviewBodyProps = {
|
||||
target: ChatPreviewTarget | null;
|
||||
previewText: string;
|
||||
isPreviewLoading: boolean;
|
||||
previewError: string;
|
||||
previewContentType?: string;
|
||||
};
|
||||
|
||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||
const extension = resolvePreviewExtension(target);
|
||||
const normalizedContentType = previewContentType?.toLowerCase() ?? '';
|
||||
const normalizedPreview = previewText.trimStart().toLowerCase();
|
||||
|
||||
if (extension === 'html' || extension === 'htm' || target.kind === 'markdown') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const looksLikeHtml =
|
||||
normalizedContentType.includes('text/html') ||
|
||||
normalizedPreview.startsWith('<!doctype html') ||
|
||||
normalizedPreview.startsWith('<html') ||
|
||||
normalizedPreview.includes('<head') ||
|
||||
normalizedPreview.includes('<body');
|
||||
|
||||
return looksLikeHtml;
|
||||
}
|
||||
|
||||
export function ChatPreviewBody({
|
||||
target,
|
||||
previewText,
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
previewContentType,
|
||||
}: ChatPreviewBodyProps) {
|
||||
if (!target) {
|
||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||
}
|
||||
|
||||
if (isPreviewLoading) {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-loading">
|
||||
<Spin size="small" />
|
||||
<Text type="secondary">preview를 불러오는 중입니다.</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewError) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
message="preview를 불러오지 못했습니다."
|
||||
description={resolvePreviewErrorMessage(previewError)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHtmlFallbackPreview(target, previewText, previewContentType)) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="실제 소스 파일 대신 앱 HTML 화면이 반환되었습니다."
|
||||
description={`${target.label} 경로가 raw 파일이 아니라 현재 앱의 fallback HTML을 돌려주고 있습니다. 정적 파일 경로 또는 실제 다운로드 경로를 사용해야 코드 preview가 정확하게 표시됩니다.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'image') {
|
||||
return (
|
||||
<InlineImage
|
||||
src={target.url}
|
||||
alt={target.label}
|
||||
className="app-chat-panel__preview-image"
|
||||
fallbackText="이미지 preview를 불러오지 못했습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'video') {
|
||||
return <video src={target.url} className="app-chat-panel__preview-video" controls playsInline />;
|
||||
}
|
||||
|
||||
if (target.kind === 'pdf') {
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
if (target.kind === 'markdown') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||
<MarkdownPreviewContent content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'} maxBlocks={12} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'code' || target.kind === 'document') {
|
||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||
|
||||
if (resolvedLanguage === 'diff') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
<CodexDiffBlock
|
||||
diffText={previewText || ''}
|
||||
summary={`${target.label} 기준 raw diff preview입니다.`}
|
||||
showToolbar={false}
|
||||
className="app-chat-panel__preview-diff"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
{renderEditorBlock(previewText || '표시할 preview 본문이 없습니다.', resolvedLanguage, 'code')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAppRouteUrl(target.url)) {
|
||||
return (
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="앱 화면 경로는 preview iframe으로 열지 않습니다."
|
||||
description="현재 화면 문맥은 이미 WebSocket으로 서버에 전달됩니다. 이 경로를 다시 열면 앱만 새로 렌더링되어 preview처럼 보이지 않을 수 있습니다."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canRenderFramePreview(target.url)) {
|
||||
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__preview-file">
|
||||
<Paragraph>
|
||||
브라우저에서 직접 렌더링하지 않는 형식입니다. 아래 버튼으로 새 탭에서 열거나 바로 다운로드할 수 있습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
|
||||
새 탭 열기
|
||||
</Button>
|
||||
<Button href={target.url} download icon={<DownloadOutlined />}>
|
||||
다운로드
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
497
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx
Executable file
497
src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx
Executable file
@@ -0,0 +1,497 @@
|
||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
cancelChatRuntimeJob,
|
||||
fetchChatRuntimeJobDetail,
|
||||
removeChatRuntimeJob,
|
||||
} from './chatUtils';
|
||||
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
function formatRuntimeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function buildTerminalLabel(terminalStatus: ChatRuntimeJobDetail['terminalStatus']) {
|
||||
if (!terminalStatus) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'cancelled') {
|
||||
return '취소됨';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'removed') {
|
||||
return '대기열 제거됨';
|
||||
}
|
||||
|
||||
if (terminalStatus === 'failed') {
|
||||
return '실패';
|
||||
}
|
||||
|
||||
return '완료';
|
||||
}
|
||||
|
||||
function RuntimeJobList({
|
||||
title,
|
||||
emptyDescription,
|
||||
items,
|
||||
activeSessionId,
|
||||
onSelectSession,
|
||||
onOpenLog,
|
||||
onCancelJob,
|
||||
onRemoveJob,
|
||||
pendingActionRequestId,
|
||||
}: {
|
||||
title: string;
|
||||
emptyDescription: string;
|
||||
items: ChatRuntimeJobItem[];
|
||||
activeSessionId: string;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenLog: (requestId: string) => void;
|
||||
onCancelJob: (requestId: string) => void;
|
||||
onRemoveJob: (requestId: string) => void;
|
||||
pendingActionRequestId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="app-chat-runtime__section">
|
||||
<div className="app-chat-runtime__section-header">
|
||||
<Text strong>{title}</Text>
|
||||
<Text type="secondary">{items.length}건</Text>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="app-chat-runtime__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__list">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={`${item.status}-${item.requestId}`}
|
||||
className={`app-chat-runtime__job${item.sessionId === activeSessionId ? ' app-chat-runtime__job--active' : ''}`}
|
||||
>
|
||||
<div className="app-chat-runtime__job-top">
|
||||
<div className="app-chat-runtime__job-headline">
|
||||
<Text strong>{item.status === 'running' ? '실행 중' : '대기 중'}</Text>
|
||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||
</div>
|
||||
<Space size={8} wrap className="app-chat-runtime__job-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
세션 보기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
{item.status === 'running' ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onCancelJob(item.requestId);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onRemoveJob(item.requestId);
|
||||
}}
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">세션: {item.sessionId}</Text>
|
||||
<Text type="secondary">요청: {item.requestId}</Text>
|
||||
<Text type="secondary">대기 시작: {formatRuntimeTime(item.enqueuedAt)}</Text>
|
||||
<Text type="secondary">실행 시작: {formatRuntimeTime(item.startedAt)}</Text>
|
||||
<Text type="secondary">PID: {item.pid ?? '-'}</Text>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentRuntimeList({
|
||||
items,
|
||||
onSelectSession,
|
||||
onOpenLog,
|
||||
}: {
|
||||
items: ChatRuntimeSnapshot['recent'];
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenLog: (requestId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
|
||||
<div className="app-chat-runtime__section-header">
|
||||
<Text strong>최근 작업</Text>
|
||||
<Text type="secondary">{items.length}건</Text>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="app-chat-runtime__empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="아직 종료된 작업 이력이 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__list">
|
||||
{items.map((item) => (
|
||||
<article key={`recent-${item.requestId}-${item.lastUpdatedAt}`} className="app-chat-runtime__job">
|
||||
<div className="app-chat-runtime__job-top">
|
||||
<div className="app-chat-runtime__job-headline">
|
||||
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
|
||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
</div>
|
||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">세션: {item.sessionId}</Text>
|
||||
<Text type="secondary">요청: {item.requestId}</Text>
|
||||
<Text type="secondary">종료 시각: {formatRuntimeTime(item.lastUpdatedAt)}</Text>
|
||||
<Text type="secondary">PID: {item.pid ?? '-'}</Text>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatRuntimeDashboard({
|
||||
snapshot,
|
||||
activeSessionId,
|
||||
connectionState,
|
||||
onSelectSession,
|
||||
socketRef,
|
||||
liveDetail,
|
||||
requestedLogRequestId,
|
||||
onRequestedLogHandled,
|
||||
}: {
|
||||
snapshot: ChatRuntimeSnapshot | null;
|
||||
activeSessionId: string;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
socketRef: { current: WebSocket | null };
|
||||
liveDetail: ChatRuntimeJobDetail | null;
|
||||
requestedLogRequestId?: string | null;
|
||||
onRequestedLogHandled?: () => void;
|
||||
}) {
|
||||
const sessions = snapshot?.sessions ?? [];
|
||||
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||
const [logLoadError, setLogLoadError] = useState('');
|
||||
const [isLogLoading, setIsLogLoading] = useState(false);
|
||||
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
|
||||
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const loadLogDetail = async (requestId: string) => {
|
||||
setIsLogLoading(true);
|
||||
setLogLoadError('');
|
||||
|
||||
try {
|
||||
const detail = await fetchChatRuntimeJobDetail(requestId);
|
||||
setSelectedDetail(detail);
|
||||
} catch (error) {
|
||||
setSelectedDetail(null);
|
||||
setLogLoadError(error instanceof Error ? error.message : '실행 로그를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setIsLogLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openLog = async (requestId: string) => {
|
||||
setIsLogModalOpen(true);
|
||||
await loadLogDetail(requestId);
|
||||
};
|
||||
|
||||
const handleCancel = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '실행 중 요청을 취소할까요?',
|
||||
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
||||
okText: '취소 실행',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingActionRequestId(requestId);
|
||||
|
||||
try {
|
||||
await cancelChatRuntimeJob(requestId);
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '대기열 요청을 제거할까요?',
|
||||
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
||||
okText: '제거',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingActionRequestId(requestId);
|
||||
|
||||
try {
|
||||
await removeChatRuntimeJob(requestId);
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogModalOpen || !selectedDetail?.item?.requestId || !liveDetail?.item?.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDetail.item.requestId !== liveDetail.item.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedDetail(liveDetail);
|
||||
}, [isLogModalOpen, liveDetail, selectedDetail?.item?.requestId]);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'runtime:watch',
|
||||
payload: {
|
||||
requestId: isLogModalOpen ? selectedDetail?.item?.requestId ?? null : null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [isLogModalOpen, selectedDetail?.item?.requestId, socketRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLogModalOpen || !logViewerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
logViewerRef.current.scrollTop = logViewerRef.current.scrollHeight;
|
||||
}, [isLogModalOpen, selectedDetail?.logs]);
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = requestedLogRequestId?.trim();
|
||||
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLogModalOpen(true);
|
||||
void loadLogDetail(requestId).finally(() => {
|
||||
onRequestedLogHandled?.();
|
||||
});
|
||||
}, [onRequestedLogHandled, requestedLogRequestId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="app-chat-runtime">
|
||||
<div className="app-chat-runtime__summary-strip">
|
||||
<div className="app-chat-runtime__summary-card">
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<LoadingOutlined />
|
||||
<Text type="secondary">실행 중</Text>
|
||||
<Text strong>{snapshot?.runningCount ?? 0}건</Text>
|
||||
</div>
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<ClockCircleOutlined />
|
||||
<Text type="secondary">대기열</Text>
|
||||
<Text strong>{snapshot?.queuedCount ?? 0}건</Text>
|
||||
</div>
|
||||
<div className="app-chat-runtime__summary-metric">
|
||||
<EyeOutlined />
|
||||
<Text type="secondary">활성 세션</Text>
|
||||
<Text strong>{snapshot?.sessionCount ?? 0}개</Text>
|
||||
</div>
|
||||
<Text type="secondary" className="app-chat-runtime__summary-status">
|
||||
{connectionState === 'connected' ? '실시간' : connectionState === 'connecting' ? '재연결 중' : '연결 끊김'}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="app-chat-runtime__session-strip">
|
||||
{sessions.length === 0 ? (
|
||||
<Text type="secondary">현재 활성 큐/실행 세션이 없습니다.</Text>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
type="button"
|
||||
className={`app-chat-runtime__session-chip${
|
||||
session.sessionId === activeSessionId ? ' app-chat-runtime__session-chip--active' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
<span>{session.sessionId}</span>
|
||||
<span>{`실행 ${session.runningCount} · 대기 ${session.queuedCount}`}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="app-chat-runtime__content">
|
||||
<RuntimeJobList
|
||||
title="실제 실행 중인 요청"
|
||||
emptyDescription="현재 실행 중인 Codex 요청이 없습니다."
|
||||
items={snapshot?.running ?? []}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onOpenLog={openLog}
|
||||
onCancelJob={handleCancel}
|
||||
onRemoveJob={handleRemove}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
<RuntimeJobList
|
||||
title="실제 대기열"
|
||||
emptyDescription="현재 대기 중인 요청이 없습니다."
|
||||
items={snapshot?.queued ?? []}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={onSelectSession}
|
||||
onOpenLog={openLog}
|
||||
onCancelJob={handleCancel}
|
||||
onRemoveJob={handleRemove}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
<RecentRuntimeList items={snapshot?.recent ?? []} onSelectSession={onSelectSession} onOpenLog={openLog} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={isLogModalOpen}
|
||||
title="실행 로그"
|
||||
placement="right"
|
||||
width="100vw"
|
||||
rootClassName="app-chat-runtime__drawer"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsLogModalOpen(false);
|
||||
setSelectedDetail(null);
|
||||
}}
|
||||
>
|
||||
{isLogLoading ? (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="secondary">로그를 불러오는 중입니다.</Text>
|
||||
</div>
|
||||
) : logLoadError ? (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="danger">{logLoadError}</Text>
|
||||
</div>
|
||||
) : selectedDetail ? (
|
||||
<div className="app-chat-runtime__log-modal">
|
||||
{selectedDetail.item?.sessionId ? (
|
||||
<Button
|
||||
size="small"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => {
|
||||
onSelectSession(selectedDetail.item?.sessionId ?? '');
|
||||
setIsLogModalOpen(false);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
<Text type="secondary">요청: {selectedDetail.item?.requestId ?? '-'}</Text>
|
||||
<Text type="secondary">세션: {selectedDetail.item?.sessionId ?? '-'}</Text>
|
||||
<Text type="secondary">마지막 갱신: {formatRuntimeTime(selectedDetail.lastUpdatedAt)}</Text>
|
||||
<Text type="secondary">종료 상태: {buildTerminalLabel(selectedDetail.terminalStatus) || '-'}</Text>
|
||||
</div>
|
||||
<Paragraph className="app-chat-runtime__job-summary">
|
||||
{selectedDetail.item?.summary ?? '요약 없음'}
|
||||
</Paragraph>
|
||||
<pre ref={logViewerRef} className="app-chat-runtime__log-viewer">
|
||||
{selectedDetail.logs.length > 0 ? selectedDetail.logs.join('\n') : '아직 기록된 로그가 없습니다.'}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-runtime__log-state">
|
||||
<Text type="secondary">표시할 로그가 없습니다.</Text>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
327
src/app/main/mainChatPanel/ErrorLogViewer.tsx
Executable file
327
src/app/main/mainChatPanel/ErrorLogViewer.tsx
Executable file
@@ -0,0 +1,327 @@
|
||||
import { CloseOutlined, ExpandOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { PreviewerUI } from '../../../components/previewer';
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
import {
|
||||
buildErrorListPreviewLine,
|
||||
buildErrorMetaRows,
|
||||
buildErrorReferenceSummary,
|
||||
formatErrorLogTime,
|
||||
formatErrorSummary,
|
||||
getErrorResourceKindLabel,
|
||||
getErrorSourceColor,
|
||||
getErrorSourceLabel,
|
||||
renderErrorTabs,
|
||||
} from './errorLogUtils';
|
||||
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type ErrorLogViewerProps = {
|
||||
errorLogs: ErrorLogItem[];
|
||||
selectedErrorLog: ErrorLogItem | null;
|
||||
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
||||
activeErrorResource: ErrorReferenceResource | null;
|
||||
isErrorDetailExpanded: boolean;
|
||||
isLoadingErrorLogs: boolean;
|
||||
errorLogLoadError: string;
|
||||
errorSourceSummary: Record<string, number>;
|
||||
onRefresh: () => void;
|
||||
onSelectErrorLog: (id: number) => void;
|
||||
onSelectResource: (url: string) => void;
|
||||
onToggleExpanded: (expanded: boolean) => void;
|
||||
};
|
||||
|
||||
function ErrorDetailContent({
|
||||
selectedErrorLog,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
onSelectResource,
|
||||
}: {
|
||||
selectedErrorLog: ErrorLogItem;
|
||||
selectedErrorLogReferenceSummary: ErrorReferenceSummary | null;
|
||||
activeErrorResource: ErrorReferenceResource | null;
|
||||
onSelectResource: (url: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{selectedErrorLogReferenceSummary ? (
|
||||
<div className="app-chat-panel__error-summary-strip">
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">preview</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.previewCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">image</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.imageCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">link</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.linkCount}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-summary-pill">
|
||||
<Text type="secondary">참조 합계</Text>
|
||||
<Text strong>{selectedErrorLogReferenceSummary.resources.length}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedErrorLogReferenceSummary?.resources.length ? (
|
||||
<div className="app-chat-panel__error-reference-stage">
|
||||
<div className="app-chat-panel__error-reference-rail">
|
||||
{selectedErrorLogReferenceSummary.resources.map((resource, index) => (
|
||||
<button
|
||||
key={`${selectedErrorLog.id}-${resource.url}`}
|
||||
type="button"
|
||||
className={
|
||||
resource.url === activeErrorResource?.url
|
||||
? 'app-chat-panel__error-reference-item app-chat-panel__error-reference-item--active'
|
||||
: 'app-chat-panel__error-reference-item'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectResource(resource.url);
|
||||
}}
|
||||
>
|
||||
<div className="app-chat-panel__error-reference-item-top">
|
||||
<Text strong>{index + 1}</Text>
|
||||
<Tag>{getErrorResourceKindLabel(resource.kind)}</Tag>
|
||||
</div>
|
||||
<Text strong>{resource.label}</Text>
|
||||
<Text type="secondary">{resource.sourcePath}</Text>
|
||||
<Text type="secondary" className="app-chat-panel__error-reference-item-snippet">
|
||||
{resource.sourcePreview}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeErrorResource ? (
|
||||
<div className="app-chat-panel__error-reference-main">
|
||||
<div className="app-chat-panel__error-reference-main-top">
|
||||
<div>
|
||||
<Text strong>{activeErrorResource.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
{getErrorResourceKindLabel(activeErrorResource.kind)} · {activeErrorResource.sourcePath}
|
||||
</Text>
|
||||
</div>
|
||||
<Button size="small" type="primary" href={activeErrorResource.url} target="_blank" rel="noreferrer">
|
||||
새 탭에서 열기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="app-chat-panel__error-reference-main-meta">
|
||||
<div className="app-chat-panel__error-reference-main-meta-item">
|
||||
<Text type="secondary">종류</Text>
|
||||
<Text>{getErrorResourceKindLabel(activeErrorResource.kind)}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-reference-main-meta-item">
|
||||
<Text type="secondary">추출 경로</Text>
|
||||
<Text>{activeErrorResource.sourcePath}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-reference-main-meta-item app-chat-panel__error-reference-main-meta-item--wide">
|
||||
<Text type="secondary">URL</Text>
|
||||
<Text>{activeErrorResource.url}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeErrorResource.kind === 'image' ? (
|
||||
<PreviewerUI
|
||||
type="image"
|
||||
title={activeErrorResource.label}
|
||||
description={activeErrorResource.url}
|
||||
value={activeErrorResource.url}
|
||||
imageAlt={activeErrorResource.label}
|
||||
height={420}
|
||||
/>
|
||||
) : activeErrorResource.kind === 'preview' ? (
|
||||
<div className="app-chat-panel__error-preview-card app-chat-panel__error-preview-card--stage">
|
||||
<div className="app-chat-panel__error-preview-frame-wrap">
|
||||
<iframe
|
||||
className="app-chat-panel__error-preview-frame app-chat-panel__error-preview-frame--stage"
|
||||
src={activeErrorResource.url}
|
||||
title={activeErrorResource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PreviewerUI
|
||||
type="text"
|
||||
title={activeErrorResource.label}
|
||||
description="외부 링크 참조"
|
||||
value={activeErrorResource.url}
|
||||
height="auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Text type="secondary" className="app-chat-panel__error-preview-url">
|
||||
{activeErrorResource.url}
|
||||
</Text>
|
||||
|
||||
<div className="app-chat-panel__error-source-preview">
|
||||
<Text strong>참조 추출 위치</Text>
|
||||
<Text type="secondary">{activeErrorResource.sourcePath}</Text>
|
||||
<pre>{activeErrorResource.sourcePreview}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-chat-panel__error-detail-meta">
|
||||
{buildErrorMetaRows(selectedErrorLog).map((row) => (
|
||||
<div key={`${selectedErrorLog.id}-${row.label}`} className="app-chat-panel__error-detail-meta-row">
|
||||
<Text type="secondary">{row.label}</Text>
|
||||
<Text>{row.value}</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Paragraph className="app-chat-panel__error-detail-line">
|
||||
<Text strong>에러 내용</Text>
|
||||
<br />
|
||||
{selectedErrorLog.errorMessage}
|
||||
</Paragraph>
|
||||
|
||||
{renderErrorTabs(selectedErrorLog)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorLogViewer({
|
||||
errorLogs,
|
||||
selectedErrorLog,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
isErrorDetailExpanded,
|
||||
isLoadingErrorLogs,
|
||||
errorLogLoadError,
|
||||
errorSourceSummary,
|
||||
onRefresh,
|
||||
onSelectErrorLog,
|
||||
onSelectResource,
|
||||
onToggleExpanded,
|
||||
}: ErrorLogViewerProps) {
|
||||
const errorDetailHeader = selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail-header">
|
||||
<div className="app-chat-panel__error-detail-header-meta">
|
||||
<Space size={[8, 8]} wrap>
|
||||
<Tag color={getErrorSourceColor(selectedErrorLog.source)}>{getErrorSourceLabel(selectedErrorLog)}</Tag>
|
||||
<Tag>{selectedErrorLog.errorType}</Tag>
|
||||
{selectedErrorLog.errorName ? <Tag>{selectedErrorLog.errorName}</Tag> : null}
|
||||
{selectedErrorLog.statusCode ? <Tag>{selectedErrorLog.statusCode}</Tag> : null}
|
||||
{selectedErrorLog.relatedPlanId ? <Tag>Plan #{selectedErrorLog.relatedPlanId}</Tag> : null}
|
||||
{selectedErrorLog.relatedWorkId ? <Tag>{selectedErrorLog.relatedWorkId}</Tag> : null}
|
||||
</Space>
|
||||
<Text type="secondary">로그 #{selectedErrorLog.id}</Text>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-detail-actions">
|
||||
{isErrorDetailExpanded ? (
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} aria-label="상세영역 닫기" onClick={() => onToggleExpanded(false)} />
|
||||
) : (
|
||||
<Button type="text" size="small" icon={<ExpandOutlined />} aria-label="상세영역 최대화" onClick={() => onToggleExpanded(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="app-chat-panel__error-layout">
|
||||
{isErrorDetailExpanded && selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail-screen">
|
||||
<div className="app-chat-panel__error-detail app-chat-panel__error-detail--expanded">
|
||||
{errorDetailHeader}
|
||||
<ErrorDetailContent
|
||||
selectedErrorLog={selectedErrorLog}
|
||||
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
||||
activeErrorResource={activeErrorResource}
|
||||
onSelectResource={onSelectResource}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="app-chat-panel__error-toolbar">
|
||||
<div className="app-chat-panel__error-toolbar-copy">
|
||||
<Text strong>최근 에러 로그 50건</Text>
|
||||
<Text type="secondary">프론트엔드, 워크서버 API, 작업 자동화 워커 오류를 함께 봅니다.</Text>
|
||||
</div>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{Object.entries(errorSourceSummary).map(([label, count]) => (
|
||||
<Tag key={label}>{label} {count}</Tag>
|
||||
))}
|
||||
<Button size="small" icon={<ReloadOutlined />} loading={isLoadingErrorLogs} onClick={onRefresh}>
|
||||
새로고침
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{errorLogLoadError ? <Alert showIcon type="error" message={errorLogLoadError} /> : null}
|
||||
|
||||
{isLoadingErrorLogs ? (
|
||||
<div className="app-chat-panel__error-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
) : errorLogs.length === 0 ? (
|
||||
<div className="app-chat-panel__error-empty">
|
||||
<Empty description="저장된 에러 로그가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-chat-panel__error-content">
|
||||
<div className="app-chat-panel__error-list">
|
||||
{errorLogs.map((item) => {
|
||||
const referenceSummary = buildErrorReferenceSummary(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
item.id === selectedErrorLog?.id
|
||||
? 'app-chat-panel__error-item app-chat-panel__error-item--active'
|
||||
: 'app-chat-panel__error-item'
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectErrorLog(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="app-chat-panel__error-item-top">
|
||||
<Text type="secondary">{formatErrorLogTime(item.createdAt)}</Text>
|
||||
<Tag bordered={false} color={getErrorSourceColor(item.source)}>
|
||||
{getErrorSourceLabel(item)}
|
||||
</Tag>
|
||||
</div>
|
||||
<Text strong className="app-chat-panel__error-item-title">{formatErrorSummary(item)}</Text>
|
||||
<Text type="secondary" className="app-chat-panel__error-item-message">
|
||||
{buildErrorListPreviewLine(item, referenceSummary)}
|
||||
</Text>
|
||||
<div className="app-chat-panel__error-item-badges">
|
||||
{referenceSummary.previewCount ? <Tag bordered={false}>preview {referenceSummary.previewCount}</Tag> : null}
|
||||
{referenceSummary.imageCount ? <Tag bordered={false}>image {referenceSummary.imageCount}</Tag> : null}
|
||||
{referenceSummary.linkCount ? <Tag bordered={false}>link {referenceSummary.linkCount}</Tag> : null}
|
||||
{item.statusCode ? <Tag bordered={false}>{item.statusCode}</Tag> : null}
|
||||
{item.requestMethod ? <Tag bordered={false}>{item.requestMethod}</Tag> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedErrorLog ? (
|
||||
<div className="app-chat-panel__error-detail">
|
||||
{errorDetailHeader}
|
||||
<ErrorDetailContent
|
||||
selectedErrorLog={selectedErrorLog}
|
||||
selectedErrorLogReferenceSummary={selectedErrorLogReferenceSummary}
|
||||
activeErrorResource={activeErrorResource}
|
||||
onSelectResource={onSelectResource}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/main/mainChatPanel/chatResourceUrl.ts
Normal file
38
src/app/main/mainChatPanel/chatResourceUrl.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/';
|
||||
const CHAT_PUBLIC_RESOURCE_MARKER = '/.codex_chat/';
|
||||
|
||||
function extractEmbeddedResourcePath(value: string) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const apiMarkerIndex = normalized.lastIndexOf(CHAT_API_RESOURCE_MARKER);
|
||||
|
||||
if (apiMarkerIndex >= 0) {
|
||||
return normalized.slice(apiMarkerIndex);
|
||||
}
|
||||
|
||||
const publicMarkerIndex = normalized.lastIndexOf(CHAT_PUBLIC_RESOURCE_MARKER);
|
||||
|
||||
if (publicMarkerIndex >= 0) {
|
||||
return normalized.slice(publicMarkerIndex);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeChatResourceUrl(value: string) {
|
||||
const normalized = extractEmbeddedResourcePath(value);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(normalized, window.location.href).toString();
|
||||
} catch {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
1283
src/app/main/mainChatPanel/chatUtils.ts
Normal file
1283
src/app/main/mainChatPanel/chatUtils.ts
Normal file
File diff suppressed because it is too large
Load Diff
468
src/app/main/mainChatPanel/errorLogUtils.tsx
Executable file
468
src/app/main/mainChatPanel/errorLogUtils.tsx
Executable file
@@ -0,0 +1,468 @@
|
||||
import { Button, Tabs, Typography } from 'antd';
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
import { PreviewerUI } from '../../../components/previewer';
|
||||
import type { ErrorReferenceCandidate, ErrorReferenceResource, ErrorReferenceSummary } from './errorLogUtils.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
const URL_PATTERN = /https?:\/\/[^\s)]+/gi;
|
||||
const IMAGE_URL_PATTERN = /\.(?:png|jpe?g|gif|webp|svg|bmp)(?:[?#].*)?$/i;
|
||||
const PREVIEW_KEY_PATTERN = /(preview|deploy|vercel|netlify|storybook|localhost|127\.0\.0\.1|pageUrl)/i;
|
||||
|
||||
export function formatErrorLogTime(value: string) {
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
export function getErrorSourceColor(source: string) {
|
||||
if (source === 'automation') {
|
||||
return 'volcano';
|
||||
}
|
||||
|
||||
if (source === 'server') {
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
return 'gold';
|
||||
}
|
||||
|
||||
export function getErrorSourceLabel(item: ErrorLogItem) {
|
||||
return item.sourceLabel || item.source;
|
||||
}
|
||||
|
||||
export function formatErrorSummary(item: ErrorLogItem) {
|
||||
const label = item.errorName || item.errorType;
|
||||
const planLabel =
|
||||
item.relatedPlanId || item.relatedWorkId
|
||||
? [item.relatedPlanId ? `#${item.relatedPlanId}` : null, item.relatedWorkId].filter(Boolean).join(' ')
|
||||
: null;
|
||||
|
||||
return planLabel ? `${label} · ${planLabel}` : label;
|
||||
}
|
||||
|
||||
export function formatCompactPath(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (value.length <= 42) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `...${value.slice(-39)}`;
|
||||
}
|
||||
|
||||
export function stringifyErrorDetail(value: unknown) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildErrorMetaRows(item: ErrorLogItem) {
|
||||
return [
|
||||
{ label: '발생 시각', value: formatErrorLogTime(item.createdAt) },
|
||||
{ label: '출처', value: getErrorSourceLabel(item) },
|
||||
{ label: '유형', value: item.errorType },
|
||||
item.errorName ? { label: '이름', value: item.errorName } : null,
|
||||
item.statusCode ? { label: '상태 코드', value: String(item.statusCode) } : null,
|
||||
item.requestMethod || item.requestPath
|
||||
? { label: '요청', value: [item.requestMethod, item.requestPath].filter(Boolean).join(' ') }
|
||||
: null,
|
||||
item.relatedPlanId ? { label: '작업', value: `#${item.relatedPlanId}` } : null,
|
||||
item.relatedWorkId ? { label: '작업 ID', value: item.relatedWorkId } : null,
|
||||
].filter(Boolean) as Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
function collectReferenceCandidates(value: unknown, path = 'context'): ErrorReferenceCandidate[] {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? [{ path, value: trimmed }] : [];
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return [{ path, value: String(value) }];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry, index) => collectReferenceCandidates(entry, `${path}[${index}]`));
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value).flatMap(([key, entry]) => collectReferenceCandidates(entry, `${path}.${key}`));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function formatReferenceLabel(path: string, kind: ErrorReferenceResource['kind'], index: number) {
|
||||
const pathLabel = path.split('.').pop()?.replace(/\[\d+\]/g, '') || '참조';
|
||||
|
||||
if (kind === 'preview') {
|
||||
return `${pathLabel} preview ${index}`;
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
return `${pathLabel} image ${index}`;
|
||||
}
|
||||
|
||||
return `${pathLabel} link ${index}`;
|
||||
}
|
||||
|
||||
function buildReferenceSourcePreview(sourceText: string, url: string) {
|
||||
const normalized = sourceText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (!normalized) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const matchIndex = normalized.indexOf(url);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
return normalized.length > 220 ? `${normalized.slice(0, 220)}...` : normalized;
|
||||
}
|
||||
|
||||
const start = Math.max(0, matchIndex - 96);
|
||||
const end = Math.min(normalized.length, matchIndex + url.length + 96);
|
||||
const prefix = start > 0 ? '... ' : '';
|
||||
const suffix = end < normalized.length ? ' ...' : '';
|
||||
|
||||
return `${prefix}${normalized.slice(start, end)}${suffix}`;
|
||||
}
|
||||
|
||||
function buildErrorReferenceResources(item: ErrorLogItem): ErrorReferenceResource[] {
|
||||
const sourceTexts: ErrorReferenceCandidate[] = [
|
||||
{ path: 'errorMessage', value: item.errorMessage },
|
||||
item.detail ? { path: 'detail', value: item.detail } : null,
|
||||
item.stackTrace ? { path: 'stackTrace', value: item.stackTrace } : null,
|
||||
item.requestPath ? { path: 'requestPath', value: item.requestPath } : null,
|
||||
...(item.context ? collectReferenceCandidates(item.context) : []),
|
||||
].filter(Boolean) as ErrorReferenceCandidate[];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const resources: ErrorReferenceResource[] = [];
|
||||
|
||||
sourceTexts.forEach(({ path, value }) => {
|
||||
const matches = value.match(URL_PATTERN) ?? [];
|
||||
|
||||
matches.forEach((rawUrl) => {
|
||||
const url = rawUrl.replace(/[),.;]+$/g, '');
|
||||
|
||||
if (!url || seen.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = IMAGE_URL_PATTERN.test(url)
|
||||
? 'image'
|
||||
: PREVIEW_KEY_PATTERN.test(path) || PREVIEW_KEY_PATTERN.test(url)
|
||||
? 'preview'
|
||||
: 'link';
|
||||
|
||||
seen.add(url);
|
||||
resources.push({
|
||||
url,
|
||||
label: formatReferenceLabel(path, kind, resources.length + 1),
|
||||
kind,
|
||||
sourcePath: path,
|
||||
sourcePreview: buildReferenceSourcePreview(value, url),
|
||||
});
|
||||
});
|
||||
|
||||
if (!matches.length && /^https?:\/\//i.test(value.trim()) && !seen.has(value.trim())) {
|
||||
const url = value.trim();
|
||||
const kind = IMAGE_URL_PATTERN.test(url)
|
||||
? 'image'
|
||||
: PREVIEW_KEY_PATTERN.test(path) || PREVIEW_KEY_PATTERN.test(url)
|
||||
? 'preview'
|
||||
: 'link';
|
||||
|
||||
seen.add(url);
|
||||
resources.push({
|
||||
url,
|
||||
label: formatReferenceLabel(path, kind, resources.length + 1),
|
||||
kind,
|
||||
sourcePath: path,
|
||||
sourcePreview: buildReferenceSourcePreview(value, url),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return resources;
|
||||
}
|
||||
|
||||
export function buildErrorReferenceSummary(item: ErrorLogItem): ErrorReferenceSummary {
|
||||
const resources = buildErrorReferenceResources(item);
|
||||
const previewCount = resources.filter((resource) => resource.kind === 'preview').length;
|
||||
const imageCount = resources.filter((resource) => resource.kind === 'image').length;
|
||||
const linkCount = resources.filter((resource) => resource.kind === 'link').length;
|
||||
return { resources, previewCount, imageCount, linkCount };
|
||||
}
|
||||
|
||||
export function getDefaultErrorResource(resources: ErrorReferenceResource[]) {
|
||||
return resources.find((resource) => resource.kind === 'preview')
|
||||
?? resources.find((resource) => resource.kind === 'image')
|
||||
?? resources[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function getErrorResourceKindLabel(kind: ErrorReferenceResource['kind']) {
|
||||
if (kind === 'preview') {
|
||||
return 'preview';
|
||||
}
|
||||
|
||||
if (kind === 'image') {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'link';
|
||||
}
|
||||
|
||||
export function buildErrorListPreviewLine(item: ErrorLogItem, summary: ErrorReferenceSummary) {
|
||||
const compactMessage = item.errorMessage.replace(/\s+/g, ' ').trim();
|
||||
const focusLabel = item.requestPath || compactMessage;
|
||||
const referenceTotal = summary.resources.length;
|
||||
|
||||
return [formatCompactPath(focusLabel), referenceTotal ? `참조 ${referenceTotal}` : null].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function buildErrorOverviewText(item: ErrorLogItem) {
|
||||
return [
|
||||
`에러: ${item.errorMessage}`,
|
||||
...buildErrorMetaRows(item).map((row) => `${row.label}: ${row.value}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildErrorDetailTabs(item: ErrorLogItem) {
|
||||
const resources = buildErrorReferenceSummary(item).resources;
|
||||
const imageResources = resources.filter((resource) => resource.kind === 'image');
|
||||
const previewResources = resources.filter((resource) => resource.kind === 'preview');
|
||||
const linkResources = resources.filter((resource) => resource.kind === 'link');
|
||||
const tabs = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: '개요',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-tab-stack">
|
||||
{previewResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{previewResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<PreviewerUI
|
||||
type="text"
|
||||
title={formatErrorSummary(item)}
|
||||
description="선택한 로그의 핵심 정보"
|
||||
value={buildErrorOverviewText(item)}
|
||||
height="auto"
|
||||
/>
|
||||
{linkResources.length || imageResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{linkResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (item.detail) {
|
||||
tabs.push({
|
||||
key: 'detail',
|
||||
label: '상세',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="code"
|
||||
title="Error Detail"
|
||||
description="상세 에러 데이터"
|
||||
value={stringifyErrorDetail(item.detail)}
|
||||
language="json"
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.context) {
|
||||
tabs.push({
|
||||
key: 'context',
|
||||
label: '컨텍스트',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="json"
|
||||
title="Error Context"
|
||||
description="추가 컨텍스트 데이터"
|
||||
value={item.context}
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (item.stackTrace) {
|
||||
tabs.push({
|
||||
key: 'stack',
|
||||
label: '스택',
|
||||
children: (
|
||||
<PreviewerUI
|
||||
type="code"
|
||||
title="Stack Trace"
|
||||
description="에러 스택"
|
||||
value={item.stackTrace}
|
||||
language="bash"
|
||||
height="auto"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (imageResources.length || linkResources.length) {
|
||||
tabs.push({
|
||||
key: 'references',
|
||||
label: '참조',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-tab-stack">
|
||||
{imageResources.length ? (
|
||||
<div className="app-chat-panel__error-image-grid">
|
||||
{imageResources.map((resource) => (
|
||||
<PreviewerUI
|
||||
key={resource.url}
|
||||
type="image"
|
||||
title={resource.label}
|
||||
description={`${resource.sourcePath} · ${resource.url}`}
|
||||
value={resource.url}
|
||||
imageAlt={resource.label}
|
||||
height={320}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{previewResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{previewResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{linkResources.length ? (
|
||||
<div className="app-chat-panel__error-reference-list">
|
||||
{linkResources.map((resource) => (
|
||||
<Button
|
||||
key={resource.url}
|
||||
type="link"
|
||||
href={resource.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0, justifyContent: 'flex-start' }}
|
||||
>
|
||||
{resource.label}: {resource.url}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (previewResources.length) {
|
||||
tabs.push({
|
||||
key: 'preview',
|
||||
label: '미리보기',
|
||||
children: (
|
||||
<div className="app-chat-panel__error-preview-grid">
|
||||
{previewResources.map((resource) => (
|
||||
<section key={resource.url} className="app-chat-panel__error-preview-card">
|
||||
<div className="app-chat-panel__error-preview-card-top">
|
||||
<div>
|
||||
<Text strong>{resource.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary">{resource.sourcePath}</Text>
|
||||
</div>
|
||||
<Button size="small" type="primary" href={resource.url} target="_blank" rel="noreferrer">
|
||||
열기
|
||||
</Button>
|
||||
</div>
|
||||
<div className="app-chat-panel__error-preview-frame-wrap">
|
||||
<iframe
|
||||
className="app-chat-panel__error-preview-frame"
|
||||
src={resource.url}
|
||||
title={resource.label}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" className="app-chat-panel__error-preview-url">
|
||||
{resource.url}
|
||||
</Text>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
export function buildErrorSourceSummary(errorLogs: ErrorLogItem[]) {
|
||||
return errorLogs.reduce<Record<string, number>>((acc, item) => {
|
||||
const key = getErrorSourceLabel(item);
|
||||
acc[key] = (acc[key] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function renderErrorTabs(item: ErrorLogItem) {
|
||||
return <Tabs className="app-chat-panel__error-tabs" items={buildErrorDetailTabs(item)} />;
|
||||
}
|
||||
8
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file
8
src/app/main/mainChatPanel/errorLogUtils.types.ts
Executable file
@@ -0,0 +1,8 @@
|
||||
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
|
||||
|
||||
export type { ErrorReferenceResource, ErrorReferenceSummary };
|
||||
|
||||
export type ErrorReferenceCandidate = {
|
||||
path: string;
|
||||
value: string;
|
||||
};
|
||||
42
src/app/main/mainChatPanel/index.ts
Normal file
42
src/app/main/mainChatPanel/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export { ChatConversationView } from './ChatConversationView';
|
||||
export { ChatRuntimeDashboard } from './ChatRuntimeDashboard';
|
||||
export { ErrorLogViewer } from './ErrorLogViewer';
|
||||
export {
|
||||
buildOfflineReply,
|
||||
clearStoredChatClientConversationState,
|
||||
copyText,
|
||||
createActivityLogPlaceholder,
|
||||
createChatConversationRoom,
|
||||
createChatMessage,
|
||||
createIntroMessage,
|
||||
createLocalMessage,
|
||||
cancelChatRuntimeJob,
|
||||
deleteChatConversationRequest,
|
||||
deleteChatConversationRoom,
|
||||
fetchChatConversationDetail,
|
||||
fetchChatConversations,
|
||||
fetchChatRuntimeJobDetail,
|
||||
fetchChatRuntimeSnapshot,
|
||||
getStoredChatSessionLastTypeId,
|
||||
isPreparingChatReplyText,
|
||||
getChatClientSessionId,
|
||||
loadStoredChatMessages,
|
||||
markChatConversationResponsesRead,
|
||||
mergeRecoveredChatMessages,
|
||||
persistStoredChatMessages,
|
||||
renameChatConversationRoom,
|
||||
removeChatRuntimeJob,
|
||||
resetLastReceivedChatEventId,
|
||||
setStoredChatSessionLastTypeId,
|
||||
setChatClientSessionId,
|
||||
uploadChatComposerFile,
|
||||
upsertChatMessage,
|
||||
updateChatConversationRoom,
|
||||
} from './chatUtils';
|
||||
export {
|
||||
getSharedChatRuntimeSnapshot,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
subscribeChatConnection,
|
||||
useChatConnection,
|
||||
} from './useChatConnection';
|
||||
export { useErrorLogs } from './useErrorLogs';
|
||||
44
src/app/main/mainChatPanel/sharedActiveConversation.ts
Normal file
44
src/app/main/mainChatPanel/sharedActiveConversation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type SharedActiveConversationSnapshot = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const sharedActiveConversation = {
|
||||
sessionId: '',
|
||||
title: '',
|
||||
subscribers: new Set<() => void>(),
|
||||
};
|
||||
|
||||
function emitSharedActiveConversation() {
|
||||
sharedActiveConversation.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
export function getSharedActiveConversationSnapshot(): SharedActiveConversationSnapshot {
|
||||
return {
|
||||
sessionId: sharedActiveConversation.sessionId,
|
||||
title: sharedActiveConversation.title,
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeSharedActiveConversation(listener: () => void) {
|
||||
sharedActiveConversation.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedActiveConversation.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function setSharedActiveConversationSnapshot(snapshot: SharedActiveConversationSnapshot) {
|
||||
const nextSessionId = snapshot.sessionId.trim();
|
||||
const nextTitle = snapshot.title.trim();
|
||||
|
||||
if (sharedActiveConversation.sessionId === nextSessionId && sharedActiveConversation.title === nextTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedActiveConversation.sessionId = nextSessionId;
|
||||
sharedActiveConversation.title = nextTitle;
|
||||
emitSharedActiveConversation();
|
||||
}
|
||||
251
src/app/main/mainChatPanel/types.ts
Executable file
251
src/app/main/mainChatPanel/types.ts
Executable file
@@ -0,0 +1,251 @@
|
||||
import type { ErrorLogItem } from '../errorLogApi';
|
||||
|
||||
export type ChatMessage = {
|
||||
id: number;
|
||||
author: 'codex' | 'system' | 'user';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
clientRequestId?: string | null;
|
||||
deliveryStatus?: 'retrying' | 'failed' | null;
|
||||
retryCount?: number;
|
||||
};
|
||||
|
||||
export type ChatComposerAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
publicUrl: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ChatViewContext = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
topMenu: string;
|
||||
focusedComponentId: string | null;
|
||||
pageUrl: string;
|
||||
isStandaloneMode: boolean;
|
||||
pageVisibilityState: 'visible' | 'hidden';
|
||||
chatTypeId: string | null;
|
||||
chatTypeLabel: string;
|
||||
chatTypeDescription: string;
|
||||
chatTypeIsTemplate: boolean;
|
||||
};
|
||||
|
||||
export type ChatConversationSummary = {
|
||||
sessionId: string;
|
||||
clientId: string | null;
|
||||
title: string;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
hasUnreadResponse: boolean;
|
||||
currentRequestId: string | null;
|
||||
currentJobStatus: ChatJobStatus | null;
|
||||
currentJobMessage: string | null;
|
||||
currentQueueSize: number;
|
||||
currentStatusUpdatedAt: string | null;
|
||||
lastMessagePreview: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatConversationRequestStatus =
|
||||
| 'accepted'
|
||||
| 'queued'
|
||||
| 'started'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'removed';
|
||||
|
||||
export type ChatConversationRequest = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
status: ChatConversationRequestStatus;
|
||||
statusMessage: string | null;
|
||||
userMessageId: number | null;
|
||||
userText: string;
|
||||
responseMessageId: number | null;
|
||||
responseText: string;
|
||||
hasResponse: boolean;
|
||||
canDelete: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
answeredAt: string | null;
|
||||
terminalAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatConversationActivityLog = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
lines: string[];
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type ChatActivityEvent = {
|
||||
requestId: string;
|
||||
line: string;
|
||||
lineCount: number;
|
||||
};
|
||||
|
||||
export type ChatPanelView = 'chat' | 'runtime' | 'errors';
|
||||
|
||||
export type ChatJobMode = 'queue' | 'direct';
|
||||
export type ChatJobStatus = 'queued' | 'started' | 'completed' | 'failed';
|
||||
|
||||
export type ChatRuntimeJobStatus = 'queued' | 'running';
|
||||
|
||||
export type ChatJobEvent = {
|
||||
requestId: string;
|
||||
status: ChatJobStatus;
|
||||
mode: ChatJobMode;
|
||||
queueSize: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ChatRuntimeJobItem = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
mode: ChatJobMode;
|
||||
status: ChatRuntimeJobStatus;
|
||||
summary: string;
|
||||
enqueuedAt: string;
|
||||
startedAt: string | null;
|
||||
pid: number | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSessionSummary = {
|
||||
sessionId: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
latestRequestId: string | null;
|
||||
latestStatus: ChatRuntimeJobStatus | null;
|
||||
};
|
||||
|
||||
export type ChatRuntimeSnapshot = {
|
||||
generatedAt: string;
|
||||
runningCount: number;
|
||||
queuedCount: number;
|
||||
sessionCount: number;
|
||||
running: ChatRuntimeJobItem[];
|
||||
queued: ChatRuntimeJobItem[];
|
||||
sessions: ChatRuntimeSessionSummary[];
|
||||
recent: Array<ChatRuntimeJobItem & { terminalStatus: ChatRuntimeTerminalStatus; lastUpdatedAt: string }>;
|
||||
};
|
||||
|
||||
export type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed';
|
||||
|
||||
export type ChatRuntimeJobDetail = {
|
||||
item: ChatRuntimeJobItem | null;
|
||||
logs: string[];
|
||||
lastUpdatedAt: string | null;
|
||||
terminalStatus: ChatRuntimeTerminalStatus | null;
|
||||
availableActions: {
|
||||
cancel: boolean;
|
||||
remove: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ErrorReferenceResource = {
|
||||
url: string;
|
||||
label: string;
|
||||
kind: 'image' | 'link' | 'preview';
|
||||
sourcePath: string;
|
||||
sourcePreview: string;
|
||||
};
|
||||
|
||||
export type ErrorReferenceSummary = {
|
||||
resources: ErrorReferenceResource[];
|
||||
previewCount: number;
|
||||
imageCount: number;
|
||||
linkCount: number;
|
||||
};
|
||||
|
||||
export type MainChatPanelProps = {
|
||||
initialView?: 'live' | 'errors';
|
||||
lockOuterScrollOnMobile?: boolean;
|
||||
};
|
||||
|
||||
export type ChatServerEvent =
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:init';
|
||||
payload: {
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:message';
|
||||
payload: ChatMessage;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:message:update';
|
||||
payload: ChatMessage;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:status';
|
||||
payload: {
|
||||
connectedAt: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:error';
|
||||
payload: {
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:job';
|
||||
payload: ChatJobEvent;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:runtime';
|
||||
payload: ChatRuntimeSnapshot;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:runtime:detail';
|
||||
payload: ChatRuntimeJobDetail;
|
||||
}
|
||||
| {
|
||||
eventId: number;
|
||||
sessionId: string;
|
||||
type: 'chat:activity';
|
||||
payload: ChatActivityEvent;
|
||||
};
|
||||
|
||||
export type ChatConversationDetailResponse = {
|
||||
ok: boolean;
|
||||
item: ChatConversationSummary;
|
||||
messages: ChatMessage[];
|
||||
requests: ChatConversationRequest[];
|
||||
activityLogs: ChatConversationActivityLog[];
|
||||
oldestLoadedMessageId: number | null;
|
||||
hasOlderMessages: boolean;
|
||||
};
|
||||
|
||||
export type ErrorLogViewerState = {
|
||||
errorLogs: ErrorLogItem[];
|
||||
selectedErrorLogId: number | null;
|
||||
isLoadingErrorLogs: boolean;
|
||||
errorLogLoadError: string;
|
||||
activeErrorResourceUrl: string;
|
||||
isErrorDetailExpanded: boolean;
|
||||
};
|
||||
689
src/app/main/mainChatPanel/useChatConnection.ts
Executable file
689
src/app/main/mainChatPanel/useChatConnection.ts
Executable file
@@ -0,0 +1,689 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
CHAT_CONNECTION,
|
||||
diagnoseConnectionFailure,
|
||||
getLastReceivedChatEventId,
|
||||
handleChatServerEvent,
|
||||
persistLastReceivedChatEventId,
|
||||
resolveChatWebSocketUrl,
|
||||
} from './chatUtils';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatJobEvent,
|
||||
ChatMessage,
|
||||
ChatRuntimeJobDetail,
|
||||
ChatRuntimeSnapshot,
|
||||
ChatViewContext,
|
||||
} from './types';
|
||||
|
||||
const DISCONNECT_UI_DELAY_MS = 1500;
|
||||
const PRESENCE_PING_INTERVAL_MS = 20_000;
|
||||
const BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS = 15_000;
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
type UseChatConnectionOptions = {
|
||||
sessionId: string;
|
||||
currentContext: ChatViewContext;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
onMessageEvent?: (message: ChatMessage, sessionId: string) => void;
|
||||
onJobEvent?: (event: ChatJobEvent, sessionId: string) => void;
|
||||
onRuntimeEvent?: (snapshot: ChatRuntimeSnapshot) => void;
|
||||
onRuntimeDetailEvent?: (detail: ChatRuntimeJobDetail) => void;
|
||||
onActivityEvent?: (event: ChatActivityEvent) => void;
|
||||
};
|
||||
|
||||
type SharedChatConnectionState = {
|
||||
connectionState: ConnectionState;
|
||||
connectionErrorDetail: string;
|
||||
runtimeSnapshot: ChatRuntimeSnapshot | null;
|
||||
};
|
||||
|
||||
type SharedChatConnection = SharedChatConnectionState & {
|
||||
socketRef: { current: WebSocket | null };
|
||||
reconnectTimerId: number | null;
|
||||
disconnectUiTimerId: number | null;
|
||||
connectTimeoutId: number | null;
|
||||
sessionId: string;
|
||||
currentContext: ChatViewContext | null;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>> | null;
|
||||
onMessageEvent?: ((message: ChatMessage, sessionId: string) => void) | undefined;
|
||||
onJobEvent?: ((event: ChatJobEvent, sessionId: string) => void) | undefined;
|
||||
onRuntimeEvent?: ((snapshot: ChatRuntimeSnapshot) => void) | undefined;
|
||||
onRuntimeDetailEvent?: ((detail: ChatRuntimeJobDetail) => void) | undefined;
|
||||
onActivityEvent?: ((event: ChatActivityEvent) => void) | undefined;
|
||||
lastEventId: number;
|
||||
websocketUrl: string;
|
||||
subscribers: Set<() => void>;
|
||||
pingSubscriberCount: number;
|
||||
consumerCount: number;
|
||||
pingIntervalId: number | null;
|
||||
visibilityHandlerInstalled: boolean;
|
||||
pageShowHandlerInstalled: boolean;
|
||||
focusHandlerInstalled: boolean;
|
||||
onlineHandlerInstalled: boolean;
|
||||
hasConnectedOnce: boolean;
|
||||
suppressDisconnectNotification: boolean;
|
||||
lastBackgroundAt: number | null;
|
||||
};
|
||||
|
||||
const sharedChatConnection: SharedChatConnection = {
|
||||
connectionState: 'connecting',
|
||||
connectionErrorDetail: '',
|
||||
runtimeSnapshot: null,
|
||||
socketRef: { current: null },
|
||||
reconnectTimerId: null,
|
||||
disconnectUiTimerId: null,
|
||||
connectTimeoutId: null,
|
||||
sessionId: '',
|
||||
currentContext: null,
|
||||
setMessages: null,
|
||||
onMessageEvent: undefined,
|
||||
onJobEvent: undefined,
|
||||
onRuntimeEvent: undefined,
|
||||
onRuntimeDetailEvent: undefined,
|
||||
onActivityEvent: undefined,
|
||||
lastEventId: 0,
|
||||
websocketUrl: '',
|
||||
subscribers: new Set(),
|
||||
pingSubscriberCount: 0,
|
||||
consumerCount: 0,
|
||||
pingIntervalId: null,
|
||||
visibilityHandlerInstalled: false,
|
||||
pageShowHandlerInstalled: false,
|
||||
focusHandlerInstalled: false,
|
||||
onlineHandlerInstalled: false,
|
||||
hasConnectedOnce: false,
|
||||
suppressDisconnectNotification: false,
|
||||
lastBackgroundAt: null,
|
||||
};
|
||||
|
||||
function emitSharedState() {
|
||||
sharedChatConnection.subscribers.forEach((listener) => {
|
||||
listener();
|
||||
});
|
||||
}
|
||||
|
||||
function getSnapshot(): SharedChatConnectionState {
|
||||
return {
|
||||
connectionState: sharedChatConnection.connectionState,
|
||||
connectionErrorDetail: sharedChatConnection.connectionErrorDetail,
|
||||
runtimeSnapshot: sharedChatConnection.runtimeSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export function getChatConnectionSnapshot() {
|
||||
return getSnapshot();
|
||||
}
|
||||
|
||||
export function subscribeChatConnection(listener: () => void) {
|
||||
sharedChatConnection.subscribers.add(listener);
|
||||
|
||||
return () => {
|
||||
sharedChatConnection.subscribers.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSharedChatRuntimeSnapshot() {
|
||||
return sharedChatConnection.runtimeSnapshot;
|
||||
}
|
||||
|
||||
export function setSharedChatRuntimeSnapshot(snapshot: ChatRuntimeSnapshot | null) {
|
||||
if (sharedChatConnection.runtimeSnapshot === snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.runtimeSnapshot = snapshot;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function setSharedConnectionState(nextState: ConnectionState) {
|
||||
if (sharedChatConnection.connectionState === nextState) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.connectionState = nextState;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function setSharedConnectionError(detail: string) {
|
||||
if (sharedChatConnection.connectionErrorDetail === detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.connectionErrorDetail = detail;
|
||||
emitSharedState();
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (sharedChatConnection.reconnectTimerId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.reconnectTimerId);
|
||||
sharedChatConnection.reconnectTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDisconnectUiTimer() {
|
||||
if (sharedChatConnection.disconnectUiTimerId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.disconnectUiTimerId);
|
||||
sharedChatConnection.disconnectUiTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearConnectTimeout() {
|
||||
if (sharedChatConnection.connectTimeoutId !== null) {
|
||||
window.clearTimeout(sharedChatConnection.connectTimeoutId);
|
||||
sharedChatConnection.connectTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function sendContextUpdate(context: ChatViewContext | null = sharedChatConnection.currentContext) {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveVisibilityState =
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible';
|
||||
const livePageUrl = typeof window !== 'undefined' ? window.location.href : context.pageUrl;
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'context:update',
|
||||
payload: {
|
||||
pageId: context.pageId,
|
||||
pageTitle: context.pageTitle,
|
||||
topMenu: context.topMenu,
|
||||
focusedComponentId: context.focusedComponentId,
|
||||
pageUrl: livePageUrl,
|
||||
isStandaloneMode: context.isStandaloneMode,
|
||||
pageVisibilityState: liveVisibilityState,
|
||||
chatTypeId: context.chatTypeId,
|
||||
chatTypeLabel: context.chatTypeLabel,
|
||||
chatTypeDescription: context.chatTypeDescription,
|
||||
chatTypeIsTemplate: context.chatTypeIsTemplate,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function sendPresencePing() {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'presence:ping',
|
||||
payload: {
|
||||
at: Date.now(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function refreshSharedSocket() {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (socket && socket.readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectSharedSocket();
|
||||
connectSharedSocket();
|
||||
}
|
||||
|
||||
function sendEventReceived(eventId: number) {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !Number.isFinite(eventId) || eventId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: 'event:received',
|
||||
payload: {
|
||||
eventId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stopPresenceMonitoring() {
|
||||
if (sharedChatConnection.pingIntervalId !== null) {
|
||||
window.clearInterval(sharedChatConnection.pingIntervalId);
|
||||
sharedChatConnection.pingIntervalId = null;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.visibilityHandlerInstalled) {
|
||||
window.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
sharedChatConnection.visibilityHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.pageShowHandlerInstalled) {
|
||||
window.removeEventListener('pageshow', handlePageShow);
|
||||
sharedChatConnection.pageShowHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.focusHandlerInstalled) {
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
sharedChatConnection.focusHandlerInstalled = false;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.onlineHandlerInstalled) {
|
||||
window.removeEventListener('online', handleWindowOnline);
|
||||
sharedChatConnection.onlineHandlerInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshSocketAfterResume() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.lastBackgroundAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - sharedChatConnection.lastBackgroundAt >= BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
sharedChatConnection.lastBackgroundAt = Date.now();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
sendPresencePing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handleWindowFocus() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
sendPresencePing();
|
||||
}
|
||||
|
||||
function handleWindowOnline() {
|
||||
refreshSharedSocket();
|
||||
}
|
||||
|
||||
function startPresenceMonitoring() {
|
||||
if (sharedChatConnection.pingSubscriberCount <= 0 || sharedChatConnection.connectionState !== 'connected') {
|
||||
stopPresenceMonitoring();
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.lastBackgroundAt = null;
|
||||
sendPresencePing();
|
||||
|
||||
if (sharedChatConnection.pingIntervalId === null) {
|
||||
sharedChatConnection.pingIntervalId = window.setInterval(() => {
|
||||
sendPresencePing();
|
||||
}, PRESENCE_PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.visibilityHandlerInstalled) {
|
||||
window.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
sharedChatConnection.visibilityHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.pageShowHandlerInstalled) {
|
||||
window.addEventListener('pageshow', handlePageShow);
|
||||
sharedChatConnection.pageShowHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.focusHandlerInstalled) {
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
sharedChatConnection.focusHandlerInstalled = true;
|
||||
}
|
||||
|
||||
if (!sharedChatConnection.onlineHandlerInstalled) {
|
||||
window.addEventListener('online', handleWindowOnline);
|
||||
sharedChatConnection.onlineHandlerInstalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (sharedChatConnection.reconnectTimerId !== null || !sharedChatConnection.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.reconnectTimerId = window.setTimeout(() => {
|
||||
sharedChatConnection.reconnectTimerId = null;
|
||||
connectSharedSocket();
|
||||
}, CHAT_CONNECTION.reconnectDelayMs);
|
||||
}
|
||||
|
||||
function handleSharedDisconnect(message?: string, detail?: string) {
|
||||
setSharedConnectionError(detail ?? '');
|
||||
clearDisconnectUiTimer();
|
||||
|
||||
if (sharedChatConnection.connectionState !== 'connected') {
|
||||
setSharedConnectionState('disconnected');
|
||||
} else {
|
||||
sharedChatConnection.disconnectUiTimerId = window.setTimeout(() => {
|
||||
sharedChatConnection.disconnectUiTimerId = null;
|
||||
|
||||
if (sharedChatConnection.socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSharedConnectionState('disconnected');
|
||||
}, DISCONNECT_UI_DELAY_MS);
|
||||
}
|
||||
|
||||
if (message) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectSharedSocket() {
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
stopPresenceMonitoring();
|
||||
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
sharedChatConnection.suppressDisconnectNotification = true;
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
socket?.close();
|
||||
}
|
||||
|
||||
function releaseSharedConnectionConsumer() {
|
||||
sharedChatConnection.consumerCount = Math.max(0, sharedChatConnection.consumerCount - 1);
|
||||
|
||||
if (sharedChatConnection.consumerCount > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.currentContext = null;
|
||||
sharedChatConnection.setMessages = null;
|
||||
sharedChatConnection.onMessageEvent = undefined;
|
||||
sharedChatConnection.onJobEvent = undefined;
|
||||
sharedChatConnection.onRuntimeEvent = undefined;
|
||||
sharedChatConnection.onRuntimeDetailEvent = undefined;
|
||||
setSharedChatRuntimeSnapshot(null);
|
||||
disconnectSharedSocket();
|
||||
setSharedConnectionError('');
|
||||
setSharedConnectionState('disconnected');
|
||||
}
|
||||
|
||||
function connectSharedSocket() {
|
||||
if (!sharedChatConnection.sessionId || !sharedChatConnection.setMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSocket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
|
||||
if (sharedChatConnection.connectionState !== 'connected') {
|
||||
setSharedConnectionState('connecting');
|
||||
}
|
||||
|
||||
sharedChatConnection.websocketUrl = resolveChatWebSocketUrl(sharedChatConnection.sessionId, sharedChatConnection.lastEventId);
|
||||
|
||||
let socket: WebSocket;
|
||||
|
||||
try {
|
||||
socket = new WebSocket(sharedChatConnection.websocketUrl);
|
||||
} catch {
|
||||
handleSharedDisconnect(
|
||||
`워크서버 WebSocket 주소가 올바르지 않습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
|
||||
'WebSocket 객체를 생성하지 못했습니다. 대상 주소 형식과 환경변수를 확인해 주세요.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.socketRef.current = socket;
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
let disconnectHandled = false;
|
||||
|
||||
const reportDisconnect = (message?: string, closeEvent?: CloseEvent) => {
|
||||
if (disconnectHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectHandled = true;
|
||||
|
||||
const wasSuppressed = sharedChatConnection.suppressDisconnectNotification;
|
||||
|
||||
if (sharedChatConnection.socketRef.current === socket) {
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
}
|
||||
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
|
||||
if (wasSuppressed) {
|
||||
setSharedConnectionError('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeEvent?.code === 1000 && !message) {
|
||||
setSharedConnectionError('');
|
||||
return;
|
||||
}
|
||||
|
||||
void diagnoseConnectionFailure(sharedChatConnection.websocketUrl, closeEvent).then((detail) => {
|
||||
handleSharedDisconnect(message, detail);
|
||||
});
|
||||
};
|
||||
|
||||
sharedChatConnection.connectTimeoutId = window.setTimeout(() => {
|
||||
if (sharedChatConnection.socketRef.current !== socket || socket.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
sharedChatConnection.socketRef.current = null;
|
||||
socket.close();
|
||||
reportDisconnect(
|
||||
`워크서버 연결 시간이 초과되었습니다. 대상: ${sharedChatConnection.websocketUrl || '/ws/chat'} 자동으로 다시 연결합니다.`,
|
||||
);
|
||||
}, CHAT_CONNECTION.connectTimeoutMs);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
sharedChatConnection.hasConnectedOnce = true;
|
||||
sharedChatConnection.suppressDisconnectNotification = false;
|
||||
setSharedConnectionState('connected');
|
||||
setSharedConnectionError('');
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
startPresenceMonitoring();
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const setMessages = sharedChatConnection.setMessages;
|
||||
|
||||
if (!setMessages) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleChatServerEvent({
|
||||
eventData: String(event.data),
|
||||
currentPageUrl: sharedChatConnection.currentContext?.pageUrl ?? '',
|
||||
expectedSessionId: sharedChatConnection.sessionId,
|
||||
setMessages,
|
||||
onMessageEvent: sharedChatConnection.onMessageEvent,
|
||||
onJobEvent: sharedChatConnection.onJobEvent,
|
||||
onRuntimeEvent: sharedChatConnection.onRuntimeEvent,
|
||||
onRuntimeDetailEvent: sharedChatConnection.onRuntimeDetailEvent,
|
||||
onActivityEvent: sharedChatConnection.onActivityEvent,
|
||||
onEventReceived: (eventId) => {
|
||||
sharedChatConnection.lastEventId = eventId;
|
||||
persistLastReceivedChatEventId(sharedChatConnection.sessionId, eventId);
|
||||
sendEventReceived(eventId);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const parsedEvent = JSON.parse(String(event.data)) as ChatRuntimeEventEnvelope | null;
|
||||
|
||||
if (parsedEvent?.type === 'chat:runtime') {
|
||||
setSharedChatRuntimeSnapshot(parsedEvent.payload);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payloads here; detailed parsing is already handled downstream
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', (event) => {
|
||||
clearConnectTimeout();
|
||||
stopPresenceMonitoring();
|
||||
reportDisconnect(
|
||||
event.code === 1000 ? undefined : '워크서버 연결이 끊어졌습니다. 자동으로 다시 연결합니다.',
|
||||
event,
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', () => {
|
||||
clearConnectTimeout();
|
||||
stopPresenceMonitoring();
|
||||
reportDisconnect('워크서버 WebSocket 연결에 실패했습니다. 자동으로 다시 연결합니다.');
|
||||
});
|
||||
}
|
||||
|
||||
type ChatRuntimeEventEnvelope = {
|
||||
type: 'chat:runtime';
|
||||
payload: ChatRuntimeSnapshot;
|
||||
};
|
||||
|
||||
function ensureSharedConnection(options: UseChatConnectionOptions) {
|
||||
const sessionChanged = sharedChatConnection.sessionId !== options.sessionId;
|
||||
|
||||
sharedChatConnection.currentContext = options.currentContext;
|
||||
sharedChatConnection.setMessages = options.setMessages;
|
||||
sharedChatConnection.onMessageEvent = options.onMessageEvent;
|
||||
sharedChatConnection.onJobEvent = options.onJobEvent;
|
||||
sharedChatConnection.onRuntimeEvent = options.onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = options.onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = options.onActivityEvent;
|
||||
|
||||
if (sessionChanged) {
|
||||
sharedChatConnection.sessionId = options.sessionId;
|
||||
sharedChatConnection.lastEventId = getLastReceivedChatEventId(options.sessionId);
|
||||
sharedChatConnection.hasConnectedOnce = false;
|
||||
disconnectSharedSocket();
|
||||
}
|
||||
|
||||
connectSharedSocket();
|
||||
}
|
||||
|
||||
export function useChatConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
}: UseChatConnectionOptions) {
|
||||
const [snapshot, setSnapshot] = useState<SharedChatConnectionState>(() => getSnapshot());
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.consumerCount += 1;
|
||||
|
||||
return () => {
|
||||
releaseSharedConnectionConsumer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSnapshotChange = () => {
|
||||
setSnapshot(getSnapshot());
|
||||
};
|
||||
|
||||
const unsubscribe = subscribeChatConnection(handleSnapshotChange);
|
||||
ensureSharedConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
});
|
||||
handleSnapshotChange();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [sessionId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.currentContext = currentContext;
|
||||
sharedChatConnection.setMessages = setMessages;
|
||||
sharedChatConnection.onMessageEvent = onMessageEvent;
|
||||
sharedChatConnection.onJobEvent = onJobEvent;
|
||||
sharedChatConnection.onRuntimeEvent = onRuntimeEvent;
|
||||
sharedChatConnection.onRuntimeDetailEvent = onRuntimeDetailEvent;
|
||||
sharedChatConnection.onActivityEvent = onActivityEvent;
|
||||
sendContextUpdate(currentContext);
|
||||
}, [
|
||||
currentContext,
|
||||
onMessageEvent,
|
||||
onJobEvent,
|
||||
onRuntimeEvent,
|
||||
onRuntimeDetailEvent,
|
||||
onActivityEvent,
|
||||
setMessages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
sharedChatConnection.pingSubscriberCount += 1;
|
||||
startPresenceMonitoring();
|
||||
|
||||
return () => {
|
||||
sharedChatConnection.pingSubscriberCount = Math.max(0, sharedChatConnection.pingSubscriberCount - 1);
|
||||
|
||||
if (sharedChatConnection.pingSubscriberCount === 0) {
|
||||
stopPresenceMonitoring();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
connectionState: snapshot.connectionState,
|
||||
connectionErrorDetail: snapshot.connectionErrorDetail,
|
||||
socketRef: sharedChatConnection.socketRef,
|
||||
};
|
||||
}
|
||||
83
src/app/main/mainChatPanel/useErrorLogs.ts
Executable file
83
src/app/main/mainChatPanel/useErrorLogs.ts
Executable file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchErrorLogs } from '../errorLogApi';
|
||||
import { buildErrorReferenceSummary, buildErrorSourceSummary, getDefaultErrorResource } from './errorLogUtils';
|
||||
import type { ChatPanelView } from './types';
|
||||
|
||||
type UseErrorLogsOptions = {
|
||||
activeView: ChatPanelView;
|
||||
hasAccess: boolean;
|
||||
};
|
||||
|
||||
export function useErrorLogs({ activeView, hasAccess }: UseErrorLogsOptions) {
|
||||
const [errorLogs, setErrorLogs] = useState<Awaited<ReturnType<typeof fetchErrorLogs>>>([]);
|
||||
const [selectedErrorLogId, setSelectedErrorLogId] = useState<number | null>(null);
|
||||
const [isLoadingErrorLogs, setIsLoadingErrorLogs] = useState(false);
|
||||
const [errorLogLoadError, setErrorLogLoadError] = useState('');
|
||||
const [activeErrorResourceUrl, setActiveErrorResourceUrl] = useState('');
|
||||
const [isErrorDetailExpanded, setIsErrorDetailExpanded] = useState(false);
|
||||
|
||||
const loadErrorLogs = async () => {
|
||||
if (!hasAccess || isLoadingErrorLogs) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingErrorLogs(true);
|
||||
setErrorLogLoadError('');
|
||||
|
||||
try {
|
||||
const items = await fetchErrorLogs(50);
|
||||
setErrorLogs(items);
|
||||
setSelectedErrorLogId((current) => current ?? items[0]?.id ?? null);
|
||||
} catch (error) {
|
||||
setErrorLogLoadError(error instanceof Error ? error.message : '에러 로그를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setIsLoadingErrorLogs(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'errors' || !hasAccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadErrorLogs();
|
||||
}, [activeView, hasAccess]);
|
||||
|
||||
const selectedErrorLog = useMemo(
|
||||
() => errorLogs.find((item) => item.id === selectedErrorLogId) ?? errorLogs[0] ?? null,
|
||||
[errorLogs, selectedErrorLogId],
|
||||
);
|
||||
const selectedErrorLogReferenceSummary = useMemo(
|
||||
() => (selectedErrorLog ? buildErrorReferenceSummary(selectedErrorLog) : null),
|
||||
[selectedErrorLog],
|
||||
);
|
||||
const activeErrorResource = useMemo(
|
||||
() =>
|
||||
selectedErrorLogReferenceSummary?.resources.find((resource) => resource.url === activeErrorResourceUrl)
|
||||
?? getDefaultErrorResource(selectedErrorLogReferenceSummary?.resources ?? []),
|
||||
[activeErrorResourceUrl, selectedErrorLogReferenceSummary],
|
||||
);
|
||||
const errorSourceSummary = useMemo(() => buildErrorSourceSummary(errorLogs), [errorLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextUrl = getDefaultErrorResource(selectedErrorLogReferenceSummary?.resources ?? [])?.url ?? '';
|
||||
setActiveErrorResourceUrl(nextUrl);
|
||||
}, [selectedErrorLog?.id, selectedErrorLogReferenceSummary]);
|
||||
|
||||
return {
|
||||
errorLogs,
|
||||
selectedErrorLog,
|
||||
selectedErrorLogId,
|
||||
selectedErrorLogReferenceSummary,
|
||||
activeErrorResource,
|
||||
errorSourceSummary,
|
||||
isLoadingErrorLogs,
|
||||
errorLogLoadError,
|
||||
activeErrorResourceUrl,
|
||||
isErrorDetailExpanded,
|
||||
setSelectedErrorLogId,
|
||||
setActiveErrorResourceUrl,
|
||||
setIsErrorDetailExpanded,
|
||||
loadErrorLogs,
|
||||
};
|
||||
}
|
||||
125
src/app/main/mainContent/windowLayout.ts
Executable file
125
src/app/main/mainContent/windowLayout.ts
Executable file
@@ -0,0 +1,125 @@
|
||||
import type { WindowFrame } from '../../../components/window';
|
||||
import type { PlanFilterStatus } from '../../../features/planBoard';
|
||||
|
||||
const WINDOW_LAYOUT_GAP = 18;
|
||||
const MIN_WINDOW_WIDTH = 360;
|
||||
const MIN_WINDOW_HEIGHT = 220;
|
||||
|
||||
export type WindowStageSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type WindowLayoutSelection = {
|
||||
instanceId: string;
|
||||
};
|
||||
|
||||
export const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui'];
|
||||
|
||||
export function getPlanStatusFromWindowSelection(selectionId: string): PlanFilterStatus | null {
|
||||
if (selectionId === 'page:plans:all') {
|
||||
return 'all';
|
||||
}
|
||||
|
||||
if (selectionId === 'plan-status:in-progress') {
|
||||
return 'in-progress';
|
||||
}
|
||||
|
||||
if (selectionId === 'plan-status:error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (selectionId === 'plan-status:done') {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDefaultWindowFrame(selectionId: string, index: number): WindowFrame {
|
||||
const isSampleSelection = selectionId.startsWith('component:') || selectionId.startsWith('widget:');
|
||||
|
||||
// Page windows need enough room for nested layouts, while sample windows start compact.
|
||||
if (selectionId.startsWith('page:')) {
|
||||
return {
|
||||
x: 20 + (index % 4) * 24,
|
||||
y: 16 + (index % 4) * 24,
|
||||
width: 960,
|
||||
height: 720,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSampleSelection) {
|
||||
return {
|
||||
x: 48 + (index % 5) * 24,
|
||||
y: 36 + (index % 5) * 24,
|
||||
width: 480,
|
||||
height: 360,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: 36 + (index % 5) * 28,
|
||||
y: 24 + (index % 5) * 28,
|
||||
width: 720,
|
||||
height: 520,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCascadeWindowFrames(
|
||||
selections: WindowLayoutSelection[],
|
||||
stageSize: WindowStageSize,
|
||||
): Record<string, WindowFrame> {
|
||||
// Keep the first window prominent and offset the rest without pushing them outside the stage.
|
||||
return Object.fromEntries(
|
||||
selections.map((selection, index) => {
|
||||
const width = Math.min(stageSize.width - 64, index === 0 ? 920 : 680);
|
||||
const height = Math.min(stageSize.height - 64, index === 0 ? 680 : 460);
|
||||
const x = Math.min(28 + (index % 6) * 36, Math.max(0, stageSize.width - width));
|
||||
const y = Math.min(24 + (index % 6) * 32, Math.max(0, stageSize.height - height));
|
||||
|
||||
return [
|
||||
selection.instanceId,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
width: Math.max(MIN_WINDOW_WIDTH, width),
|
||||
height: Math.max(MIN_WINDOW_HEIGHT, height),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGridWindowFrames(
|
||||
selections: WindowLayoutSelection[],
|
||||
stageSize: WindowStageSize,
|
||||
): Record<string, WindowFrame> {
|
||||
if (selections.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Square-ish grids preserve usable window sizes across mixed page and sample selections.
|
||||
const count = selections.length;
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const cellWidth = (stageSize.width - WINDOW_LAYOUT_GAP * (columns + 1)) / columns;
|
||||
const cellHeight = (stageSize.height - WINDOW_LAYOUT_GAP * (rows + 1)) / rows;
|
||||
|
||||
return Object.fromEntries(
|
||||
selections.map((selection, index) => {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
|
||||
return [
|
||||
selection.instanceId,
|
||||
{
|
||||
x: Math.max(0, WINDOW_LAYOUT_GAP + column * (cellWidth + WINDOW_LAYOUT_GAP)),
|
||||
y: Math.max(0, WINDOW_LAYOUT_GAP + row * (cellHeight + WINDOW_LAYOUT_GAP)),
|
||||
width: Math.max(MIN_WINDOW_WIDTH, cellWidth),
|
||||
height: Math.max(MIN_WINDOW_HEIGHT, cellHeight),
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
114
src/app/main/mainView/constants.tsx
Executable file
114
src/app/main/mainView/constants.tsx
Executable file
@@ -0,0 +1,114 @@
|
||||
import type { PlanFilterStatus, PlanQuickFilter } from '../../../features/planBoard';
|
||||
import type { PlanSidebarKey, PlaySidebarKey } from '../types';
|
||||
|
||||
export const PLAN_GROUP_LABEL = '작업';
|
||||
|
||||
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
all: '자동화 현황',
|
||||
'in-progress': '실행 중 (0)',
|
||||
done: '완료',
|
||||
error: '실패 (0)',
|
||||
};
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
};
|
||||
|
||||
export const PLAN_SIDEBAR_LABELS: Record<PlanSidebarKey, string> = {
|
||||
...PLAN_FILTER_LABELS,
|
||||
release: 'release (0)',
|
||||
'release-review': 'release 검수',
|
||||
board: '작업 요청',
|
||||
charts: '차트',
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'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 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> = {
|
||||
layout: 'Layout Editor',
|
||||
};
|
||||
|
||||
export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSidebarKey, string>> = {
|
||||
all: 'plan-menu-all',
|
||||
'in-progress': 'plan-menu-in-progress',
|
||||
release: 'plan-menu-release',
|
||||
'release-review': 'plan-menu-release-review',
|
||||
done: 'plan-menu-done',
|
||||
error: 'plan-menu-error',
|
||||
board: 'plan-menu-board',
|
||||
charts: 'plan-menu-charts',
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
export function resolvePlanOpenKeys() {
|
||||
return [...PLAN_BASE_OPEN_KEYS];
|
||||
}
|
||||
|
||||
export function resolvePlayOpenKeys() {
|
||||
return [...PLAY_BASE_OPEN_KEYS];
|
||||
}
|
||||
|
||||
export function resolveSavedLayoutMenuKey(layoutId: string): PlaySidebarKey {
|
||||
return `${PLAY_LAYOUT_RECORD_PREFIX}${layoutId}`;
|
||||
}
|
||||
|
||||
export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
|
||||
return key.startsWith(PLAY_LAYOUT_RECORD_PREFIX) ? key.slice(PLAY_LAYOUT_RECORD_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||
|
||||
if (selectedPlayMenu === 'layout') {
|
||||
return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
|
||||
}
|
||||
|
||||
return savedLayouts.find((record) => record.id === savedLayoutId)?.name ?? 'Saved Layout';
|
||||
}
|
||||
|
||||
export function renderPlanMenuLabel(menu: PlanSidebarKey, label: string) {
|
||||
const anchorId = PLAN_MENU_ANCHOR_IDS[menu];
|
||||
|
||||
if (!anchorId) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return <span id={anchorId}>{label}</span>;
|
||||
}
|
||||
|
||||
export function resolvePlanQuickFilterMenu(filter: PlanQuickFilter): Extract<PlanSidebarKey, 'in-progress' | 'release' | 'error'> {
|
||||
if (filter === 'working') {
|
||||
return 'in-progress';
|
||||
}
|
||||
|
||||
return filter === 'release-pending-main' ? 'release' : 'error';
|
||||
}
|
||||
|
||||
export function resolvePlanMenuState(menu: PlanSidebarKey) {
|
||||
if (menu === 'release') {
|
||||
return {
|
||||
quickFilter: 'release-pending-main' as PlanQuickFilter,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quickFilter: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDocsSectionLabel(section: string) {
|
||||
return DOCS_FOLDER_LABELS[section] ?? section;
|
||||
}
|
||||
5
src/app/main/mainView/index.ts
Executable file
5
src/app/main/mainView/index.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
export * from './constants';
|
||||
export * from './navigation';
|
||||
export * from './searchOptions';
|
||||
export * from './useMainViewData';
|
||||
export * from './utils';
|
||||
54
src/app/main/mainView/navigation.ts
Executable file
54
src/app/main/mainView/navigation.ts
Executable file
@@ -0,0 +1,54 @@
|
||||
import { PLAN_FILTER_STATUSES, type PlanFilterStatus } from '../../../features/planBoard';
|
||||
import type { PlanSidebarKey, PlaySidebarKey, TopMenuKey } from '../types';
|
||||
import { resolveSavedLayoutMenuKey } from './constants';
|
||||
|
||||
export type MainViewInitialNavigation = {
|
||||
activeTopMenu: TopMenuKey;
|
||||
selectedPlanMenu: PlanSidebarKey;
|
||||
selectedPlayMenu: PlaySidebarKey;
|
||||
initialSelectedPlanId: number | null;
|
||||
initialSelectedWorkId: string | null;
|
||||
};
|
||||
|
||||
export function resolveInitialNavigation(): MainViewInitialNavigation {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
activeTopMenu: 'plans',
|
||||
selectedPlanMenu: 'all',
|
||||
selectedPlayMenu: 'layout',
|
||||
initialSelectedPlanId: null,
|
||||
initialSelectedWorkId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const topMenuParam = params.get('topMenu');
|
||||
const requestedTopMenu: TopMenuKey =
|
||||
topMenuParam === 'docs' || topMenuParam === 'apis' || topMenuParam === 'plans' || topMenuParam === 'chat' || topMenuParam === 'play'
|
||||
? topMenuParam
|
||||
: 'plans';
|
||||
const planFilterParam = params.get('planFilter');
|
||||
const selectedPlanMenu =
|
||||
planFilterParam === 'release'
|
||||
? 'release'
|
||||
: planFilterParam && PLAN_FILTER_STATUSES.includes(planFilterParam as PlanFilterStatus)
|
||||
? (planFilterParam as PlanFilterStatus)
|
||||
: 'all';
|
||||
const planIdParam = params.get('planId');
|
||||
const parsedPlanId = planIdParam ? Number(planIdParam) : null;
|
||||
const playSectionParam = params.get('playSection');
|
||||
const playLayoutIdParam = params.get('playLayoutId');
|
||||
|
||||
return {
|
||||
activeTopMenu: requestedTopMenu,
|
||||
selectedPlanMenu,
|
||||
selectedPlayMenu:
|
||||
playSectionParam === 'layout'
|
||||
? 'layout'
|
||||
: playSectionParam === 'layout-record' && playLayoutIdParam
|
||||
? resolveSavedLayoutMenuKey(playLayoutIdParam)
|
||||
: 'layout',
|
||||
initialSelectedPlanId: Number.isFinite(parsedPlanId) ? parsedPlanId : null,
|
||||
initialSelectedWorkId: params.get('workId'),
|
||||
};
|
||||
}
|
||||
226
src/app/main/mainView/searchOptions.ts
Executable file
226
src/app/main/mainView/searchOptions.ts
Executable file
@@ -0,0 +1,226 @@
|
||||
import type { SearchKeywordOption } from '../../../components/search';
|
||||
import type { LoadedSampleEntry } from '../../../samples/registry';
|
||||
import type { ChatSidebarKey, PlanSidebarKey, TopMenuKey } from '../types';
|
||||
import { compactKeywords, scrollToElement } from './utils';
|
||||
import { PLAN_FILTER_LABELS, PLAN_GROUP_LABEL, PLAN_SIDEBAR_LABELS } from './constants';
|
||||
|
||||
type SearchOptionBuilderParams = {
|
||||
componentSamples: LoadedSampleEntry[];
|
||||
widgetSamples: LoadedSampleEntry[];
|
||||
docFolders: string[];
|
||||
docsDocuments: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
preview?: string;
|
||||
}>;
|
||||
hasAccess: boolean;
|
||||
setActiveTopMenu: (menu: TopMenuKey) => void;
|
||||
setSelectedApiMenu: (key: string) => void;
|
||||
setSelectedDocsMenu: (key: string) => void;
|
||||
setSelectedPlanMenu: (key: PlanSidebarKey) => void;
|
||||
setSelectedChatMenu: (key: ChatSidebarKey) => void;
|
||||
setFocusedComponentId: (value: string | null) => void;
|
||||
};
|
||||
|
||||
export function buildMainViewSearchOptions({
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docFolders,
|
||||
docsDocuments,
|
||||
hasAccess,
|
||||
setActiveTopMenu,
|
||||
setSelectedApiMenu,
|
||||
setSelectedDocsMenu,
|
||||
setSelectedPlanMenu,
|
||||
setSelectedChatMenu,
|
||||
setFocusedComponentId,
|
||||
}: SearchOptionBuilderParams): SearchKeywordOption[] {
|
||||
const onSelectWindow = () => {
|
||||
setFocusedComponentId(null);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'page:apis:components',
|
||||
label: 'APIs / Components',
|
||||
group: 'Page',
|
||||
keywords: ['api', 'components', '컴포넌트'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('apis');
|
||||
setSelectedApiMenu('components');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:apis:widgets',
|
||||
label: 'APIs / Widgets',
|
||||
group: 'Page',
|
||||
keywords: ['api', 'widgets', '위젯'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('apis');
|
||||
setSelectedApiMenu('widgets');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:all',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_FILTER_LABELS.all}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'automation', '자동화', '실행', '현황', 'worker'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('all');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:board',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS.board}`,
|
||||
group: 'Page',
|
||||
keywords: ['plans', 'plan', '플랜', '게시판', 'board', '요청', '작업 요청', '접수', '계획'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('board');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:plans:release-review',
|
||||
label: `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS['release-review']}`,
|
||||
group: 'Page',
|
||||
keywords: ['release', 'review', '검수', '릴리즈', 'release 검수'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('plans');
|
||||
setSelectedPlanMenu('release-review');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(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:chat:live',
|
||||
label: 'Codex Live / Codex Live',
|
||||
group: 'Page',
|
||||
keywords: ['codex live', 'chat', 'codex', '채팅', '대화', '메시지'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('chat');
|
||||
setSelectedChatMenu('live');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
{
|
||||
id: 'page:chat:errors',
|
||||
label: '앱로그 / 에러 로그',
|
||||
group: 'Page',
|
||||
keywords: ['app log', 'applog', '앱로그', 'error', 'errors', '에러', '로그'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('chat');
|
||||
setSelectedChatMenu('errors');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
id: 'page:chat:manage',
|
||||
label: '채팅 관리 / 유형 권한 관리',
|
||||
group: 'Page',
|
||||
keywords: ['chat manage', 'chat type', 'permission', '권한', '채팅 유형', '채팅 관리'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('chat');
|
||||
setSelectedChatMenu('manage');
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
} satisfies SearchKeywordOption,
|
||||
]
|
||||
: []),
|
||||
...docFolders.map((folder) => ({
|
||||
id: `docs-folder:${folder}`,
|
||||
label: `Docs / ${folder}`,
|
||||
group: 'Docs Folder',
|
||||
keywords: [folder, 'docs', '문서'],
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('docs');
|
||||
setSelectedDocsMenu(folder);
|
||||
setFocusedComponentId(null);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...docsDocuments.map((document) => ({
|
||||
id: `doc:${document.id}`,
|
||||
label: document.title,
|
||||
group: `Docs / ${document.folder}`,
|
||||
keywords: [document.folder, document.id, document.title],
|
||||
description: document.preview,
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('docs');
|
||||
setSelectedDocsMenu(document.folder);
|
||||
setFocusedComponentId(`doc:${document.id}`);
|
||||
scrollToElement(`document-preview-${document.id}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...componentSamples.map((entry) => ({
|
||||
id: `component:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
label: entry.sampleMeta.title,
|
||||
group: 'Component',
|
||||
keywords: compactKeywords([
|
||||
entry.sampleMeta.componentId,
|
||||
entry.sampleMeta.id,
|
||||
entry.sampleMeta.category,
|
||||
entry.sampleMeta.kind,
|
||||
entry.sampleMeta.variantLabel ?? '',
|
||||
]),
|
||||
description: entry.sampleMeta.description,
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('apis');
|
||||
setSelectedApiMenu('components');
|
||||
setFocusedComponentId(`component:${entry.sampleMeta.componentId}`);
|
||||
scrollToElement(`component-sample-${entry.sampleMeta.componentId}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
...widgetSamples.map((entry) => ({
|
||||
id: `widget:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`,
|
||||
label: entry.sampleMeta.title,
|
||||
group: 'Widget',
|
||||
keywords: compactKeywords([
|
||||
entry.sampleMeta.componentId,
|
||||
entry.sampleMeta.id,
|
||||
entry.sampleMeta.category,
|
||||
entry.sampleMeta.kind,
|
||||
entry.sampleMeta.variantLabel ?? '',
|
||||
]),
|
||||
description: entry.sampleMeta.description,
|
||||
onSelect: () => {
|
||||
setActiveTopMenu('apis');
|
||||
setSelectedApiMenu('widgets');
|
||||
setFocusedComponentId(`widget:${entry.sampleMeta.componentId}`);
|
||||
},
|
||||
onSelectWindow,
|
||||
})),
|
||||
];
|
||||
}
|
||||
85
src/app/main/mainView/useMainViewData.ts
Executable file
85
src/app/main/mainView/useMainViewData.ts
Executable file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { resolveMarkdownDocuments } from '../../../components/markdownPreview';
|
||||
import { resolveSampleEntries, type LoadedSampleEntry } from '../../../samples/registry';
|
||||
import { listSavedLayouts, type SavedLayoutRecord } from '../../../views/play/layoutStorage';
|
||||
import { docsMarkdownEntries } from '../../manifests/docs.manifest';
|
||||
import { componentSampleEntries, widgetSampleEntries } from '../../manifests/samples.manifest';
|
||||
import { DOCS_DEFAULT_FOLDER } from './constants';
|
||||
|
||||
export function useMainViewData() {
|
||||
const [componentSamples, setComponentSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [widgetSamples, setWidgetSamples] = useState<LoadedSampleEntry[]>([]);
|
||||
const [docsDocuments, setDocsDocuments] = useState<Awaited<ReturnType<typeof resolveMarkdownDocuments>>>([]);
|
||||
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void resolveMarkdownDocuments(docsMarkdownEntries, '/docs/').then((documents) => {
|
||||
if (mounted) {
|
||||
setDocsDocuments(documents);
|
||||
}
|
||||
});
|
||||
|
||||
void resolveSampleEntries(componentSampleEntries, '/components/').then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setComponentSamples(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
void resolveSampleEntries(widgetSampleEntries, '/widgets/').then((loadedEntries) => {
|
||||
if (mounted) {
|
||||
setWidgetSamples(loadedEntries);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
void listSavedLayouts()
|
||||
.then((layouts) => {
|
||||
if (mounted) {
|
||||
setSavedLayouts(layouts);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) {
|
||||
setSavedLayouts([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const docFolders = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(docsDocuments.map((document) => document.folder))).sort((left, right) => {
|
||||
if (left === DOCS_DEFAULT_FOLDER) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (right === DOCS_DEFAULT_FOLDER) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return left.localeCompare(right);
|
||||
}),
|
||||
[docsDocuments],
|
||||
);
|
||||
|
||||
return {
|
||||
componentSamples,
|
||||
widgetSamples,
|
||||
docsDocuments,
|
||||
savedLayouts,
|
||||
setSavedLayouts,
|
||||
docFolders,
|
||||
};
|
||||
}
|
||||
60
src/app/main/mainView/utils.ts
Executable file
60
src/app/main/mainView/utils.ts
Executable file
@@ -0,0 +1,60 @@
|
||||
export function isTypingTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(target.closest('input, textarea, select, [contenteditable="true"], .ant-input, .ant-select'));
|
||||
}
|
||||
|
||||
export function matchesShortcut(event: KeyboardEvent, shortcut: string) {
|
||||
const tokens = shortcut
|
||||
.split('+')
|
||||
.map((token) => token.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectsCtrl = tokens.includes('ctrl') || tokens.includes('control');
|
||||
const expectsMeta = tokens.includes('meta') || tokens.includes('cmd') || tokens.includes('command');
|
||||
const expectsShift = tokens.includes('shift');
|
||||
const expectsAlt = tokens.includes('alt') || tokens.includes('option');
|
||||
const expectsMod = tokens.includes('mod');
|
||||
const keyToken = tokens.find((token) => !['ctrl', 'control', 'meta', 'cmd', 'command', 'shift', 'alt', 'option', 'mod'].includes(token));
|
||||
|
||||
if (event.ctrlKey !== expectsCtrl && !(expectsMod && event.ctrlKey && !event.metaKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.metaKey !== expectsMeta && !(expectsMod && event.metaKey && !event.ctrlKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.shiftKey !== expectsShift || event.altKey !== expectsAlt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expectsCtrl && !expectsMeta && !expectsMod && (event.ctrlKey || event.metaKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!keyToken) {
|
||||
return expectsCtrl || expectsMeta || expectsMod || expectsShift || expectsAlt;
|
||||
}
|
||||
|
||||
return event.key.toLowerCase() === keyToken;
|
||||
}
|
||||
|
||||
export function scrollToElement(elementId: string) {
|
||||
window.setTimeout(() => {
|
||||
document.getElementById(elementId)?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
|
||||
export function compactKeywords(values: Array<string | undefined>) {
|
||||
return values.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
813
src/app/main/notificationApi.ts
Executable file
813
src/app/main/notificationApi.ts
Executable file
@@ -0,0 +1,813 @@
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
function resolveNotificationApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
const NOTIFICATION_API_BASE_URL = resolveNotificationApiBaseUrl();
|
||||
const NOTIFICATION_API_REQUEST_TIMEOUT_MS = 8000;
|
||||
const NOTIFICATION_MESSAGES_CACHE_WINDOW_MS = 1500;
|
||||
type NotificationMessagesResponse = {
|
||||
ok: boolean;
|
||||
items: NotificationMessageItem[];
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
const notificationMessagesCache = new Map<
|
||||
string,
|
||||
{
|
||||
fetchedAt: number;
|
||||
value: NotificationMessagesResponse | null;
|
||||
promise: Promise<NotificationMessagesResponse> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
function resolveNotificationApiFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
const isLocalWorkServerHost =
|
||||
hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0';
|
||||
|
||||
if (!isLocalWorkServerHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const NOTIFICATION_API_FALLBACK_BASE_URL =
|
||||
!import.meta.env.VITE_WORK_SERVER_URL && NOTIFICATION_API_BASE_URL === '/api'
|
||||
? resolveNotificationApiFallbackBaseUrl()
|
||||
: null;
|
||||
|
||||
class NotificationApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'NotificationApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export type WebPushSubscriptionPayload = {
|
||||
endpoint: string;
|
||||
expirationTime?: number | null;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientNotificationPayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
data?: Record<string, string>;
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type ClientNotificationSendResult = {
|
||||
ok: boolean;
|
||||
ios: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
web: {
|
||||
ok: boolean;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
sentCount: number;
|
||||
failedCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type PwaNotificationTokenPayload = {
|
||||
token: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
|
||||
export type NotificationMessagePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type NotificationMessageListStatus = 'all' | 'unread';
|
||||
export const NOTIFICATION_MESSAGES_UPDATED_EVENT = 'work-server.notification-messages-updated';
|
||||
|
||||
export type NotificationMessageItem = {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
preview: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: NotificationMessagePriority;
|
||||
read: boolean;
|
||||
readAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationMessagePayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
priority?: NotificationMessagePriority;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type NotificationMessageRow = {
|
||||
id: number | string;
|
||||
title: string;
|
||||
body: string;
|
||||
category: string;
|
||||
source: string;
|
||||
priority: NotificationMessagePriority | string;
|
||||
is_read: boolean;
|
||||
read_at: string | null;
|
||||
metadata_json: Record<string, unknown> | string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const NOTIFICATION_MESSAGE_TABLE = 'notification_messages';
|
||||
|
||||
let notificationMessageTableSetupPromise: Promise<void> | null = null;
|
||||
|
||||
function emitNotificationMessagesUpdated() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(NOTIFICATION_MESSAGES_UPDATED_EVENT));
|
||||
}
|
||||
|
||||
function createNotificationPreview(body: string) {
|
||||
const normalized = body
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function mapNotificationMessageRow(row: NotificationMessageRow): NotificationMessageItem {
|
||||
const body = String(row.body ?? '');
|
||||
let metadata: Record<string, unknown> = {};
|
||||
|
||||
if (typeof row.metadata_json === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(row.metadata_json) as Record<string, unknown>;
|
||||
metadata = parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
metadata = {};
|
||||
}
|
||||
} else if (row.metadata_json && typeof row.metadata_json === 'object') {
|
||||
metadata = row.metadata_json;
|
||||
}
|
||||
|
||||
const metadataPreview =
|
||||
typeof metadata.previewText === 'string'
|
||||
? metadata.previewText
|
||||
: typeof metadata.listPreviewText === 'string'
|
||||
? metadata.listPreviewText
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: Number(row.id ?? 0),
|
||||
title: String(row.title ?? ''),
|
||||
body,
|
||||
preview: createNotificationPreview(metadataPreview || body),
|
||||
category: String(row.category ?? 'general'),
|
||||
source: String(row.source ?? 'system'),
|
||||
priority:
|
||||
row.priority === 'low' || row.priority === 'high' || row.priority === 'urgent' ? row.priority : 'normal',
|
||||
read: Boolean(row.is_read),
|
||||
readAt: row.read_at ?? null,
|
||||
metadata,
|
||||
createdAt: String(row.created_at ?? ''),
|
||||
updatedAt: String(row.updated_at ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureNotificationMessageTableViaCrud() {
|
||||
if (!notificationMessageTableSetupPromise) {
|
||||
notificationMessageTableSetupPromise = (async () => {
|
||||
const tables = await request<{ items: Array<{ table_name: string }> }>('/schema/tables');
|
||||
const hasTable = tables.items.some((item) => item.table_name === NOTIFICATION_MESSAGE_TABLE);
|
||||
|
||||
if (hasTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
await request('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `
|
||||
create table if not exists ${NOTIFICATION_MESSAGE_TABLE} (
|
||||
id integer generated by default as identity primary key,
|
||||
title varchar(200) not null,
|
||||
body text not null,
|
||||
category varchar(60) not null default 'general',
|
||||
source varchar(80) not null default 'system',
|
||||
priority varchar(20) not null default 'normal',
|
||||
is_read boolean not null default false,
|
||||
read_at timestamptz null,
|
||||
metadata_json jsonb not null default '{}'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
`,
|
||||
}),
|
||||
});
|
||||
})().catch((error) => {
|
||||
notificationMessageTableSetupPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return notificationMessageTableSetupPromise;
|
||||
}
|
||||
|
||||
async function fetchNotificationMessagesViaCrud(params?: {
|
||||
status?: NotificationMessageListStatus;
|
||||
limit?: number;
|
||||
}) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
|
||||
const limit = typeof params?.limit === 'number' ? Math.min(100, Math.max(1, Math.round(params.limit))) : 20;
|
||||
const where =
|
||||
params?.status === 'unread'
|
||||
? [
|
||||
{
|
||||
field: 'is_read',
|
||||
operator: 'eq' as const,
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const [itemsResponse, unreadCountResponse] = await Promise.all([
|
||||
request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
where,
|
||||
orderBy: [
|
||||
{ field: 'is_read', direction: 'asc' },
|
||||
{ field: 'created_at', direction: 'desc' },
|
||||
{ field: 'id', direction: 'desc' },
|
||||
],
|
||||
limit,
|
||||
}),
|
||||
}),
|
||||
request<{
|
||||
result?: {
|
||||
rows?: Array<{ count?: string | number }>;
|
||||
};
|
||||
}>('/ddl/raw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sql: `select count(*)::int as count from ${NOTIFICATION_MESSAGE_TABLE} where is_read = false;`,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
items: itemsResponse.rows.map((row) => mapNotificationMessageRow(row)),
|
||||
unreadCount: Number(unreadCountResponse.result?.rows?.[0]?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNotificationMessageViaCrud(id: number) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/select`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
limit: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
return mapNotificationMessageRow(row);
|
||||
}
|
||||
|
||||
async function createNotificationMessageViaCrud(payload: CreateNotificationMessagePayload) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/insert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
rows: [
|
||||
{
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
category: payload.category ?? 'general',
|
||||
source: payload.source ?? 'system',
|
||||
priority: payload.priority ?? 'normal',
|
||||
metadata_json: payload.metadata ?? {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('알림 메시지를 저장하지 못했습니다.', 500);
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessagesUpdated();
|
||||
return item;
|
||||
}
|
||||
|
||||
async function updateNotificationMessageReadStateViaCrud(id: number, read: boolean) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows: NotificationMessageRow[];
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/update`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
is_read: read,
|
||||
read_at: read ? new Date().toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
|
||||
const row = response.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new NotificationApiError('상태를 변경할 알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
const item = mapNotificationMessageRow(row);
|
||||
emitNotificationMessagesUpdated();
|
||||
return item;
|
||||
}
|
||||
|
||||
async function deleteNotificationMessageViaCrud(id: number) {
|
||||
await ensureNotificationMessageTableViaCrud();
|
||||
const response = await request<{
|
||||
ok: boolean;
|
||||
rows?: Array<{ id?: number | string }>;
|
||||
deleted?: number;
|
||||
}>(`/crud/${NOTIFICATION_MESSAGE_TABLE}/delete`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
where: [{ field: 'id', operator: 'eq', value: id }],
|
||||
}),
|
||||
});
|
||||
|
||||
const deletedCount =
|
||||
typeof response.deleted === 'number'
|
||||
? response.deleted
|
||||
: Array.isArray(response.rows)
|
||||
? response.rows.length
|
||||
: 0;
|
||||
|
||||
if (!deletedCount) {
|
||||
throw new NotificationApiError('삭제할 알림 메시지를 찾을 수 없습니다.', 404);
|
||||
}
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
}
|
||||
|
||||
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const method = init?.method?.toUpperCase() ?? 'GET';
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), NOTIFICATION_API_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (method === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
throw new NotificationApiError('알림 서버 응답이 지연됩니다.', 408);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
throw new NotificationApiError(payload.message || '알림 요청 처리에 실패했습니다.', response.status);
|
||||
} catch {
|
||||
throw new NotificationApiError(text || '알림 요청 처리에 실패했습니다.', response.status);
|
||||
}
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
try {
|
||||
return await requestOnce<T>(NOTIFICATION_API_BASE_URL, path, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
NOTIFICATION_API_FALLBACK_BASE_URL &&
|
||||
NOTIFICATION_API_FALLBACK_BASE_URL !== NOTIFICATION_API_BASE_URL &&
|
||||
(error instanceof NotificationApiError
|
||||
? error.status === 404 || error.status === 408 || error.status === 502
|
||||
: error instanceof Error && /404|not found|Failed to fetch|Load failed|NetworkError/i.test(error.message));
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestOnce<T>(NOTIFICATION_API_FALLBACK_BASE_URL, path, init);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWebPushConfig() {
|
||||
try {
|
||||
return await request<{ enabled: boolean; publicKey: string }>('/notifications/webpush/config');
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return {
|
||||
enabled: false,
|
||||
publicKey: '',
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNotificationMessages(params?: {
|
||||
status?: NotificationMessageListStatus;
|
||||
limit?: number;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params?.status) {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
|
||||
if (typeof params?.limit === 'number') {
|
||||
searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
const cacheKey = query || '__default__';
|
||||
const cachedEntry = notificationMessagesCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
|
||||
if (cachedEntry?.value && now - cachedEntry.fetchedAt < NOTIFICATION_MESSAGES_CACHE_WINDOW_MS) {
|
||||
return cachedEntry.value;
|
||||
}
|
||||
|
||||
if (cachedEntry?.promise) {
|
||||
return cachedEntry.promise;
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
const response = await request<NotificationMessagesResponse>(`/notifications/messages${query ? `?${query}` : ''}`);
|
||||
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: Date.now(),
|
||||
value: response,
|
||||
promise: null,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
const response = await fetchNotificationMessagesViaCrud(params);
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: Date.now(),
|
||||
value: response,
|
||||
promise: null,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
const currentEntry = notificationMessagesCache.get(cacheKey);
|
||||
|
||||
if (currentEntry?.promise) {
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: currentEntry.fetchedAt,
|
||||
value: currentEntry.value,
|
||||
promise: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
notificationMessagesCache.set(cacheKey, {
|
||||
fetchedAt: cachedEntry?.fetchedAt ?? 0,
|
||||
value: cachedEntry?.value ?? null,
|
||||
promise: requestPromise,
|
||||
});
|
||||
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
export async function fetchNotificationMessage(id: number) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>(`/notifications/messages/${id}`);
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return fetchNotificationMessageViaCrud(id);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNotificationMessage(payload: CreateNotificationMessagePayload) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>('/notifications/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
category: payload.category ?? 'general',
|
||||
source: payload.source ?? 'system',
|
||||
priority: payload.priority ?? 'normal',
|
||||
metadata: payload.metadata ?? {},
|
||||
}),
|
||||
});
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return createNotificationMessageViaCrud(payload);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateNotificationMessageReadState(id: number, read: boolean) {
|
||||
try {
|
||||
const response = await request<{ ok: boolean; item: NotificationMessageItem }>(`/notifications/messages/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
read,
|
||||
}),
|
||||
});
|
||||
|
||||
emitNotificationMessagesUpdated();
|
||||
return response.item;
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return updateNotificationMessageReadStateViaCrud(id, read);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotificationMessage(id: number) {
|
||||
try {
|
||||
await request<{ ok: boolean; deleted: boolean }>(`/notifications/messages/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
emitNotificationMessagesUpdated();
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationApiError && error.status === 404) {
|
||||
return deleteNotificationMessageViaCrud(id);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getNotificationMetadataText(metadata: Record<string, unknown> | null | undefined, key: string) {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = metadata[key];
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export async function markChatNotificationMessagesAsRead(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const response = await fetchNotificationMessages({
|
||||
status: 'unread',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const targetIds = response.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.read !== true &&
|
||||
item.category === 'chat' &&
|
||||
getNotificationMetadataText(item.metadata, 'sessionId') === normalizedSessionId,
|
||||
)
|
||||
.map((item) => item.id);
|
||||
|
||||
if (targetIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await Promise.allSettled(targetIds.map((id) => updateNotificationMessageReadState(id, true)));
|
||||
return targetIds.length;
|
||||
}
|
||||
|
||||
export async function dismissChatWebPushNotifications(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.ready);
|
||||
const notifications = await registration?.getNotifications();
|
||||
|
||||
if (!notifications || notifications.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let dismissedCount = 0;
|
||||
|
||||
notifications.forEach((notification) => {
|
||||
const notificationData =
|
||||
notification.data && typeof notification.data === 'object'
|
||||
? (notification.data as Record<string, unknown>)
|
||||
: null;
|
||||
const notificationCategory = getNotificationMetadataText(notificationData, 'category');
|
||||
const notificationType = getNotificationMetadataText(notificationData, 'type');
|
||||
const notificationThreadId = getNotificationMetadataText(notificationData, 'threadId');
|
||||
|
||||
if (
|
||||
(
|
||||
notificationCategory === 'chat' ||
|
||||
notificationType.startsWith('chat') ||
|
||||
notificationThreadId.startsWith('chat:')
|
||||
) &&
|
||||
getNotificationMetadataText(notificationData, 'sessionId') === normalizedSessionId
|
||||
) {
|
||||
notification.close();
|
||||
dismissedCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return dismissedCount;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerWebPushSubscription(
|
||||
subscription: WebPushSubscriptionPayload,
|
||||
deviceId?: string,
|
||||
) {
|
||||
return request<{ ok: boolean; endpoint: string }>('/notifications/subscriptions/web', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
subscription,
|
||||
deviceId,
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterWebPushSubscription(endpoint: string) {
|
||||
return request<{ ok: boolean; endpoint: string; removed: boolean }>('/notifications/subscriptions/web', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
endpoint,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerPwaNotificationToken(payload: PwaNotificationTokenPayload) {
|
||||
return request<{ ok: boolean; token: string }>('/notifications/tokens/ios', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceId: payload.deviceId,
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unregisterPwaNotificationToken(token: string) {
|
||||
return request<{ ok: boolean; token: string; removed: boolean }>('/notifications/tokens/ios', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldFallbackToLocalNotification(result: ClientNotificationSendResult) {
|
||||
return (
|
||||
result.web.skipped === true ||
|
||||
result.web.ok !== true ||
|
||||
result.web.sentCount < 1 ||
|
||||
result.web.failedCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function showLocalClientNotification(payload: ClientNotificationPayload) {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof Notification === 'undefined' ||
|
||||
Notification.permission !== 'granted'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notificationOptions = {
|
||||
body: payload.body,
|
||||
data: payload.data ?? {},
|
||||
tag: payload.threadId ?? payload.data?.notificationKey ?? undefined,
|
||||
badge: '/pwa-192x192.svg',
|
||||
icon: '/pwa-192x192.svg',
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
|
||||
const registration = (await navigator.serviceWorker.getRegistration()) ?? (await navigator.serviceWorker.ready);
|
||||
|
||||
if (registration?.showNotification) {
|
||||
await registration.showNotification(payload.title, notificationOptions);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the Notification constructor below.
|
||||
}
|
||||
|
||||
try {
|
||||
new Notification(payload.title, notificationOptions);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendClientNotification(payload: ClientNotificationPayload) {
|
||||
return request<ClientNotificationSendResult>('/notifications/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
95
src/app/main/notificationIdentity.ts
Executable file
95
src/app/main/notificationIdentity.ts
Executable file
@@ -0,0 +1,95 @@
|
||||
import { clearClientId, getOrCreateClientId } from './clientIdentity';
|
||||
|
||||
export const NOTIFICATION_DEVICE_ID_STORAGE_KEY = 'work-server.notification.device-id';
|
||||
export const PWA_NOTIFICATION_TOKEN_STORAGE_KEY = 'work-server.notification.pwa-token';
|
||||
|
||||
export type AutomationNotificationPreferenceTarget = {
|
||||
targetKind: 'client' | 'ios-token' | 'ios-token-client';
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export function buildScopedPwaNotificationTargetId(token: string, clientId: string) {
|
||||
return [token.trim(), clientId.trim()].filter(Boolean).join('::client::');
|
||||
}
|
||||
|
||||
export function getSavedNotificationDeviceId() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const clientId = getOrCreateClientId();
|
||||
if (clientId) {
|
||||
window.localStorage.setItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY, clientId);
|
||||
return clientId;
|
||||
}
|
||||
|
||||
const saved = window.localStorage.getItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY);
|
||||
|
||||
if (saved) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
const generated = `web-${Date.now()}`;
|
||||
window.localStorage.setItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY, generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
export function getSavedPwaNotificationToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY)?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function setSavedPwaNotificationToken(token: string | null | undefined) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedToken = token?.trim() ?? '';
|
||||
|
||||
if (normalizedToken) {
|
||||
window.localStorage.setItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY, normalizedToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearNotificationIdentity() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(NOTIFICATION_DEVICE_ID_STORAGE_KEY);
|
||||
window.localStorage.removeItem(PWA_NOTIFICATION_TOKEN_STORAGE_KEY);
|
||||
clearClientId();
|
||||
}
|
||||
|
||||
export function getAutomationNotificationPreferenceTarget(): AutomationNotificationPreferenceTarget | null {
|
||||
const pwaToken = getSavedPwaNotificationToken();
|
||||
const clientId = getSavedNotificationDeviceId();
|
||||
|
||||
if (pwaToken && clientId) {
|
||||
return {
|
||||
targetKind: 'ios-token-client',
|
||||
targetId: buildScopedPwaNotificationTargetId(pwaToken, clientId),
|
||||
};
|
||||
}
|
||||
|
||||
if (pwaToken) {
|
||||
return {
|
||||
targetKind: 'ios-token',
|
||||
targetId: pwaToken,
|
||||
};
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
targetKind: 'client',
|
||||
targetId: clientId,
|
||||
};
|
||||
}
|
||||
33
src/app/main/pages/ApisPage.tsx
Executable file
33
src/app/main/pages/ApisPage.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import { Card, Typography } from 'antd';
|
||||
import { ComponentSamplesLayout } from '../../../features/layout/component-sample-gallery';
|
||||
import { SampleWidgetsLayout } from '../../../features/layout/widget-sample-gallery';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui'];
|
||||
|
||||
export function ApisPage() {
|
||||
const { selectedApiMenu, componentSampleEntries, widgetSampleEntries } = useMainLayoutContext();
|
||||
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<Card
|
||||
title={selectedApiMenu === 'components' ? 'APIs / Components' : 'APIs / Widgets'}
|
||||
className="app-main-card"
|
||||
bordered={false}
|
||||
>
|
||||
<Paragraph className="app-main-copy">
|
||||
{selectedApiMenu === 'components'
|
||||
? '공통 UI 컴포넌트 샘플과 확장 샘플을 확인합니다.'
|
||||
: '공통 위젯 샘플을 확인합니다.'}
|
||||
</Paragraph>
|
||||
|
||||
{selectedApiMenu === 'components' ? (
|
||||
<ComponentSamplesLayout entries={componentSampleEntries} excludeComponentIds={HIDDEN_COMPONENT_IDS} />
|
||||
) : (
|
||||
<SampleWidgetsLayout entries={widgetSampleEntries} />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/app/main/pages/ChatPage.tsx
Executable file
20
src/app/main/pages/ChatPage.tsx
Executable file
@@ -0,0 +1,20 @@
|
||||
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
|
||||
import { MainChatPanel } from '../MainChatPanel';
|
||||
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
|
||||
export function ChatPage() {
|
||||
const { selectedChatMenu } = useMainLayoutContext();
|
||||
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
{selectedChatMenu === 'manage' ? (
|
||||
<ChatTypeManagementPage />
|
||||
) : selectedChatMenu === 'changes' ? (
|
||||
<ChatSourceChangesPage />
|
||||
) : (
|
||||
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/app/main/pages/DocsPage.tsx
Executable file
29
src/app/main/pages/DocsPage.tsx
Executable file
@@ -0,0 +1,29 @@
|
||||
import { Card, Space, Typography } from 'antd';
|
||||
import { MarkdownPreviewCard } from '../../../components/markdownPreview';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
import { getDocsSectionLabel } from '../routes';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function DocsPage() {
|
||||
const { selectedDocsMenu, selectedDocs } = useMainLayoutContext();
|
||||
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<Card
|
||||
title={`Docs / ${getDocsSectionLabel(selectedDocsMenu)}`}
|
||||
extra={<Text code>{selectedDocs.length} docs</Text>}
|
||||
className="app-main-card"
|
||||
bordered={false}
|
||||
>
|
||||
<Space direction="vertical" size={16} className="app-main-stack">
|
||||
{selectedDocs.map((document) => (
|
||||
<div key={document.id} id={`document-preview-${document.id}`} data-focus-id={`doc:${document.id}`}>
|
||||
<MarkdownPreviewCard document={document} />
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/app/main/pages/PlansPage.tsx
Executable file
75
src/app/main/pages/PlansPage.tsx
Executable file
@@ -0,0 +1,75 @@
|
||||
import { BoardPage } from '../../../features/board';
|
||||
import { HistoryPage } from '../../../features/history';
|
||||
import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../../features/planBoard';
|
||||
import { ServerCommandPage } from '../../../features/serverCommand';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
|
||||
export function PlansPage() {
|
||||
const {
|
||||
selectedPlanMenu,
|
||||
activePlanQuickFilter,
|
||||
planQuickFilterRequestKey,
|
||||
initialSelectedPlanId,
|
||||
initialSelectedWorkId,
|
||||
} = useMainLayoutContext();
|
||||
|
||||
if (selectedPlanMenu === 'board') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<BoardPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'charts') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<PlanBoardChartsPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'schedule') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<PlanSchedulePage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'release-review') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<ReleaseReviewPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'history') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<HistoryPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPlanMenu === 'server-command') {
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<ServerCommandPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-main-panel">
|
||||
<PlanBoardPage
|
||||
statusFilter={selectedPlanMenu === 'release' ? 'done' : selectedPlanMenu}
|
||||
quickFilter={activePlanQuickFilter}
|
||||
quickFilterRequestKey={planQuickFilterRequestKey}
|
||||
initialSelectedPlanId={initialSelectedPlanId}
|
||||
initialSelectedWorkId={initialSelectedWorkId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/app/main/pages/PlayPage.tsx
Executable file
17
src/app/main/pages/PlayPage.tsx
Executable file
@@ -0,0 +1,17 @@
|
||||
import { LayoutPlaygroundView } from '../../../views/play/LayoutPlaygroundView';
|
||||
import { useMainLayoutContext } from '../layout/MainLayoutContext';
|
||||
import { resolveSavedLayoutIdFromMenuKey } from '../routes';
|
||||
|
||||
export function PlayPage() {
|
||||
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
|
||||
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||
|
||||
return (
|
||||
<div className="app-main-panel app-main-panel--play">
|
||||
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
|
||||
{selectedSavedLayoutId ? (
|
||||
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
382
src/app/main/routes.tsx
Executable file
382
src/app/main/routes.tsx
Executable file
@@ -0,0 +1,382 @@
|
||||
import { AppstoreOutlined, FileMarkdownOutlined, MessageOutlined, ProfileOutlined } from '@ant-design/icons';
|
||||
import { Badge } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { PlanFilterStatus } from '../../features/planBoard';
|
||||
|
||||
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
||||
export type HeaderTopMenuKey = 'docs' | 'plans' | 'play';
|
||||
export type ApiSectionKey = 'components' | 'widgets';
|
||||
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
|
||||
export type PlaySectionKey = 'layout';
|
||||
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
|
||||
|
||||
export const DOCS_DEFAULT_FOLDER = 'worklogs';
|
||||
export const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const;
|
||||
export const PLAN_MENU_STATUS_ORDER: PlanFilterStatus[] = ['all', 'in-progress', 'error', 'done'];
|
||||
export const PLAN_GROUP_LABEL = '작업';
|
||||
|
||||
export const DOCS_FOLDER_LABELS: Record<string, string> = {
|
||||
worklogs: '작업일지',
|
||||
features: '기능문서',
|
||||
components: '컴포넌트문서',
|
||||
templates: '문서템플릿',
|
||||
};
|
||||
|
||||
export const PLAN_FILTER_LABELS: Record<PlanFilterStatus, string> = {
|
||||
all: '자동화 현황',
|
||||
'in-progress': '실행 중 (0)',
|
||||
done: '완료',
|
||||
error: '실패 (0)',
|
||||
};
|
||||
|
||||
export const PLAN_SIDEBAR_LABELS: Record<PlanSectionKey, string> = {
|
||||
...PLAN_FILTER_LABELS,
|
||||
release: 'release (0)',
|
||||
'release-review': 'release 검수',
|
||||
board: '작업 요청',
|
||||
charts: '차트',
|
||||
schedule: '스케줄',
|
||||
history: '이력',
|
||||
'server-command': 'Command',
|
||||
};
|
||||
|
||||
export const PLAY_SIDEBAR_LABELS: Record<PlaySectionKey, string> = {
|
||||
layout: 'Layout Editor',
|
||||
};
|
||||
|
||||
export const PLAN_MENU_ANCHOR_IDS: Partial<Record<PlanSectionKey, string>> = {
|
||||
all: 'plan-menu-all',
|
||||
'in-progress': 'plan-menu-in-progress',
|
||||
release: 'plan-menu-release',
|
||||
'release-review': 'plan-menu-release-review',
|
||||
done: 'plan-menu-done',
|
||||
error: 'plan-menu-error',
|
||||
board: 'plan-menu-board',
|
||||
charts: 'plan-menu-charts',
|
||||
schedule: 'plan-menu-schedule',
|
||||
history: 'plan-menu-history',
|
||||
'server-command': 'plan-menu-server-command',
|
||||
};
|
||||
|
||||
export function getDocsSectionLabel(section: string) {
|
||||
return DOCS_FOLDER_LABELS[section] ?? section;
|
||||
}
|
||||
|
||||
export function resolveSavedLayoutMenuKey(layoutId: string): PlaySidebarKey {
|
||||
return `${PLAY_LAYOUT_RECORD_PREFIX}${layoutId}`;
|
||||
}
|
||||
|
||||
export function resolveSavedLayoutIdFromMenuKey(key: PlaySidebarKey) {
|
||||
return key.startsWith(PLAY_LAYOUT_RECORD_PREFIX) ? key.slice(PLAY_LAYOUT_RECORD_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function resolvePlaySidebarLabel(selectedPlayMenu: PlaySidebarKey, savedLayouts: Array<{ id: string; name: string }>) {
|
||||
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
|
||||
|
||||
if (selectedPlayMenu === 'layout') {
|
||||
return PLAY_SIDEBAR_LABELS[selectedPlayMenu];
|
||||
}
|
||||
|
||||
return savedLayouts.find((record) => record.id === savedLayoutId)?.name ?? 'Saved Layout';
|
||||
}
|
||||
|
||||
export function renderPlanMenuLabel(menu: PlanSectionKey, label: string) {
|
||||
const anchorId = PLAN_MENU_ANCHOR_IDS[menu];
|
||||
|
||||
if (!anchorId) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return <span id={anchorId}>{label}</span>;
|
||||
}
|
||||
|
||||
export function buildDocsPath(folder = DOCS_DEFAULT_FOLDER) {
|
||||
return `/docs/${folder}`;
|
||||
}
|
||||
|
||||
export function buildApisPath(section: ApiSectionKey = 'components') {
|
||||
return `/apis/${section}`;
|
||||
}
|
||||
|
||||
export function buildPlansPath(section: PlanSectionKey = 'all') {
|
||||
return `/plans/${section}`;
|
||||
}
|
||||
|
||||
export function buildChatPath(section: ChatSectionKey = 'live') {
|
||||
return `/chat/${section}`;
|
||||
}
|
||||
|
||||
export function buildPlayPath(section: PlaySectionKey = 'layout') {
|
||||
return `/play/${section}`;
|
||||
}
|
||||
|
||||
export function buildSavedLayoutPath(layoutId: string) {
|
||||
return `/play/layout-record/${layoutId}`;
|
||||
}
|
||||
|
||||
type RenderMenuLabelParams = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function renderMenuLabel({ key, label }: RenderMenuLabelParams) {
|
||||
return <span id={key}>{label}</span>;
|
||||
}
|
||||
|
||||
export function buildDocsMenuItems(docFolders: string[]): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'docs-group',
|
||||
icon: <FileMarkdownOutlined />,
|
||||
label: 'Docs',
|
||||
children: docFolders.map((folder) => ({
|
||||
key: folder,
|
||||
label: DOCS_FOLDER_LABELS[folder] ?? folder,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildApiMenuItems(): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'api-group',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: 'APIs',
|
||||
children: [
|
||||
{ key: 'components', label: 'Components' },
|
||||
{ key: 'widgets', label: 'Widgets' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildPlanMenuItems(hasAccess = true): MenuProps['items'] {
|
||||
if (!hasAccess) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'plan-group',
|
||||
icon: <ProfileOutlined />,
|
||||
label: PLAN_GROUP_LABEL,
|
||||
children: [
|
||||
{
|
||||
key: 'all',
|
||||
label: renderPlanMenuLabel('all', PLAN_FILTER_LABELS.all),
|
||||
},
|
||||
{
|
||||
key: 'board',
|
||||
label: renderPlanMenuLabel('board', PLAN_SIDEBAR_LABELS.board),
|
||||
},
|
||||
{
|
||||
key: 'release-review',
|
||||
label: renderPlanMenuLabel('release-review', PLAN_SIDEBAR_LABELS['release-review']),
|
||||
},
|
||||
{
|
||||
key: 'charts',
|
||||
label: renderPlanMenuLabel('charts', PLAN_SIDEBAR_LABELS.charts),
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: renderPlanMenuLabel('schedule', PLAN_SIDEBAR_LABELS.schedule),
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: renderPlanMenuLabel('history', PLAN_SIDEBAR_LABELS.history),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'server-group',
|
||||
icon: <ProfileOutlined />,
|
||||
label: 'Servers',
|
||||
children: [
|
||||
{
|
||||
key: 'server-command',
|
||||
label: renderPlanMenuLabel('server-command', PLAN_SIDEBAR_LABELS['server-command']),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function renderChatUnreadLabel(label: string, unreadCount: number) {
|
||||
if (unreadCount <= 0) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge count={unreadCount} size="small" offset={[10, 0]}>
|
||||
<span>{label}</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function buildChatMenuItems(hasAccess = true, unreadCount = 0): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'codex-live-group',
|
||||
icon: <MessageOutlined />,
|
||||
label: renderChatUnreadLabel('Codex Live', unreadCount),
|
||||
children: [
|
||||
{ key: 'live', label: renderChatUnreadLabel('Codex Live', unreadCount) },
|
||||
{ key: 'changes', label: '변경 이력' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'app-log-group',
|
||||
icon: <MessageOutlined />,
|
||||
label: '앱로그',
|
||||
children: [{ key: 'errors', label: '에러 로그' }],
|
||||
},
|
||||
...(hasAccess
|
||||
? [
|
||||
{
|
||||
key: 'chat-manage-group',
|
||||
icon: <MessageOutlined />,
|
||||
label: '채팅 관리',
|
||||
children: [{ key: 'manage', label: '유형 권한 관리' }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: string }>): MenuProps['items'] {
|
||||
return [
|
||||
{
|
||||
key: 'play-group',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: 'Play',
|
||||
children: [
|
||||
{
|
||||
key: 'play-layout-group',
|
||||
label: 'Layout',
|
||||
children: [
|
||||
{ key: 'layout', label: 'Layout Editor' },
|
||||
...(savedLayouts.length
|
||||
? savedLayouts.map((record) => ({
|
||||
key: resolveSavedLayoutMenuKey(record.id),
|
||||
label: record.name,
|
||||
}))
|
||||
: [{ key: 'saved-layout-empty', label: '저장된 레이아웃 없음', disabled: true }]),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolvePlanOpenKeys() {
|
||||
return ['plan-group', 'server-group', 'codex-live-group', 'app-log-group', 'chat-manage-group'];
|
||||
}
|
||||
|
||||
export function resolvePlayOpenKeys() {
|
||||
return ['play-group', 'play-layout-group'];
|
||||
}
|
||||
|
||||
export function resolvePlanQuickFilterMenu(filter: 'working' | 'release-pending-main' | 'automation-failed') {
|
||||
if (filter === 'working') {
|
||||
return 'in-progress' as const;
|
||||
}
|
||||
|
||||
return filter === 'release-pending-main' ? ('release' as const) : ('error' as const);
|
||||
}
|
||||
|
||||
export function renderSidebarIntro(activeTopMenu: TopMenuKey) {
|
||||
const isDocsGroup = activeTopMenu === 'docs' || activeTopMenu === 'apis';
|
||||
|
||||
return {
|
||||
color: isDocsGroup ? 'gold' : activeTopMenu === 'play' ? 'cyan' : 'green',
|
||||
tag: isDocsGroup ? 'Docs' : activeTopMenu === 'play' ? 'Play' : '자동화',
|
||||
description: isDocsGroup
|
||||
? '사이드바에서 Docs와 APIs를 함께 탐색합니다.'
|
||||
: activeTopMenu === 'play'
|
||||
? '사이드바에서 Play 화면을 탐색합니다.'
|
||||
: '사이드바에서 작업, Codex Live, 앱로그를 함께 전환합니다.',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCurrentPageDescriptor(params: {
|
||||
topMenu: TopMenuKey;
|
||||
docsMenu: string;
|
||||
apiMenu: ApiSectionKey;
|
||||
planMenu: PlanSectionKey;
|
||||
chatMenu: ChatSectionKey;
|
||||
playMenu: PlaySidebarKey;
|
||||
savedLayouts: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const { topMenu, docsMenu, apiMenu, planMenu, chatMenu, playMenu, savedLayouts } = params;
|
||||
|
||||
if (topMenu === 'docs') {
|
||||
return {
|
||||
id: `docs:${docsMenu}`,
|
||||
title: `Docs / ${getDocsSectionLabel(docsMenu)}`,
|
||||
topMenu,
|
||||
section: docsMenu,
|
||||
};
|
||||
}
|
||||
|
||||
if (topMenu === 'apis') {
|
||||
return {
|
||||
id: `apis:${apiMenu}`,
|
||||
title: apiMenu === 'components' ? 'APIs / Components' : 'APIs / Widgets',
|
||||
topMenu,
|
||||
section: apiMenu,
|
||||
};
|
||||
}
|
||||
|
||||
if (topMenu === 'plans') {
|
||||
const title = planMenu === 'server-command' ? `Servers / ${PLAN_SIDEBAR_LABELS[planMenu]}` : `${PLAN_GROUP_LABEL} / ${PLAN_SIDEBAR_LABELS[planMenu]}`;
|
||||
|
||||
return {
|
||||
id: `plans:${planMenu}`,
|
||||
title,
|
||||
topMenu,
|
||||
section: planMenu,
|
||||
};
|
||||
}
|
||||
|
||||
if (topMenu === 'chat') {
|
||||
return {
|
||||
id: `app-log:${chatMenu}`,
|
||||
title:
|
||||
chatMenu === 'errors'
|
||||
? '앱로그 / 에러 로그'
|
||||
: chatMenu === 'changes'
|
||||
? 'Codex Live / 변경 이력'
|
||||
: chatMenu === 'manage'
|
||||
? '채팅 관리 / 유형 권한 관리'
|
||||
: 'Codex Live / Codex Live',
|
||||
topMenu,
|
||||
section: chatMenu,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `play:${playMenu}`,
|
||||
title: `Play / ${resolvePlaySidebarLabel(playMenu, savedLayouts)}`,
|
||||
topMenu,
|
||||
section: playMenu,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: string) {
|
||||
if (menu === 'docs') {
|
||||
return buildDocsPath(currentDocsFolder);
|
||||
}
|
||||
|
||||
return menu === 'plans' ? buildPlansPath('all') : buildPlayPath('layout');
|
||||
}
|
||||
|
||||
export function createPageWindowId(topMenu: TopMenuKey, section: string) {
|
||||
return `page:${topMenu}:${section}`;
|
||||
}
|
||||
|
||||
export function renderMenuAnchor(label: string, anchorId: string): ReactNode {
|
||||
return renderMenuLabel({ key: anchorId, label });
|
||||
}
|
||||
68
src/app/main/tokenAccess.ts
Executable file
68
src/app/main/tokenAccess.ts
Executable file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token';
|
||||
export const TOKEN_ACCESS_SYNC_EVENT = 'work-app:token-access-changed';
|
||||
export const ALLOWED_REGISTRATION_TOKEN = 'usr_7f3a9c2d8e1b4a6f';
|
||||
|
||||
function normalizeToken(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function isAllowedRegistrationToken(token: string | null | undefined) {
|
||||
return normalizeToken(token) === ALLOWED_REGISTRATION_TOKEN;
|
||||
}
|
||||
|
||||
export function getRegisteredAccessToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizeToken(window.localStorage.getItem(TOKEN_ACCESS_STORAGE_KEY));
|
||||
}
|
||||
|
||||
export function hasRegisteredAccessTokenAccess() {
|
||||
return isAllowedRegistrationToken(getRegisteredAccessToken());
|
||||
}
|
||||
|
||||
export function setRegisteredAccessToken(token: string | null | undefined) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedToken = normalizeToken(token);
|
||||
|
||||
if (normalizedToken) {
|
||||
window.localStorage.setItem(TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(TOKEN_ACCESS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));
|
||||
}
|
||||
|
||||
export function useTokenAccess() {
|
||||
const [registeredToken, setRegisteredToken] = useState(() => getRegisteredAccessToken());
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const syncRegisteredToken = () => {
|
||||
setRegisteredToken(getRegisteredAccessToken());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', syncRegisteredToken);
|
||||
window.addEventListener(TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncRegisteredToken);
|
||||
window.removeEventListener(TOKEN_ACCESS_SYNC_EVENT, syncRegisteredToken);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
registeredToken,
|
||||
hasAccess: isAllowedRegistrationToken(registeredToken),
|
||||
};
|
||||
}
|
||||
63
src/app/main/types.ts
Executable file
63
src/app/main/types.ts
Executable file
@@ -0,0 +1,63 @@
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { PlanQuickFilter } from '../../features/planBoard';
|
||||
import type {
|
||||
ApiSectionKey,
|
||||
ChatSectionKey,
|
||||
HeaderTopMenuKey,
|
||||
PlanSectionKey,
|
||||
PlaySidebarKey,
|
||||
TopMenuKey,
|
||||
} from './routes';
|
||||
export type {
|
||||
HeaderTopMenuKey,
|
||||
PlaySidebarKey,
|
||||
TopMenuKey,
|
||||
} from './routes';
|
||||
|
||||
export type PlanSidebarKey = PlanSectionKey;
|
||||
export type ChatSidebarKey = ChatSectionKey;
|
||||
|
||||
export type MainHeaderProps = {
|
||||
activeTopMenu: TopMenuKey;
|
||||
sidebarCollapsed: boolean;
|
||||
contentExpanded: boolean;
|
||||
isMobileViewport: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleContentExpanded: () => void;
|
||||
onChangeTopMenu: (menu: HeaderTopMenuKey) => void;
|
||||
onOpenPlanQuickFilter: (filter: PlanQuickFilter) => void;
|
||||
};
|
||||
|
||||
export type MainSidebarProps = {
|
||||
activeTopMenu: TopMenuKey;
|
||||
hasAccess: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
isMobileViewport: boolean;
|
||||
openKeys: string[];
|
||||
apiMenuItems: MenuProps['items'];
|
||||
docsMenuItems: MenuProps['items'];
|
||||
planMenuItems: MenuProps['items'];
|
||||
chatMenuItems: MenuProps['items'];
|
||||
playMenuItems: MenuProps['items'];
|
||||
selectedApiMenu: ApiSectionKey;
|
||||
selectedDocsMenu: string;
|
||||
selectedPlanMenu: PlanSidebarKey;
|
||||
selectedChatMenu: ChatSidebarKey;
|
||||
selectedPlayMenu: PlaySidebarKey;
|
||||
introColor: string;
|
||||
introTag: string;
|
||||
introDescription: string;
|
||||
onOpenKeysChange: (keys: string[]) => void;
|
||||
onSelectApiMenu: (key: ApiSectionKey) => void;
|
||||
onSelectDocsMenu: (key: string) => void;
|
||||
onSelectPlanMenu: (key: PlanSidebarKey) => void;
|
||||
onSelectChatMenu: (key: ChatSidebarKey) => void;
|
||||
onSelectPlayMenu: (key: PlaySidebarKey) => void;
|
||||
};
|
||||
|
||||
export type MainContentProps = {
|
||||
contentExpanded: boolean;
|
||||
onToggleContentExpanded: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
Reference in New Issue
Block a user