Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

132
src/App.tsx Executable file
View File

@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react';
import { getOrCreateClientId } from './app/main/clientIdentity';
import { reportClientError } from './app/main/errorLogApi';
import { AppShell } from './app/main';
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
import { reportVisitorPageView } from './features/history/api';
import { useAppStore } from './store';
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
function shouldRetryChunkLoad(errorMessage: string) {
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
errorMessage,
);
}
function retryChunkLoadOnce(errorMessage: string) {
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return false;
}
if (!shouldRetryChunkLoad(errorMessage)) {
return false;
}
if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') {
return false;
}
sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1');
window.location.reload();
return true;
}
function App() {
const { currentPage } = useAppStore();
const lastTrackedPageIdRef = useRef<string | null>(null);
const [showInitialLoading, setShowInitialLoading] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const handleError = (event: ErrorEvent) => {
const reportedError = event.error instanceof Error ? event.error : null;
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
if (retryChunkLoadOnce(errorMessage)) {
return;
}
void reportClientError({
errorType: 'window.error',
errorName: reportedError?.name ?? null,
errorMessage,
stackTrace: reportedError?.stack ?? null,
requestPath: `${window.location.pathname}${window.location.search}${window.location.hash}`,
context: {
filename: event.filename || null,
line: event.lineno || null,
column: event.colno || null,
},
});
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
const reason = event.reason;
const reportedError = reason instanceof Error ? reason : null;
const errorMessage =
reportedError?.message || (typeof reason === 'string' ? reason : '처리되지 않은 Promise 거절이 발생했습니다.');
if (retryChunkLoadOnce(errorMessage)) {
return;
}
void reportClientError({
errorType: 'unhandledrejection',
errorName: reportedError?.name ?? null,
errorMessage,
stackTrace: reportedError?.stack ?? null,
requestPath: `${window.location.pathname}${window.location.search}${window.location.hash}`,
context: {
reasonType: typeof reason,
},
});
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
useEffect(() => {
getOrCreateClientId();
}, []);
useEffect(() => {
const hideTimer = window.setTimeout(() => {
setShowInitialLoading(false);
}, INITIAL_LOADING_MIN_VISIBLE_MS);
return () => {
window.clearTimeout(hideTimer);
};
}, []);
useEffect(() => {
if (lastTrackedPageIdRef.current === currentPage.id) {
return;
}
lastTrackedPageIdRef.current = currentPage.id;
void reportVisitorPageView(currentPage);
}, [currentPage]);
return (
<>
<AppShell />
<ReleasePendingMainModal />
{showInitialLoading ? <InitialLoadingOverlay /> : null}
</>
);
}
export default App;

24
src/app/main/AppShell.tsx Executable file
View 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>
);
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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;
}
}

View 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>
</>
);
}

View 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;
}
}

View 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
View 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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

328
src/app/main/MainContent.tsx Executable file
View 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

File diff suppressed because it is too large Load Diff

727
src/app/main/MainLayout.css Executable file
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { AppShell } from './AppShell';
export function MainView() {
return <AppShell />;
}

View 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
View 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
View 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
View 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);
},
};
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View 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,
};

View 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);
},
};

View 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,
};
}

View File

@@ -0,0 +1 @@
export { useConversationListData as useConversationListController } from './useConversationListData';

View 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,
};
}

View File

@@ -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,
};
}

View File

@@ -0,0 +1 @@
export { useConversationRoomData as useConversationRoomController } from './useConversationRoomData';

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View File

@@ -0,0 +1 @@
export { useNotificationCenterData as useNotificationController } from './useNotificationCenterData';

View File

@@ -0,0 +1 @@
export { useRuntimeData as useRuntimeController } from './useRuntimeData';

View 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,
};
}

View 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,
};
}

View 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';

View 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
View 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
View 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
View File

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

View 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>
);
}

View 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;
}

View 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,
})),
];
}

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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)} />;
}

View File

@@ -0,0 +1,8 @@
import type { ErrorReferenceResource, ErrorReferenceSummary } from './types';
export type { ErrorReferenceResource, ErrorReferenceSummary };
export type ErrorReferenceCandidate = {
path: string;
value: string;
};

View 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';

View 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();
}

View 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;
};

View 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,
};
}

View 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,
};
}

View 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),
},
];
}),
);
}

View 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
View File

@@ -0,0 +1,5 @@
export * from './constants';
export * from './navigation';
export * from './searchOptions';
export * from './useMainViewData';
export * from './utils';

View 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'),
};
}

View 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,
})),
];
}

View 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
View 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
View 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),
});
}

View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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;
};

View File

