import { ApiOutlined, BellOutlined, ClockCircleOutlined, CopyOutlined, FileMarkdownOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, ProfileOutlined, ReloadOutlined, SettingOutlined, } from '@ant-design/icons'; import { Alert, Button, Checkbox, Drawer, Dropdown, Grid, Input, InputNumber, Layout, Modal, Select, Segmented, Space, Typography, } from 'antd'; import { useEffect, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { fetchPlanItems } from '../../features/planBoard/api'; import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters'; import { fetchServerCommands, restartServerCommand } from '../../features/serverCommand/api'; import type { ServerCommandItem } from '../../features/serverCommand/types'; import { DEFAULT_APP_CONFIG, saveAutomationNotificationPreferenceToServer, saveAppConfigToServer, setStoredAppConfig, syncAppConfigFromServer, useAppConfig, type AppConfig, type PlanCostTimeUnit, } from './appConfig'; import { fetchWebPushConfig, registerPwaNotificationToken, registerWebPushSubscription, unregisterPwaNotificationToken, unregisterWebPushSubscription, type WebPushSubscriptionPayload, } from './notificationApi'; import { clearNotificationIdentity, getSavedNotificationDeviceId, getSavedPwaNotificationToken, setSavedPwaNotificationToken, } from './notificationIdentity'; import { resetNonAuthClientState } from './appMaintenance'; import { ALLOWED_REGISTRATION_TOKEN, setRegisteredAccessToken, useTokenAccess, } from './tokenAccess'; import { chatConnectionGateway, chatGateway } from './chatV2'; import { HeaderMessageCenter } from './HeaderMessageCenter'; import { fetchChatRuntimeJobDetail } from './mainChatPanel'; import { getSharedActiveConversationSnapshot, subscribeSharedActiveConversation, } from './mainChatPanel/sharedActiveConversation'; import { buildChatPath } from './routes'; import type { MainHeaderProps } from './types'; import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from './mainChatPanel/types'; const { Header } = Layout; const { Paragraph, Text } = Typography; const { useBreakpoint } = Grid; const APP_SETTINGS_CATEGORIES = [ { value: 'automation', label: '작업' }, { value: 'workspace', label: '작업 환경' }, ] as const; const APP_SETTINGS_SECTIONS: Array<{ value: 'chatSettings' | 'planDefaults' | 'planCost' | 'worklogAutomation' | 'automationNotifications' | 'gestureShortcuts'; label: string; category: (typeof APP_SETTINGS_CATEGORIES)[number]['value']; }> = [ { value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' }, { value: 'planDefaults', label: '자동화 기본값', category: 'automation' }, { value: 'planCost', label: '비용 표시', category: 'automation' }, { value: 'worklogAutomation', label: '업무일지 자동화 설정', category: 'automation' }, { value: 'automationNotifications', label: '자동화 알림', category: 'automation' }, { value: 'gestureShortcuts', label: '제스처 / 단축키', category: 'workspace' }, ]; const PLAN_COST_TIME_UNIT_LABELS: Record = { hour: '시간', minute: '분', second: '초', }; type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied'; type SettingsModalKey = 'appSettings' | 'notification' | 'token' | 'update'; type AppSettingsCategoryKey = (typeof APP_SETTINGS_CATEGORIES)[number]['value']; type AppSettingsSectionKey = (typeof APP_SETTINGS_SECTIONS)[number]['value']; let lastPushSwRegisterAttempts: Array<{ url: string; mode: string; error: string }> = []; type FeedbackTone = 'success' | 'info' | 'warning' | 'error'; type InlineFeedback = { tone: FeedbackTone; message: string; }; function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { return left.maxContextMessages === right.maxContextMessages && left.maxContextChars === right.maxContextChars; } function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['chat']) { const changedLabels: string[] = []; if (saved.maxContextMessages !== draft.maxContextMessages) { changedLabels.push('최근 문맥 메시지 수'); } if (saved.maxContextChars !== draft.maxContextChars) { changedLabels.push('최근 문맥 글자 수'); } return changedLabels; } function areWorklogAutomationSettingsEqual(left: AppConfig['worklogAutomation'], right: AppConfig['worklogAutomation']) { return ( left.autoCreateDailyWorklog === right.autoCreateDailyWorklog && left.dailyCreateTime === right.dailyCreateTime && left.includeScreenshots === right.includeScreenshots && left.includeChangedFiles === right.includeChangedFiles && left.includeCommandLogs === right.includeCommandLogs && left.template === right.template ); } function getWorklogAutomationDiffLabels(saved: AppConfig['worklogAutomation'], draft: AppConfig['worklogAutomation']) { const changedLabels: string[] = []; if (saved.autoCreateDailyWorklog !== draft.autoCreateDailyWorklog) { changedLabels.push('일일 작업일지 자동 생성'); } if (saved.dailyCreateTime !== draft.dailyCreateTime) { changedLabels.push('자동 생성 시각'); } if (saved.includeScreenshots !== draft.includeScreenshots) { changedLabels.push('스크린샷 포함'); } if (saved.includeChangedFiles !== draft.includeChangedFiles) { changedLabels.push('변경 파일 목록 포함'); } if (saved.includeCommandLogs !== draft.includeCommandLogs) { changedLabels.push('실행 커맨드 포함'); } if (saved.template !== draft.template) { changedLabels.push('기본 템플릿'); } return changedLabels; } function arePlanDefaultSettingsEqual(left: AppConfig['planDefaults'], right: AppConfig['planDefaults']) { return ( left.jangsingProcessingRequired === right.jangsingProcessingRequired && left.autoDeployToMain === right.autoDeployToMain && left.openEditorAfterCreate === right.openEditorAfterCreate ); } function getPlanDefaultDiffLabels(saved: AppConfig['planDefaults'], draft: AppConfig['planDefaults']) { const changedLabels: string[] = []; if (saved.jangsingProcessingRequired !== draft.jangsingProcessingRequired) { changedLabels.push('기능동작확인 기본값'); } if (saved.autoDeployToMain !== draft.autoDeployToMain) { changedLabels.push('메인까지 자동등록'); } if (saved.openEditorAfterCreate !== draft.openEditorAfterCreate) { changedLabels.push('등록 후 편집기 열기'); } return changedLabels; } function arePlanCostSettingsEqual(left: AppConfig['planCost'], right: AppConfig['planCost']) { return ( left.baseCostPerMillionTokens === right.baseCostPerMillionTokens && left.retryCostMultiplierPercent === right.retryCostMultiplierPercent && left.hourlyCostMultiplierPercent === right.hourlyCostMultiplierPercent && left.timeCostUnit === right.timeCostUnit && left.attentionCostThresholdMultiplier === right.attentionCostThresholdMultiplier && left.warningCostThresholdMultiplier === right.warningCostThresholdMultiplier && left.highCostThresholdMultiplier === right.highCostThresholdMultiplier ); } function getPlanCostDiffLabels(saved: AppConfig['planCost'], draft: AppConfig['planCost']) { const changedLabels: string[] = []; if (saved.baseCostPerMillionTokens !== draft.baseCostPerMillionTokens) { changedLabels.push('백만 토큰당 기준 비용'); } if (saved.retryCostMultiplierPercent !== draft.retryCostMultiplierPercent) { changedLabels.push('재처리 가산 비율'); } if (saved.hourlyCostMultiplierPercent !== draft.hourlyCostMultiplierPercent) { changedLabels.push('시간 가산 비율'); } if (saved.timeCostUnit !== draft.timeCostUnit) { changedLabels.push('시간 가산 기준 단위'); } if (saved.attentionCostThresholdMultiplier !== draft.attentionCostThresholdMultiplier) { changedLabels.push('관심 구간 기준'); } if (saved.warningCostThresholdMultiplier !== draft.warningCostThresholdMultiplier) { changedLabels.push('주의 구간 기준'); } if (saved.highCostThresholdMultiplier !== draft.highCostThresholdMultiplier) { changedLabels.push('높음 구간 기준'); } return changedLabels; } function formatCostAmount(value: number) { return `${new Intl.NumberFormat('ko-KR').format(value)}원`; } function formatPlanCostTimeMultiplierLabel(planCost: AppConfig['planCost']) { return `${PLAN_COST_TIME_UNIT_LABELS[planCost.timeCostUnit]}당 ${planCost.hourlyCostMultiplierPercent}%`; } function getPlanCostThresholdPreview(planCost: AppConfig['planCost']) { const attentionThreshold = planCost.baseCostPerMillionTokens * Math.max(0.1, planCost.attentionCostThresholdMultiplier); const warningThreshold = planCost.baseCostPerMillionTokens * Math.max(planCost.attentionCostThresholdMultiplier, planCost.warningCostThresholdMultiplier); const highThreshold = planCost.baseCostPerMillionTokens * Math.max(planCost.warningCostThresholdMultiplier, planCost.highCostThresholdMultiplier); return `안정 ${formatCostAmount(attentionThreshold)} 이하 · 관심 ${formatCostAmount( warningThreshold, )} 이하 · 주의 ${formatCostAmount(highThreshold)} 이하 · 높음 ${formatCostAmount(highThreshold)} 초과`; } function getAppSettingsSectionCategory(section: AppSettingsSectionKey): AppSettingsCategoryKey { return APP_SETTINGS_SECTIONS.find((entry) => entry.value === section)?.category ?? 'automation'; } function getAppSettingsSectionOptions(category: AppSettingsCategoryKey) { return APP_SETTINGS_SECTIONS.filter((entry) => entry.category === category).map((entry) => ({ label: entry.label, value: entry.value, })); } function formatRuntimeTimestamp(value: string | null | undefined) { if (!value) { return '-'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return value; } return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } function formatRuntimeRelativeLabel(value: string | null | undefined) { if (!value) { return '-'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return formatRuntimeTimestamp(value); } const diffMs = Date.now() - date.getTime(); if (diffMs <= 15_000) { return '방금'; } const diffSeconds = Math.floor(diffMs / 1000); if (diffSeconds < 60) { return `${diffSeconds}초 전`; } const diffMinutes = Math.floor(diffSeconds / 60); if (diffMinutes < 60) { return `${diffMinutes}분 전`; } const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) { return `${diffHours}시간 전`; } const diffDays = Math.floor(diffHours / 24); return `${diffDays}일 전`; } function buildRuntimeTerminalLabel(terminalStatus: ChatRuntimeJobDetail['terminalStatus']) { if (!terminalStatus) { return '-'; } if (terminalStatus === 'cancelled') { return '취소됨'; } if (terminalStatus === 'removed') { return '대기열 제거됨'; } if (terminalStatus === 'failed') { return '실패'; } return '완료'; } const AUTOMATION_NOTIFICATION_OPTIONS: Array<{ key: keyof Pick< AppConfig['automation'], | 'notifyOnAutomationStart' | 'notifyOnAutomationProgress' | 'notifyOnAutomationCompletion' | 'notifyOnAutomationRelease' | 'notifyOnAutomationMain' | 'notifyOnAutomationFailure' | 'notifyOnAutomationRestart' | 'notifyOnAutomationIssueResolved' >; label: string; description: string; }> = [ { key: 'notifyOnAutomationStart', label: '자동화 시작', description: '작업 자동화가 시작될 때 알림을 받습니다.', }, { key: 'notifyOnAutomationProgress', label: '자동화 진행', description: '자동 작업이 오래 걸릴 때 진행 알림을 받습니다.', }, { key: 'notifyOnAutomationCompletion', label: '자동 작업 완료', description: '작업완료, 변경 없음 완료, 수동 완료 처리 알림을 받습니다.', }, { key: 'notifyOnAutomationRelease', label: 'release 반영 완료', description: 'release 반영 완료 알림을 받습니다.', }, { key: 'notifyOnAutomationMain', label: 'main 반영 완료', description: 'main 반영과 메인 프로젝트 동기화 완료 알림을 받습니다.', }, { key: 'notifyOnAutomationFailure', label: '자동화 실패', description: '브랜치, 자동 작업, release, main 반영 실패 알림을 받습니다.', }, { key: 'notifyOnAutomationRestart', label: '작업 재시작', description: '재시도나 이슈 조치로 작업이 다시 큐에 들어갈 때 알림을 받습니다.', }, { key: 'notifyOnAutomationIssueResolved', label: '이슈 해결 처리', description: '최신 이슈가 해결 처리될 때 알림을 받습니다.', }, ]; function areAutomationNotificationSettingsEqual(left: AppConfig['automation'], right: AppConfig['automation']) { return AUTOMATION_NOTIFICATION_OPTIONS.every((option) => left[option.key] === right[option.key]); } function getAutomationNotificationDiffLabels(saved: AppConfig['automation'], draft: AppConfig['automation']) { return AUTOMATION_NOTIFICATION_OPTIONS.filter((option) => saved[option.key] !== draft[option.key]).map( (option) => option.label, ); } function areGestureShortcutSettingsEqual( left: AppConfig['gestureShortcuts'], right: AppConfig['gestureShortcuts'], ) { return left.openSearch === right.openSearch && left.openWindowSearch === right.openWindowSearch; } function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) { const changedLabels: string[] = []; if (saved.openSearch !== draft.openSearch) { changedLabels.push('통합 검색 열기'); } if (saved.openWindowSearch !== draft.openWindowSearch) { changedLabels.push('Window UI 검색 열기'); } return changedLabels; } function isSettingsModalAllowed(modal: SettingsModalKey, hasAccess: boolean) { if (hasAccess) { return true; } return modal === 'token'; } function getSettingsModalTitle(modal: SettingsModalKey) { switch (modal) { case 'notification': return '알림'; case 'token': return '권한'; case 'update': return '업데이트'; default: return '앱 설정'; } } function formatDateTimeLabel(value: string | null) { if (!value) { return '-'; } const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) { return value; } return new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', dateStyle: 'medium', timeStyle: 'short', }).format(parsed); } function getServerVersionStatusClassName(item: ServerCommandItem | null) { if (!item) { return 'app-header__server-version-indicator--unknown'; } if (item.buildRequired) { return 'app-header__server-version-indicator--build-required'; } if (item.updateAvailable) { return 'app-header__server-version-indicator--update-available'; } return 'app-header__server-version-indicator--latest'; } function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) { return formatDateTimeLabel(item?.latestSourceChangeAt ?? null); } function getServerVersionStatusTitle(item: ServerCommandItem | null, label: string) { if (!item) { return `${label} 최신 버전 확인 전`; } if (item.buildRequired) { return `${label} 커밋 미반영 상태`; } if (item.updateAvailable) { return `${label} 운영 반영 대기 상태`; } return `${label} 최신 버전`; } function getClientNotificationPermission(): ClientNotificationPermissionState { if ( typeof window === 'undefined' || typeof Notification === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window) ) { return 'unsupported'; } if (Notification.permission === 'granted') { return 'granted'; } if (Notification.permission === 'denied') { return 'denied'; } return 'default'; } function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) { switch (permission) { case 'granted': return '허용됨'; case 'denied': return '차단됨'; case 'unsupported': return '미지원'; default: return '미확인'; } } 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 isAppleMobileDevice() { if (typeof navigator === 'undefined') { return false; } return /iPhone|iPad|iPod/i.test(navigator.userAgent); } function hasSecureOrigin() { if (typeof window === 'undefined') { return false; } return window.isSecureContext || window.location.hostname === 'localhost'; } function urlBase64ToUint8Array(base64String: string) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let index = 0; index < rawData.length; index += 1) { outputArray[index] = rawData.charCodeAt(index); } return outputArray; } function isSamePushApplicationServerKey( leftKey: ArrayBuffer | null | undefined, rightKey: Uint8Array, ) { if (!leftKey) { return false; } const leftBytes = new Uint8Array(leftKey); if (leftBytes.byteLength !== rightKey.byteLength) { return false; } for (let index = 0; index < leftBytes.byteLength; index += 1) { if (leftBytes[index] !== rightKey[index]) { return false; } } return true; } function serializePushSubscription(subscription: PushSubscription): WebPushSubscriptionPayload { const json = subscription.toJSON(); return { endpoint: subscription.endpoint, expirationTime: subscription.expirationTime, keys: { p256dh: json.keys?.p256dh ?? '', auth: json.keys?.auth ?? '', }, }; } async function getPushServiceWorkerRegistration() { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return null; } const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js'; const resolvedServiceWorkerUrl = typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl; const timeoutMs = 30000; const startedAt = Date.now(); let registeredRegistration: ServiceWorkerRegistration | null = null; const waitForServiceWorkerReady = async (remainingTimeoutMs: number) => { const readyStartedAt = Date.now(); while (Date.now() - readyStartedAt < remainingTimeoutMs) { try { const readyRegistration = await Promise.race([ navigator.serviceWorker.ready, new Promise((resolve) => { window.setTimeout(() => resolve(null), 1000); }), ]); if (readyRegistration) { return readyRegistration; } } catch { // keep polling until timeout } await new Promise((resolve) => { window.setTimeout(resolve, 300); }); } return null; }; const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => { if (!registration) { return null; } if (registration.active || registration.waiting) { return registration; } const installingWorker = registration.installing; if (!installingWorker) { return registration; } const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); await new Promise((resolve) => { let completed = false; const finish = () => { if (completed) { return; } completed = true; window.clearTimeout(timeoutId); installingWorker.removeEventListener('statechange', onStateChange); resolve(); }; const onStateChange = () => { if ( installingWorker.state === 'activated' || installingWorker.state === 'installed' || Boolean(registration.active) || Boolean(registration.waiting) ) { finish(); } }; const timeoutId = window.setTimeout(finish, remainingTimeoutMs); installingWorker.addEventListener('statechange', onStateChange); onStateChange(); }); if (registration.active || registration.waiting) { return registration; } const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); return readyRegistration ?? registration; }; const tryRegister = async (url: string, mode: string, options?: RegistrationOptions) => { try { registeredRegistration = await navigator.serviceWorker.register(url, options); return true; } catch (error) { lastPushSwRegisterAttempts = [ ...lastPushSwRegisterAttempts, { url, mode, error: error instanceof Error ? error.message : 'unknown error', }, ]; return false; } }; const devOptionsModule = { type: 'module', scope: '/', updateViaCache: 'none' } as const; const devOptionsClassic = { scope: '/', updateViaCache: 'none' } as const; if (import.meta.env.DEV) { lastPushSwRegisterAttempts = []; const registered = (await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule)) || (await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic)); if (!registered) { // registerSW helper in main.tsx may already be handling this path; if manual registration fails, // continue and try to resolve an existing active registration below. } } else { await tryRegister(resolvedServiceWorkerUrl, 'default'); } const usableRegisteredRegistration = await resolveUsableRegistration(registeredRegistration); if (usableRegisteredRegistration) { return usableRegisteredRegistration; } while (Date.now() - startedAt < timeoutMs) { const existingRegistration = await navigator.serviceWorker.getRegistration(); const usableExistingRegistration = await resolveUsableRegistration(existingRegistration); if (usableExistingRegistration) { return usableExistingRegistration; } const scopedRegistration = await navigator.serviceWorker.getRegistration('/'); const usableScopedRegistration = await resolveUsableRegistration(scopedRegistration); if (usableScopedRegistration) { return usableScopedRegistration; } const registrations = await navigator.serviceWorker.getRegistrations(); for (const registration of registrations) { const usableRegistration = await resolveUsableRegistration(registration); if (usableRegistration) { return usableRegistration; } } try { const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); const usableReadyRegistration = await resolveUsableRegistration(readyRegistration); if (usableReadyRegistration) { return usableReadyRegistration; } } catch { // keep polling until timeout } await new Promise((resolve) => { window.setTimeout(resolve, 250); }); } const registrations = await navigator.serviceWorker.getRegistrations(); for (const registration of registrations) { const usableRegistration = await resolveUsableRegistration(registration); if (usableRegistration) { return usableRegistration; } } if (import.meta.env.DEV) { try { await Promise.all(registrations.map((registration) => registration.unregister())); await waitForDuration(300); await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule); if (!registeredRegistration) { await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic); } const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); const usableReadyRegistration = await resolveUsableRegistration(readyRegistration ?? registeredRegistration); if (usableReadyRegistration) { return usableReadyRegistration; } } catch { // ignore and fallthrough to null } } return null; } function waitForDuration(durationMs: number) { return new Promise((resolve) => { window.setTimeout(resolve, durationMs); }); } async function copyText(text: string) { if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } if (typeof document === 'undefined') { throw new Error('클립보드 API를 사용할 수 없습니다.'); } const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } export function MainHeader({ activeTopMenu, sidebarCollapsed, contentExpanded, isMobileViewport, onToggleSidebar, onToggleContentExpanded, onChangeTopMenu, onOpenPlanQuickFilter, }: MainHeaderProps) { void contentExpanded; void onToggleContentExpanded; const screens = useBreakpoint(); const [modalApi, modalContextHolder] = Modal.useModal(); const navigate = useNavigate(); const location = useLocation(); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [activeSettingsModal, setActiveSettingsModal] = useState('appSettings'); const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState('automation'); const [activeAppSettingsSection, setActiveAppSettingsSection] = useState('planDefaults'); const [notificationEnabled, setNotificationEnabled] = useState(false); const [notificationLoading, setNotificationLoading] = useState(false); const [notificationFeedback, setNotificationFeedback] = useState(null); const [notificationCopyFeedback, setNotificationCopyFeedback] = useState(null); const [registeredPwaNotificationToken, setRegisteredPwaNotificationToken] = useState(() => getSavedPwaNotificationToken(), ); const [pwaNotificationTokenInput, setPwaNotificationTokenInput] = useState(() => getSavedPwaNotificationToken()); const [pwaNotificationTokenSaving, setPwaNotificationTokenSaving] = useState(false); const [pwaNotificationTokenFeedback, setPwaNotificationTokenFeedback] = useState(null); const [pwaNotificationTokenCopyFeedback, setPwaNotificationTokenCopyFeedback] = useState(null); const [clientNotificationPermission, setClientNotificationPermission] = useState( () => getClientNotificationPermission(), ); const [webPushConfigured, setWebPushConfigured] = useState(false); const [isStandaloneMode, setIsStandaloneMode] = useState(false); const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot()); const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState(() => chatConnectionGateway.getSharedRuntimeSnapshot(), ); const [activeConversationSnapshot, setActiveConversationSnapshot] = useState(() => getSharedActiveConversationSnapshot(), ); const [isRuntimeModalOpen, setIsRuntimeModalOpen] = useState(false); const [runtimeConversationTitles, setRuntimeConversationTitles] = useState>({}); const [isRuntimeLogDrawerOpen, setIsRuntimeLogDrawerOpen] = useState(false); const [runtimeLogLoading, setRuntimeLogLoading] = useState(false); const [runtimeLogError, setRuntimeLogError] = useState(''); const [runtimeLogDetail, setRuntimeLogDetail] = useState(null); const [updateCheckFeedback, setUpdateCheckFeedback] = useState(null); const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState(null); const [clientResetting, setClientResetting] = useState(false); const [clientResetFeedback, setClientResetFeedback] = useState(null); const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState(null); const [testServerStatus, setTestServerStatus] = useState(null); const [prodServerStatus, setProdServerStatus] = useState(null); const [workServerStatus, setWorkServerStatus] = useState(null); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null); const [serverRestartFeedback, setServerRestartFeedback] = useState(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState(null); const { registeredToken, hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); const [appConfigDraft, setAppConfigDraft] = useState(appConfig); const [appConfigFeedback, setAppConfigFeedback] = useState(null); const [appConfigSaving, setAppConfigSaving] = useState(false); const [appConfigCopyFeedback, setAppConfigCopyFeedback] = useState(null); const [tokenInput, setTokenInput] = useState(''); const [tokenFeedback, setTokenFeedback] = useState(null); const [planShortcutCounts, setPlanShortcutCounts] = useState({ working: 0, releasePendingMain: 0, automationFailed: 0, }); const headerTopMenu = !hasAccess ? 'docs' : activeTopMenu === 'apis' ? 'docs' : activeTopMenu === 'chat' ? 'plans' : activeTopMenu; const notificationStatusClassName = notificationEnabled ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'; const chatConnectionStatusClassName = chatConnection.connectionState === 'connected' ? 'app-header__status-dot--active' : chatConnection.connectionState === 'connecting' ? 'app-header__status-dot--progress' : 'app-header__status-dot--inactive'; const testServerPendingUpdateCount = testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0; const prodServerPendingUpdateCount = prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0; const workServerPendingUpdateCount = workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount; const settingsStatusClassName = totalPendingUpdateCount >= 2 ? 'app-header__status-dot--inactive' : totalPendingUpdateCount === 1 ? 'app-header__status-dot--warning' : 'app-header__status-dot--active'; const settingsStatusLabel = totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태'; const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0; const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0; const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0; const runningRuntimeBadgeLabel = runningRuntimeCount > 99 ? '99+' : String(runningRuntimeCount); const hasPendingRuntimeWork = runningRuntimeCount > 0 || queuedRuntimeCount > 0; const chatConnectionLabelParts = [ chatConnection.connectionState === 'connected' ? 'Codex 채팅 실시간 연결됨' : chatConnection.connectionState === 'connecting' ? 'Codex 채팅 연결 중' : 'Codex 채팅 오프라인', ]; if (runningRuntimeCount > 0) { chatConnectionLabelParts.push(`실행 ${runningRuntimeCount}건`); } if (queuedRuntimeCount > 0) { chatConnectionLabelParts.push(`대기 ${queuedRuntimeCount}건`); } const chatConnectionLabel = chatConnectionLabelParts.join(' · '); const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${ hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : '' }`; const connectionCountBadgeClassName = `app-header__connection-count-badge app-header__connection-count-badge--${chatConnection.connectionState}`; const getConversationLabel = (sessionId: string) => { if (sessionId === activeConversationSnapshot.sessionId && activeConversationSnapshot.title) { return activeConversationSnapshot.title; } return runtimeConversationTitles[sessionId] || sessionId; }; const navigateToConversation = (sessionId: string) => { const searchParams = new URLSearchParams(location.search); searchParams.set('topMenu', 'chat'); searchParams.set('sessionId', sessionId); searchParams.delete('chatView'); searchParams.delete('runtimeRequestId'); navigate({ pathname: buildChatPath('live'), search: `?${searchParams.toString()}`, }); setIsRuntimeModalOpen(false); }; const openRuntimeLog = async (requestId: string) => { setIsRuntimeModalOpen(false); setIsRuntimeLogDrawerOpen(true); setRuntimeLogLoading(true); setRuntimeLogError(''); try { const detail = await fetchChatRuntimeJobDetail(requestId); setRuntimeLogDetail(detail); } catch (error) { setRuntimeLogDetail(null); setRuntimeLogError(error instanceof Error ? error.message : '실행 로그를 불러오지 못했습니다.'); } finally { setRuntimeLogLoading(false); } }; const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey; const canResetClientState = !clientResetting; const canRestartServers = hasAccess && !workServerStatusLoading && !serverRestartingKey; const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat); const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual( appConfig.worklogAutomation, appConfigDraft.worklogAutomation, ); const chatSettingsDiffLabels = getChatSettingsDiffLabels(appConfig.chat, appConfigDraft.chat); const planDefaultSettingsDirty = !arePlanDefaultSettingsEqual(appConfig.planDefaults, appConfigDraft.planDefaults); const planDefaultDiffLabels = getPlanDefaultDiffLabels(appConfig.planDefaults, appConfigDraft.planDefaults); const planCostSettingsDirty = !arePlanCostSettingsEqual(appConfig.planCost, appConfigDraft.planCost); const planCostDiffLabels = getPlanCostDiffLabels(appConfig.planCost, appConfigDraft.planCost); const worklogAutomationDiffLabels = getWorklogAutomationDiffLabels( appConfig.worklogAutomation, appConfigDraft.worklogAutomation, ); const automationNotificationSettingsDirty = !areAutomationNotificationSettingsEqual( appConfig.automation, appConfigDraft.automation, ); const automationNotificationDiffLabels = getAutomationNotificationDiffLabels( appConfig.automation, appConfigDraft.automation, ); const gestureShortcutSettingsDirty = !areGestureShortcutSettingsEqual( appConfig.gestureShortcuts, appConfigDraft.gestureShortcuts, ); const gestureShortcutDiffLabels = getGestureShortcutDiffLabels( appConfig.gestureShortcuts, appConfigDraft.gestureShortcuts, ); const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory); const syncRegisteredWebPushStatus = async () => { const permission = getClientNotificationPermission(); setClientNotificationPermission(permission); setIsStandaloneMode(isStandaloneDisplayMode()); if (permission === 'unsupported') { setNotificationEnabled(false); return; } try { const config = await fetchWebPushConfig(); setWebPushConfigured(Boolean(config.enabled && config.publicKey)); if (!config.enabled || !config.publicKey) { setNotificationEnabled(false); return; } if (Notification.permission !== 'granted') { setNotificationEnabled(false); return; } const registration = await getPushServiceWorkerRegistration(); if (!registration) { setNotificationEnabled(false); return; } let subscription = await registration.pushManager.getSubscription(); if (!subscription) { setNotificationEnabled(false); return; } const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); if (!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)) { await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined); await subscription.unsubscribe().catch(() => undefined); subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: expectedApplicationServerKey, }); } await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId()); setNotificationEnabled(true); setNotificationFeedback(null); } catch { setNotificationEnabled(false); } }; useEffect(() => { void syncRegisteredWebPushStatus(); }, []); useEffect(() => { const handleSync = () => { void syncRegisteredWebPushStatus(); }; window.addEventListener('focus', handleSync); window.addEventListener('pageshow', handleSync); document.addEventListener('visibilitychange', handleSync); if ('serviceWorker' in navigator) { void navigator.serviceWorker.ready .then(() => { void syncRegisteredWebPushStatus(); }) .catch(() => undefined); } return () => { window.removeEventListener('focus', handleSync); window.removeEventListener('pageshow', handleSync); document.removeEventListener('visibilitychange', handleSync); }; }, []); useEffect(() => { return chatConnectionGateway.subscribe(() => { setChatConnection(chatConnectionGateway.getSnapshot()); setChatRuntimeSnapshot(chatConnectionGateway.getSharedRuntimeSnapshot()); }); }, []); useEffect(() => { return subscribeSharedActiveConversation(() => { setActiveConversationSnapshot(getSharedActiveConversationSnapshot()); }); }, []); useEffect(() => { if (!isRuntimeModalOpen) { return; } let cancelled = false; void chatGateway.listConversations() .then((items) => { if (cancelled) { return; } setRuntimeConversationTitles( items.reduce>((acc, item) => { acc[item.sessionId] = item.title?.trim() || item.sessionId; return acc; }, {}), ); }) .catch(() => { if (!cancelled) { setRuntimeConversationTitles({}); } }); return () => { cancelled = true; }; }, [isRuntimeModalOpen]); useEffect(() => { setTokenInput(registeredToken); }, [registeredToken]); useEffect(() => { setAppConfigDraft(appConfig); }, [appConfig]); useEffect(() => { if (getAppSettingsSectionCategory(activeAppSettingsSection) === activeAppSettingsCategory) { return; } const firstSection = APP_SETTINGS_SECTIONS.find((entry) => entry.category === activeAppSettingsCategory); if (firstSection) { setActiveAppSettingsSection(firstSection.value); } }, [activeAppSettingsCategory, activeAppSettingsSection]); useEffect(() => { if (!isSettingsModalAllowed(activeSettingsModal, hasAccess)) { setActiveSettingsModal('token'); } }, [activeSettingsModal, hasAccess]); useEffect(() => { if (!hasAccess) { setTestServerStatus(null); setWorkServerStatus(null); return; } void refreshUpdateTargets(true); }, [hasAccess]); useEffect(() => { if (!settingsModalOpen || activeSettingsModal !== 'update' || !hasAccess) { return; } void refreshUpdateTargets(true); }, [activeSettingsModal, hasAccess, settingsModalOpen]); const ensureClientNotificationPermission = async () => { const currentPermission = getClientNotificationPermission(); setClientNotificationPermission(currentPermission); if (currentPermission === 'unsupported') { setNotificationFeedback({ tone: 'error', message: '현재 브라우저에서는 Web Push를 지원하지 않습니다.' }); return false; } if (!hasSecureOrigin()) { setNotificationFeedback({ tone: 'error', message: '알림은 HTTPS 또는 localhost 환경에서만 사용할 수 있습니다.' }); return false; } if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { setNotificationFeedback({ tone: 'warning', message: '아이폰에서는 홈 화면에 추가한 뒤 실행한 PWA에서만 웹 푸시를 사용할 수 있습니다.', }); return false; } if (currentPermission === 'denied') { setNotificationFeedback({ tone: 'warning', message: '브라우저 또는 기기 설정에서 알림 권한을 먼저 허용해 주세요.', }); return false; } if (currentPermission === 'granted') { setNotificationFeedback(null); return true; } try { const permission = await Notification.requestPermission(); const nextPermission = permission === 'granted' ? 'granted' : permission === 'denied' ? 'denied' : 'default'; setClientNotificationPermission(nextPermission); if (nextPermission !== 'granted') { setNotificationFeedback({ tone: 'warning', message: '알림 권한이 허용되지 않아 알림을 켤 수 없습니다.' }); return false; } setNotificationFeedback(null); return true; } catch { setNotificationFeedback({ tone: 'error', message: '알림 권한 요청 중 오류가 발생했습니다.' }); return false; } }; const syncNotificationEnabled = async (nextEnabled: boolean) => { if (notificationLoading) { return; } const previousEnabled = notificationEnabled; setNotificationCopyFeedback(null); setNotificationLoading(true); setNotificationEnabled(nextEnabled); if (nextEnabled) { setNotificationFeedback({ tone: 'info', message: '알림 권한과 Web Push 등록 상태를 확인하는 중입니다.' }); const permissionGranted = await ensureClientNotificationPermission(); if (!permissionGranted) { setNotificationEnabled(false); setNotificationLoading(false); return; } } try { let registration = await getPushServiceWorkerRegistration(); if (!registration) { await waitForDuration(1500); registration = await getPushServiceWorkerRegistration(); } if (!registration) { if (nextEnabled) { if (import.meta.env.DEV) { const serviceWorkerUrl = '/dev-sw.js?dev-sw'; let swFetchStatus = 'unknown'; try { const response = await fetch(serviceWorkerUrl, { cache: 'no-store' }); swFetchStatus = `${response.status} ${response.statusText}`; } catch { swFetchStatus = 'fetch failed'; } let registrationSummary = 'none'; try { const registrations = await navigator.serviceWorker.getRegistrations(); registrationSummary = registrations.length === 0 ? 'none' : registrations .map((item) => { const states = [ item.active ? `active:${item.active.state}` : 'active:none', item.waiting ? `waiting:${item.waiting.state}` : 'waiting:none', item.installing ? `installing:${item.installing.state}` : 'installing:none', ]; return `${item.scope} [${states.join(', ')}]`; }) .join(' | '); } catch { registrationSummary = 'read failed'; } throw new Error( [ '알림 서비스워커 준비가 아직 끝나지 않았습니다.', `dev-sw 응답: ${swFetchStatus}`, `등록 상태: ${registrationSummary}`, `등록 시도: ${ lastPushSwRegisterAttempts.length === 0 ? 'none' : lastPushSwRegisterAttempts .map((attempt) => `${attempt.mode} ${attempt.url} -> ${attempt.error}`) .join(' | ') }`, `secureContext: ${window.isSecureContext ? 'yes' : 'no'}`, `permission: ${Notification.permission}`, ].join(' '), ); } throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.'); } setNotificationEnabled(false); setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' }); return; } if (nextEnabled) { const config = await fetchWebPushConfig(); setWebPushConfigured(Boolean(config.enabled && config.publicKey)); if (!config.enabled || !config.publicKey) { throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.'); } let subscription = await registration.pushManager.getSubscription(); const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); if ( subscription && !isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey) ) { await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined); await subscription.unsubscribe().catch(() => undefined); subscription = null; } if (!subscription) { subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: expectedApplicationServerKey, }); } await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId()); setNotificationEnabled(true); setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' }); return; } const subscription = await registration.pushManager.getSubscription(); if (subscription) { await unregisterWebPushSubscription(subscription.endpoint); await subscription.unsubscribe(); } setNotificationEnabled(false); setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' }); } catch (error) { setNotificationEnabled(previousEnabled); setNotificationFeedback({ tone: 'error', message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.', }); } finally { setNotificationLoading(false); } }; const handleRegisterPwaNotificationToken = async () => { const trimmedToken = pwaNotificationTokenInput.trim(); setPwaNotificationTokenCopyFeedback(null); if (!trimmedToken) { setPwaNotificationTokenFeedback({ tone: 'warning', message: '등록할 PWA 알림 토큰을 입력해 주세요.' }); return; } setPwaNotificationTokenSaving(true); try { await registerPwaNotificationToken({ token: trimmedToken, deviceId: getSavedNotificationDeviceId(), }); if (registeredPwaNotificationToken && registeredPwaNotificationToken !== trimmedToken) { await unregisterPwaNotificationToken(registeredPwaNotificationToken); } setSavedPwaNotificationToken(trimmedToken); setRegisteredPwaNotificationToken(trimmedToken); void syncAppConfigFromServer(); setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 서버에 등록했습니다.' }); } catch (error) { setPwaNotificationTokenFeedback({ tone: 'error', message: error instanceof Error ? error.message : 'PWA 알림 토큰 등록에 실패했습니다.', }); } finally { setPwaNotificationTokenSaving(false); } }; const handleClearPwaNotificationToken = async () => { const tokenToRemove = registeredPwaNotificationToken || pwaNotificationTokenInput.trim(); setPwaNotificationTokenCopyFeedback(null); if (!tokenToRemove) { setPwaNotificationTokenFeedback({ tone: 'info', message: '제거할 PWA 알림 토큰이 없습니다.' }); return; } setPwaNotificationTokenSaving(true); try { await unregisterPwaNotificationToken(tokenToRemove); setSavedPwaNotificationToken(''); setRegisteredPwaNotificationToken(''); setPwaNotificationTokenInput(''); void syncAppConfigFromServer(); setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA 알림 토큰을 제거했습니다.' }); } catch (error) { setPwaNotificationTokenFeedback({ tone: 'error', message: error instanceof Error ? error.message : 'PWA 알림 토큰 제거에 실패했습니다.', }); } finally { setPwaNotificationTokenSaving(false); } }; const refreshServerStatuses = async () => { const items = await fetchServerCommands(); const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null; const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null; const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null; setTestServerStatus(nextTestServerStatus); setProdServerStatus(nextProdServerStatus); setWorkServerStatus(nextWorkServerStatus); return { test: nextTestServerStatus, prod: nextProdServerStatus, 'work-server': nextWorkServerStatus, } satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>; }; const refreshUpdateTargets = async (silent = false) => { if (!hasAccess) { setTestServerStatus(null); setProdServerStatus(null); setWorkServerStatus(null); if (!silent) { setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' }); } return null; } setWorkServerStatusLoading(true); try { const nextStatuses = await refreshServerStatuses(); if (!silent) { setUpdateCheckFeedback(null); } return nextStatuses; } catch (error) { if (!silent) { setUpdateCheckFeedback({ tone: 'error', message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.', }); } return null; } finally { setWorkServerStatusLoading(false); } }; const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => { for (let attempt = 0; attempt < 16; attempt += 1) { await waitForDuration(2500); try { const nextStatuses = await refreshServerStatuses(); const nextStatus = nextStatuses[key]; if (!nextStatus) { continue; } const restarted = baseline == null || nextStatus.startedAt !== baseline.startedAt || nextStatus.checkedAt !== baseline.checkedAt; if (nextStatus.availability === 'online' && restarted) { return { ok: true, item: nextStatus }; } } catch { // 서버 재기동 중에는 일시적으로 조회가 실패할 수 있습니다. } } return { ok: false, item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus, }; }; const handleResetClientState = async () => { if (clientResetting) { return; } setClientResetCopyFeedback(null); setClientResetFeedback(null); setClientResetting(true); try { const result = await resetNonAuthClientState(); const changedCount = result.removedLocalStorageKeys.length + result.removedSessionStorageKeys.length + result.removedCacheKeys.length + result.unregisteredServiceWorkerCount; setClientResetFeedback({ tone: 'success', message: changedCount > 0 ? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.` : '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.', }); window.setTimeout(() => { window.location.replace(window.location.href); }, 700); } catch (error) { setClientResetFeedback({ tone: 'error', message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.', }); } finally { setClientResetting(false); } }; const restartServerWithVerification = async ( key: 'test' | 'prod' | 'work-server', busyKey: 'test' | 'prod' | 'work-server' | 'all', ) => { const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus; const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; const result = await restartServerCommand(key); if (key === 'test') { setTestServerStatus(result.item); } else if (key === 'prod') { setProdServerStatus(result.item); } else { setWorkServerStatus(result.item); } setServerRestartFeedback({ tone: 'info', message: result.restartState === 'accepted' ? `${targetLabel} 재기동 요청을 접수했습니다. 실제 정상 응답 여부를 확인하는 중입니다.` : `${targetLabel} 재기동을 실행했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`, }); setServerRestartingKey(busyKey); const verified = await waitForServerRestart(key, baseline); if (!verified.ok || !verified.item || verified.item.availability !== 'online') { setServerRestartFeedback({ tone: 'error', message: `${targetLabel} 재기동 후 정상 응답을 확인하지 못했습니다. 상태를 다시 확인해 주세요.`, }); return false; } setServerRestartFeedback({ tone: 'success', message: `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`, }); return true; }; const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => { if (!hasAccess || serverRestartingKey) { return false; } setServerRestartCopyFeedback(null); setServerRestartFeedback(null); setServerRestartingKey(key); try { return await restartServerWithVerification(key, key); } catch (error) { const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; setServerRestartFeedback({ tone: 'error', message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`, }); return false; } finally { setServerRestartingKey(null); } }; const handleConfirmRestartProdServer = () => { if (!hasAccess || serverRestartingKey) { return; } modalApi.confirm({ title: 'PROD 빌드 반영', content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?', okText: '빌드 및 재기동', cancelText: '취소', okButtonProps: { danger: true }, onOk: async () => { await handleRestartSingleServer('prod'); }, }); }; const handleRestartBothServers = async () => { if (!hasAccess || serverRestartingKey) { return; } setServerRestartCopyFeedback(null); setServerRestartFeedback(null); setServerRestartingKey('all'); try { const testOk = await restartServerWithVerification('test', 'all'); if (!testOk) { return; } const workServerOk = await restartServerWithVerification('work-server', 'all'); if (!workServerOk) { return; } setServerRestartFeedback({ tone: 'success', message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.', }); } catch (error) { setServerRestartFeedback({ tone: 'error', message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.', }); } finally { setServerRestartingKey(null); } }; const openSettingsModal = ( modal: SettingsModalKey, nextAppSettingsSection: AppSettingsSectionKey = 'planDefaults', ) => { setSettingsOpen(false); setActiveSettingsModal(isSettingsModalAllowed(modal, hasAccess) ? modal : 'token'); setActiveAppSettingsCategory(getAppSettingsSectionCategory(nextAppSettingsSection)); setActiveAppSettingsSection(nextAppSettingsSection); setAppConfigDraft(appConfig); setAppConfigFeedback(null); setSettingsModalOpen(true); }; const updateAppConfigDraft = (updater: (current: AppConfig) => AppConfig) => { setAppConfigDraft((current) => updater(current)); }; const handleSaveAppConfig = async () => { if (appConfigSaving) { return; } setAppConfigSaving(true); setAppConfigFeedback(null); setAppConfigCopyFeedback(null); try { const savedConfig = activeAppSettingsSection === 'automationNotifications' ? await saveAutomationNotificationPreferenceToServer(appConfigDraft) : await saveAppConfigToServer(appConfigDraft); setStoredAppConfig(savedConfig); setAppConfigFeedback({ tone: 'success', message: activeAppSettingsSection === 'automationNotifications' ? '자동화 알림 설정을 알림 토큰과 클라이언트별로 저장했습니다.' : '앱 설정을 DB에 저장했습니다.', }); } catch (error) { setAppConfigDraft(appConfig); setAppConfigFeedback({ tone: 'error', message: error instanceof Error ? error.message : '앱 설정 저장에 실패했습니다.', }); } finally { setAppConfigSaving(false); } }; const handleResetNotificationIdentity = () => { modalApi.confirm({ title: '알림 클라이언트 초기화', content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.', okText: '초기화', cancelText: '취소', onOk: () => { clearNotificationIdentity(); window.location.reload(); }, }); }; const handleResetAppConfig = () => { setAppConfigDraft(DEFAULT_APP_CONFIG); setAppConfigFeedback({ tone: 'info', message: '추천 기본값으로 되돌렸습니다. 저장 후 반영됩니다.' }); setAppConfigCopyFeedback(null); }; useEffect(() => { void syncAppConfigFromServer().then((synced) => { if (!synced) { setAppConfigFeedback({ tone: 'warning', message: '서버 설정을 불러오지 못해 로컬 설정을 사용합니다.' }); } }); }, []); useEffect(() => { if (!hasAccess || !settingsOpen) { setPlanShortcutCounts({ working: 0, releasePendingMain: 0, automationFailed: 0, }); return; } let cancelled = false; void fetchPlanItems('all') .then((items) => { if (cancelled) { return; } setPlanShortcutCounts({ working: items.filter(isWorkingPlanItem).length, releasePendingMain: items.filter(isReleasePendingMainItem).length, automationFailed: items.filter(isAutomationFailedItem).length, }); }) .catch(() => { if (!cancelled) { setPlanShortcutCounts({ working: 0, releasePendingMain: 0, automationFailed: 0, }); } }); return () => { cancelled = true; }; }, [hasAccess, settingsOpen]); const renderFeedback = ( feedback: InlineFeedback | null, copyFeedback: InlineFeedback | null, setCopyFeedback: (feedback: InlineFeedback | null) => void, ) => { if (!feedback) { return null; } return ( } onClick={() => { void copyText(feedback.message) .then(() => { setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' }); }) .catch(() => { setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' }); }); }} /> } /> {copyFeedback ? : null} ); }; const handleRegisterToken = () => { const trimmedToken = tokenInput.trim(); if (!trimmedToken) { setTokenFeedback({ tone: 'warning', message: '등록할 토큰을 입력해 주세요.' }); return; } if (trimmedToken !== ALLOWED_REGISTRATION_TOKEN) { setTokenFeedback({ tone: 'error', message: '허용되지 않은 토큰입니다.' }); return; } setRegisteredAccessToken(trimmedToken); setTokenFeedback({ tone: 'success', message: '권한 토큰을 등록했습니다.' }); }; const handleClearToken = () => { setRegisteredAccessToken(''); setTokenInput(''); setTokenFeedback({ tone: 'info', message: '권한 토큰을 제거했습니다.' }); }; const settingsTriggerButton = ( ); const chatSettingsPanel = (
최근 문맥 메시지 수 최근 대화 중 몇 개의 메시지를 채팅 문맥으로 참조할지 설정합니다. { setAppConfigDraft((current) => ({ ...current, chat: { ...current.chat, maxContextMessages: typeof value === 'number' && Number.isFinite(value) ? Math.min(50, Math.max(1, Math.round(value))) : DEFAULT_APP_CONFIG.chat.maxContextMessages, }, })); }} />
최근 문맥 글자 수 최근 대화 전체에서 참조할 최대 글자 수입니다. 초과하면 전송 전에 확인 모달이 표시됩니다. { setAppConfigDraft((current) => ({ ...current, chat: { ...current.chat, maxContextChars: typeof value === 'number' && Number.isFinite(value) ? Math.min(20_000, Math.max(500, Math.round(value))) : DEFAULT_APP_CONFIG.chat.maxContextChars, }, })); }} />
); const planDefaultsPanel = ( {renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)} { updateAppConfigDraft((current) => ({ ...current, planDefaults: { ...current.planDefaults, jangsingProcessingRequired: event.target.checked, }, })); }} > 기능동작확인 기본값을 완료로 설정 { updateAppConfigDraft((current) => ({ ...current, planDefaults: { ...current.planDefaults, autoDeployToMain: event.target.checked, }, })); }} > 메인까지 자동등록 { updateAppConfigDraft((current) => ({ ...current, planDefaults: { ...current.planDefaults, openEditorAfterCreate: event.target.checked, }, })); }} > 새 항목 등록 후 편집기 자동 열기 ); const planCostPanel = ( {renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)} 백만 토큰당 기준 비용 { updateAppConfigDraft((current) => ({ ...current, planCost: { ...current.planCost, baseCostPerMillionTokens: typeof value === 'number' ? value : current.planCost.baseCostPerMillionTokens, }, })); }} /> 예: {formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)} 기준이면 누적 토큰 100만개에서 재처리 가산 전 기본 비용이 동일하게 계산됩니다. 재처리 가산 비율 { updateAppConfigDraft((current) => ({ ...current, planCost: { ...current.planCost, retryCostMultiplierPercent: typeof value === 'number' ? value : current.planCost.retryCostMultiplierPercent, }, })); }} /> 재처리 1회마다 기본 비용에 가산할 비율입니다. 시간 가산 비율 { updateAppConfigDraft((current) => ({ ...current, gestureShortcuts: { ...current.gestureShortcuts, openSearch: event.target.value, }, })); }} /> 통합 검색을 엽니다. 예: `Mod+K`, `Alt+/` 오른쪽 가운데 왼쪽 당기기 액션 { updateAppConfigDraft((current) => ({ ...current, gestureShortcuts: { ...current.gestureShortcuts, openWindowSearch: event.target.value, }, })); }} /> 선택한 항목을 `Window UI`로 여는 검색을 엽니다. 예: `Mod+Shift+K` ); const settingsMenu = (
{hasAccess ? ( <> ) : null}
); return ( <> {modalContextHolder}
settingsMenu} > {settingsTriggerButton} ) : ( tokenTriggerButton )}
{ setIsRuntimeModalOpen(false); }} >
실행 {runningRuntimeCount}
대기 {queuedRuntimeCount}
활성 {runtimeSessionCount}
실행 중 요청 {chatRuntimeSnapshot && chatRuntimeSnapshot.running.length > 0 ? ( chatRuntimeSnapshot.running.map((item) => (
{getConversationLabel(item.sessionId)} {item.summary || '요약 없음'}
실행 {formatRuntimeRelativeLabel(item.startedAt)}
)) ) : ( 현재 실행 중인 요청이 없습니다. )}
대기 요청 {chatRuntimeSnapshot && chatRuntimeSnapshot.queued.length > 0 ? ( chatRuntimeSnapshot.queued.map((item) => (
{getConversationLabel(item.sessionId)} {item.summary || '요약 없음'}
대기 {formatRuntimeRelativeLabel(item.enqueuedAt)}
)) ) : ( 현재 대기 중인 요청이 없습니다. )}
최근 작업 {chatRuntimeSnapshot && chatRuntimeSnapshot.recent.length > 0 ? ( chatRuntimeSnapshot.recent.map((item) => (
{getConversationLabel(item.sessionId)} {item.summary || '요약 없음'}
{buildRuntimeTerminalLabel(item.terminalStatus)} {formatRuntimeRelativeLabel(item.lastUpdatedAt)}
)) ) : ( 최근 종료된 작업이 없습니다. )}
{ setIsRuntimeLogDrawerOpen(false); setRuntimeLogDetail(null); setRuntimeLogError(''); }} styles={{ body: { padding: 24, }, }} > {runtimeLogLoading ? ( 로그를 불러오는 중입니다. ) : runtimeLogError ? ( {runtimeLogError} ) : runtimeLogDetail ? ( {runtimeLogDetail.item?.sessionId ? ( ) : null}
요청: {runtimeLogDetail.item?.requestId ?? '-'} 세션: {runtimeLogDetail.item?.sessionId ?? '-'} 마지막 갱신: {formatRuntimeTimestamp(runtimeLogDetail.lastUpdatedAt)} 종료 상태: {buildRuntimeTerminalLabel(runtimeLogDetail.terminalStatus)}
{runtimeLogDetail.item?.summary ?? '요약 없음'}
              {runtimeLogDetail.logs.length > 0 ? runtimeLogDetail.logs.join('\n') : '아직 기록된 로그가 없습니다.'}
            
) : ( 표시할 로그가 없습니다. )}
{ setSettingsModalOpen(false); }} > {activeSettingsModal === 'appSettings' ? ( <> { setActiveAppSettingsCategory(value as AppSettingsCategoryKey); }} />