@@ -0,0 +1,35 @@
import type { MarkdownDocumentEntry } from '../../components/markdownPreview';
const docsMarkdownModules = import.meta.glob('/docs/**/*.md', {
query: '?raw',
import: 'default',
}) as Record<string, () => Promise<string>>;
const featureMarkdownModules = import.meta.glob('../../features/**/*.md', {
query: '?raw',
import: 'default',
}) as Record<string, () => Promise<string>>;
function createMarkdownEntries(
modules: Record<string, () => Promise<string>>,
): MarkdownDocumentEntry[] {
const sortedPaths = Object.keys(modules).sort((left, right) => {
const isLeftWorklog = left.includes('/docs/worklogs/');
const isRightWorklog = right.includes('/docs/worklogs/');
if (isLeftWorklog && isRightWorklog) {
return right.localeCompare(left);
}
return left.localeCompare(right);
});
return sortedPaths.map((path, index) => ({
path,
load: modules[path],
order: index,
}));
}
export const docsMarkdownEntries = createMarkdownEntries(docsMarkdownModules);
export const featureMarkdownEntries = createMarkdownEntries(featureMarkdownModules);

View File

@@ -0,0 +1,26 @@
import type { SampleEntry } from '../../samples/registry';
import type { SampleModule } from '../../widgets/core';
const componentSampleModules = import.meta.glob('../../components/**/samples/*.tsx') as Record<
string,
() => Promise<SampleModule>
>;
const widgetSampleModules = import.meta.glob('../../widgets/**/samples/*.tsx') as Record<
string,
() => Promise<SampleModule>
>;
function createSampleEntries(
modules: Record<string, () => Promise<SampleModule>>,
): SampleEntry[] {
return Object.entries(modules)
.map(([modulePath, load]) => ({
modulePath,
load,
}))
.sort((left, right) => left.modulePath.localeCompare(right.modulePath));
}
export const componentSampleEntries = createSampleEntries(componentSampleModules);
export const widgetSampleEntries = createSampleEntries(widgetSampleModules);

View File

@@ -0,0 +1,41 @@
import { PictureOutlined } from '@ant-design/icons';
import type { ImgHTMLAttributes } from 'react';
import { useEffect, useState } from 'react';
type InlineImageProps = ImgHTMLAttributes<HTMLImageElement> & {
fallbackText?: string;
};
export function InlineImage({ alt, className, fallbackText, onError, src, ...rest }: InlineImageProps) {
const [hasError, setHasError] = useState(!src);
useEffect(() => {
setHasError(!src);
}, [src]);
if (hasError) {
return (
<div
className={['inline-image-fallback', className].filter(Boolean).join(' ')}
role="img"
aria-label={alt || fallbackText || '이미지를 불러오지 못했습니다.'}
>
<PictureOutlined />
<span>{fallbackText || '이미지를 불러오지 못했습니다.'}</span>
</div>
);
}
return (
<img
{...rest}
alt={alt}
className={className}
src={src}
onError={(event) => {
setHasError(true);
onError?.(event);
}}
/>
);
}

View File

@@ -0,0 +1,51 @@
import { Flex, Typography } from 'antd';
const { Text } = Typography;
export type MultiProgressItem = {
label: string;
percent: number;
color: string;
};
export type MultiProgressUIProps = {
label: string;
meta?: string;
data: MultiProgressItem[];
};
export function MultiProgressUI({ label, meta, data }: MultiProgressUIProps) {
return (
<Flex vertical gap={12} className="dashboard-multi-progress-ui">
<div className="dashboard-multi-progress-ui__header">
<div className="dashboard-multi-progress-ui__copy">
<Text className="dashboard-multi-progress-ui__label">{label}</Text>
{meta ? <Text type="secondary">{meta}</Text> : null}
</div>
</div>
<div className="dashboard-multi-progress-ui__bar">
{data.map((item) => (
<div
key={item.label}
className="dashboard-multi-progress-ui__segment"
style={{ width: `${item.percent}%`, backgroundColor: item.color }}
/>
))}
</div>
<Flex vertical gap={8}>
{data.map((item) => (
<div key={item.label} className="dashboard-multi-progress-ui__legend">
<span
className="dashboard-multi-progress-ui__swatch"
style={{ backgroundColor: item.color }}
/>
<Text>{item.label}</Text>
<Text className="dashboard-multi-progress-ui__percent">{item.percent}%</Text>
</div>
))}
</Flex>
</Flex>
);
}

View File

@@ -0,0 +1,2 @@
export { MultiProgressUI } from './MultiProgressUI';
export type { MultiProgressItem, MultiProgressUIProps } from './MultiProgressUI';

View File

@@ -0,0 +1 @@
export { createMultiProgressMetaPlugin, createMultiProgressSortPlugin } from './multi-progress.plugin';

View File

@@ -0,0 +1,16 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { MultiProgressUIProps } from '../types';
export function createMultiProgressMetaPlugin(meta: string): PropsPlugin<MultiProgressUIProps> {
return (props) => ({
...props,
meta,
});
}
export function createMultiProgressSortPlugin(): PropsPlugin<MultiProgressUIProps> {
return (props) => ({
...props,
data: [...props.data].sort((left, right) => right.percent - left.percent),
});
}

View File

@@ -0,0 +1,28 @@
import type { SampleMeta } from '../../../../widgets/core';
import { MultiProgressUI } from '../MultiProgressUI';
export const sampleMeta: SampleMeta = {
id: 'dashboard-multi-progress-base',
componentId: 'dashboard-multi-progress',
title: 'Dashboard Multi Progress',
description: '여러 단계 진행률을 하나의 막대와 범례로 함께 보여주는 컴포넌트입니다.',
category: 'Components',
kind: 'base',
variantLabel: 'Base',
order: 11,
features: ['docs'],
};
export function Sample() {
return (
<MultiProgressUI
label="오늘 처리 현황"
meta="입고 / 피킹 / 출고"
data={[
{ label: '입고', percent: 28, color: '#165dff' },
{ label: '피킹', percent: 34, color: '#52c41a' },
{ label: '출고', percent: 38, color: '#fa8c16' },
]}
/>
);
}

View File

@@ -0,0 +1,36 @@
import type { SampleMeta } from '../../../../widgets/core';
import { plugins } from '../../../../types/component-plugin';
import { MultiProgressUI } from '../MultiProgressUI';
import {
createMultiProgressMetaPlugin,
createMultiProgressSortPlugin,
} from '../plugins';
import type { MultiProgressUIProps } from '../types';
export const sampleMeta: SampleMeta = {
id: 'dashboard-multi-progress',
componentId: 'dashboard-multi-progress',
title: 'Dashboard Multi Progress',
description: '여러 상태 비중을 하나의 bar와 범례로 표현하는 대시보드 컴포넌트입니다.',
category: 'Components',
kind: 'feature',
variantLabel: 'Showcase',
order: 11,
features: ['docs'],
};
export function Sample() {
const props = plugins<MultiProgressUIProps>(
{
label: '배송 상태 비중',
data: [
{ label: '배송 완료', percent: 54, color: '#28c76f' },
{ label: '배송 중', percent: 28, color: '#00cfe8' },
{ label: '지연', percent: 18, color: '#ea5455' },
],
},
[createMultiProgressMetaPlugin('오늘 기준'), createMultiProgressSortPlugin()],
);
return <MultiProgressUI {...props} />;
}

View File

@@ -0,0 +1 @@
export type { MultiProgressItem, MultiProgressUIProps } from './multi-progress';

View File

@@ -0,0 +1,11 @@
export type MultiProgressItem = {
label: string;
percent: number;
color: string;
};
export type MultiProgressUIProps = {
label: string;
meta?: string;
data: MultiProgressItem[];
};

View File

@@ -0,0 +1,26 @@
import { Flex, Progress, Typography } from 'antd';
import type { ProgressUIProps } from './types';
const { Text } = Typography;
export function ProgressUI({ label, meta, data }: ProgressUIProps) {
return (
<Flex vertical gap={8} className="dashboard-progress-ui">
<div className="dashboard-progress-ui__header">
<div className="dashboard-progress-ui__copy">
<Text className="dashboard-progress-ui__label">{label}</Text>
{meta ? <Text type="secondary">{meta}</Text> : null}
</div>
<Text className="dashboard-progress-ui__percent">{data.percent}%</Text>
</div>
<Progress
percent={data.percent}
size="small"
showInfo={false}
strokeColor={data.color}
className="dashboard-progress-ui__bar"
/>
</Flex>
);
}

View File

@@ -0,0 +1,3 @@
export { ProgressUI } from './ProgressUI';
export { createProgressColorPlugin, createProgressMetaPlugin } from './plugins';
export type { ProgressUIData, ProgressUIProps } from './types';

View File

@@ -0,0 +1 @@
export { createProgressColorPlugin, createProgressMetaPlugin } from './progress.plugin';

View File

@@ -0,0 +1,19 @@
import type { PropsPlugin } from '../../../../types/component-plugin';
import type { ProgressUIProps } from '../types';
export function createProgressMetaPlugin(meta: string): PropsPlugin<ProgressUIProps> {
return (props) => ({
...props,
meta,
});
}
export function createProgressColorPlugin(color: string): PropsPlugin<ProgressUIProps> {
return (props) => ({
...props,
data: {
...props.data,
color,
},
});
}

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