Files
ai-code-app/src/app/main/MainHeader.tsx

3320 lines
121 KiB
TypeScript
Executable File

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<PlanCostTimeUnit, string> = {
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<null>((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<void>((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<void>((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<SettingsModalKey>('appSettings');
const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState<AppSettingsCategoryKey>('automation');
const [activeAppSettingsSection, setActiveAppSettingsSection] = useState<AppSettingsSectionKey>('planDefaults');
const [notificationEnabled, setNotificationEnabled] = useState(false);
const [notificationLoading, setNotificationLoading] = useState(false);
const [notificationFeedback, setNotificationFeedback] = useState<InlineFeedback | null>(null);
const [notificationCopyFeedback, setNotificationCopyFeedback] = useState<InlineFeedback | null>(null);
const [registeredPwaNotificationToken, setRegisteredPwaNotificationToken] = useState(() =>
getSavedPwaNotificationToken(),
);
const [pwaNotificationTokenInput, setPwaNotificationTokenInput] = useState(() => getSavedPwaNotificationToken());
const [pwaNotificationTokenSaving, setPwaNotificationTokenSaving] = useState(false);
const [pwaNotificationTokenFeedback, setPwaNotificationTokenFeedback] = useState<InlineFeedback | null>(null);
const [pwaNotificationTokenCopyFeedback, setPwaNotificationTokenCopyFeedback] = useState<InlineFeedback | null>(null);
const [clientNotificationPermission, setClientNotificationPermission] = useState<ClientNotificationPermissionState>(
() => getClientNotificationPermission(),
);
const [webPushConfigured, setWebPushConfigured] = useState(false);
const [isStandaloneMode, setIsStandaloneMode] = useState(false);
const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot());
const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() =>
chatConnectionGateway.getSharedRuntimeSnapshot(),
);
const [activeConversationSnapshot, setActiveConversationSnapshot] = useState(() =>
getSharedActiveConversationSnapshot(),
);
const [isRuntimeModalOpen, setIsRuntimeModalOpen] = useState(false);
const [runtimeConversationTitles, setRuntimeConversationTitles] = useState<Record<string, string>>({});
const [isRuntimeLogDrawerOpen, setIsRuntimeLogDrawerOpen] = useState(false);
const [runtimeLogLoading, setRuntimeLogLoading] = useState(false);
const [runtimeLogError, setRuntimeLogError] = useState('');
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
const [updateCheckFeedback, setUpdateCheckFeedback] = useState<InlineFeedback | null>(null);
const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState<InlineFeedback | null>(null);
const [clientResetting, setClientResetting] = useState(false);
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'prod' | 'work-server' | 'all' | null>(null);
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
const { registeredToken, hasAccess } = useTokenAccess();
const appConfig = useAppConfig();
const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig);
const [appConfigFeedback, setAppConfigFeedback] = useState<InlineFeedback | null>(null);
const [appConfigSaving, setAppConfigSaving] = useState(false);
const [appConfigCopyFeedback, setAppConfigCopyFeedback] = useState<InlineFeedback | null>(null);
const [tokenInput, setTokenInput] = useState('');
const [tokenFeedback, setTokenFeedback] = useState<InlineFeedback | null>(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<Record<string, string>>((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 (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
showIcon
type={feedback.tone}
message={feedback.message}
action={
<Button
type="text"
size="small"
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(feedback.message)
.then(() => {
setCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
})
.catch(() => {
setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
}}
/>
}
/>
{copyFeedback ? <Alert showIcon type={copyFeedback.tone} message={copyFeedback.message} /> : null}
</Space>
);
};
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 = (
<Button
type="text"
aria-label={`설정 · ${settingsStatusLabel}`}
icon={
<span className="app-header__settings-icon" aria-hidden="true">
<SettingOutlined />
<span className={`app-header__status-dot ${settingsStatusClassName}`} />
</span>
}
/>
);
const tokenTriggerButton = (
<Button
type="text"
aria-label={`권한 · ${hasAccess ? '허용됨' : '차단됨'}`}
icon={
<span className="app-header__settings-icon" aria-hidden="true">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
hasAccess ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'
}`}
/>
</span>
}
onClick={() => {
openSettingsModal('token');
}}
/>
);
const worklogAutomationPanel = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
showIcon
type="info"
message="업무일지 자동화 기본값을 설정합니다."
description="현재는 앱 설정의 기준값을 저장하는 단계이며, 자동 생성/캡처 흐름에서 공통 기본값으로 사용하기 위한 설정입니다."
/>
<Alert
showIcon
type={worklogAutomationSettingsDirty ? 'warning' : 'success'}
message={
worklogAutomationSettingsDirty
? 'DB 저장값과 편집 중인 업무일지 자동화 설정이 다릅니다.'
: '업무일지 자동화 설정이 현재 DB 저장값과 같습니다.'
}
description={
worklogAutomationSettingsDirty
? `변경 항목: ${worklogAutomationDiffLabels.join(', ')} / DB 저장값 기준: ${
appConfig.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
}, 실행 시각 ${appConfig.worklogAutomation.dailyCreateTime}, 템플릿 ${
appConfig.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
} / 편집 중: ${
appConfigDraft.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
}, 실행 시각 ${appConfigDraft.worklogAutomation.dailyCreateTime}, 템플릿 ${
appConfigDraft.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
}`
: `DB 저장값 기준: ${
appConfig.worklogAutomation.autoCreateDailyWorklog ? '자동 생성 On' : '자동 생성 Off'
}, 실행 시각 ${appConfig.worklogAutomation.dailyCreateTime}, 템플릿 ${
appConfig.worklogAutomation.template === 'detailed' ? '상세형' : '간단형'
}`
}
/>
{appConfigFeedback ? (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
showIcon
type={appConfigFeedback.tone}
message={appConfigFeedback.message}
action={
<Button
type="text"
size="small"
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
}}
/>
}
/>
{appConfigCopyFeedback ? (
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
) : null}
</Space>
) : null}
<Checkbox
checked={appConfigDraft.worklogAutomation.autoCreateDailyWorklog}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
autoCreateDailyWorklog: event.target.checked,
},
}));
}}
>
</Checkbox>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Input
type="time"
value={appConfigDraft.worklogAutomation.dailyCreateTime}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
dailyCreateTime: event.target.value || DEFAULT_APP_CONFIG.worklogAutomation.dailyCreateTime,
},
}));
}}
/>
<Text type="secondary"> .</Text>
</Space>
<Text type="secondary"> .</Text>
<Checkbox
checked={appConfigDraft.worklogAutomation.includeScreenshots}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
includeScreenshots: event.target.checked,
},
}));
}}
>
</Checkbox>
<Checkbox
checked={appConfigDraft.worklogAutomation.includeChangedFiles}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
includeChangedFiles: event.target.checked,
},
}));
}}
>
</Checkbox>
<Checkbox
checked={appConfigDraft.worklogAutomation.includeCommandLogs}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
includeCommandLogs: event.target.checked,
},
}));
}}
>
</Checkbox>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> 릿</Text>
<Segmented
block
value={appConfigDraft.worklogAutomation.template}
options={[
{ label: '상세형', value: 'detailed' },
{ label: '간단형', value: 'simple' },
]}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
worklogAutomation: {
...current.worklogAutomation,
template: value as AppConfig['worklogAutomation']['template'],
},
}));
}}
/>
<Text type="secondary">
, , , .
</Text>
</Space>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
</Button>
<Button onClick={handleResetAppConfig}> </Button>
<Button onClick={handleResetNotificationIdentity}> </Button>
</Space>
</Space>
);
const chatSettingsPanel = (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type={chatSettingsDirty ? 'warning' : 'success'}
showIcon
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
description={
chatSettingsDirty
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}`
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조`
}
/>
<div>
<Text strong> </Text>
<Paragraph type="secondary"> .</Paragraph>
<InputNumber
min={1}
max={50}
value={appConfigDraft.chat.maxContextMessages}
onChange={(value) => {
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,
},
}));
}}
/>
</div>
<div>
<Text strong> </Text>
<Paragraph type="secondary"> . .</Paragraph>
<InputNumber
min={500}
max={20000}
step={100}
value={appConfigDraft.chat.maxContextChars}
onChange={(value) => {
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,
},
}));
}}
/>
</div>
</Space>
);
const planDefaultsPanel = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
showIcon
type="info"
message="작업 자동화 기본값을 설정합니다."
description="새 작업 요청을 등록할 때 기본으로 채워지는 자동화 옵션입니다."
/>
<Alert
showIcon
type={planDefaultSettingsDirty ? 'warning' : 'success'}
message={
planDefaultSettingsDirty
? 'DB 저장값과 편집 중인 자동화 기본값이 다릅니다.'
: '자동화 기본값이 현재 DB 저장값과 같습니다.'
}
description={
planDefaultSettingsDirty
? `변경 항목: ${planDefaultDiffLabels.join(', ')} / DB 저장값 기준: 기능동작확인 ${
appConfig.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
}, ${appConfig.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'}, 등록 후 편집기 ${
appConfig.planDefaults.openEditorAfterCreate ? '열기' : '닫기'
} / 편집 중: 기능동작확인 ${
appConfigDraft.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
}, ${
appConfigDraft.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'
}, 등록 후 편집기 ${appConfigDraft.planDefaults.openEditorAfterCreate ? '열기' : '닫기'}`
: `DB 저장값 기준: 기능동작확인 ${
appConfig.planDefaults.jangsingProcessingRequired ? '완료' : '오동작'
}, ${appConfig.planDefaults.autoDeployToMain ? '메인까지 자동등록' : 'release만 반영'}, 등록 후 편집기 ${
appConfig.planDefaults.openEditorAfterCreate ? '열기' : '닫기'
}`
}
/>
{renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)}
<Checkbox
checked={appConfigDraft.planDefaults.jangsingProcessingRequired}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
planDefaults: {
...current.planDefaults,
jangsingProcessingRequired: event.target.checked,
},
}));
}}
>
</Checkbox>
<Checkbox
checked={appConfigDraft.planDefaults.autoDeployToMain}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
planDefaults: {
...current.planDefaults,
autoDeployToMain: event.target.checked,
},
}));
}}
>
</Checkbox>
<Checkbox
checked={appConfigDraft.planDefaults.openEditorAfterCreate}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
planDefaults: {
...current.planDefaults,
openEditorAfterCreate: event.target.checked,
},
}));
}}
>
</Checkbox>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
</Button>
<Button onClick={handleResetAppConfig}> </Button>
</Space>
</Space>
);
const planCostPanel = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
showIcon
type="info"
message="자동화 비용 표시 기준을 설정합니다."
description="진행 중 작업과 완료된 작업 모두 누적 토큰 사용량, 재처리 횟수, 종료 시각 기준 처리 시간을 반영해 예상 비용을 계산합니다. 재처리/시간 가산 비율과 상태 구간 기준값은 아래에서 직접 조정할 수 있습니다."
/>
<Alert
showIcon
type={planCostSettingsDirty ? 'warning' : 'success'}
message={
planCostSettingsDirty ? 'DB 저장값과 편집 중인 비용 설정이 다릅니다.' : '비용 설정이 현재 DB 저장값과 같습니다.'
}
description={
planCostSettingsDirty
? `변경 항목: ${planCostDiffLabels.join(', ')} / DB 저장값 기준: 백만 토큰당 ${formatCostAmount(
appConfig.planCost.baseCostPerMillionTokens,
)}, 재처리 ${appConfig.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfig.planCost)}, 관심 ${appConfig.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfig.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfig.planCost.highCostThresholdMultiplier}배 / 편집 중: 백만 토큰당 ${formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)}, 재처리 ${appConfigDraft.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfigDraft.planCost)}, 관심 ${appConfigDraft.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfigDraft.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfigDraft.planCost.highCostThresholdMultiplier}`
: `DB 저장값 기준: 백만 토큰당 ${formatCostAmount(appConfig.planCost.baseCostPerMillionTokens)}, 재처리 ${appConfig.planCost.retryCostMultiplierPercent}%, ${formatPlanCostTimeMultiplierLabel(appConfig.planCost)}, 관심 ${appConfig.planCost.attentionCostThresholdMultiplier}배, 주의 ${appConfig.planCost.warningCostThresholdMultiplier}배, 높음 ${appConfig.planCost.highCostThresholdMultiplier}`
}
/>
{renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)}
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<InputNumber
min={100}
max={1000000}
step={100}
style={{ width: '100%' }}
addonAfter="원"
value={appConfigDraft.planCost.baseCostPerMillionTokens}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
baseCostPerMillionTokens: typeof value === 'number' ? value : current.planCost.baseCostPerMillionTokens,
},
}));
}}
/>
<Text type="secondary">
: {formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)} 100
.
</Text>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<InputNumber
min={0}
max={500}
step={1}
style={{ width: '100%' }}
addonAfter="%"
value={appConfigDraft.planCost.retryCostMultiplierPercent}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
retryCostMultiplierPercent:
typeof value === 'number' ? value : current.planCost.retryCostMultiplierPercent,
},
}));
}}
/>
<Text type="secondary"> 1 .</Text>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Select
style={{ width: '100%' }}
value={appConfigDraft.planCost.timeCostUnit}
options={[
{ value: 'hour', label: '시간 기준' },
{ value: 'minute', label: '분 기준' },
{ value: 'second', label: '초 기준' },
]}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
timeCostUnit: value,
},
}));
}}
/>
<InputNumber
min={0}
max={500}
step={1}
style={{ width: '100%' }}
addonAfter="%"
value={appConfigDraft.planCost.hourlyCostMultiplierPercent}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
hourlyCostMultiplierPercent:
typeof value === 'number' ? value : current.planCost.hourlyCostMultiplierPercent,
},
}));
}}
/>
<Text type="secondary">
{PLAN_COST_TIME_UNIT_LABELS[appConfigDraft.planCost.timeCostUnit]}
.
</Text>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Space direction={screens.sm ? 'horizontal' : 'vertical'} size={8} style={{ width: '100%' }}>
<InputNumber
min={0.1}
max={100}
step={0.1}
style={{ width: screens.sm ? '33.33%' : '100%' }}
addonBefore="관심"
addonAfter="배"
value={appConfigDraft.planCost.attentionCostThresholdMultiplier}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
attentionCostThresholdMultiplier:
typeof value === 'number' ? value : current.planCost.attentionCostThresholdMultiplier,
},
}));
}}
/>
<InputNumber
min={0.1}
max={100}
step={0.1}
style={{ width: screens.sm ? '33.33%' : '100%' }}
addonBefore="주의"
addonAfter="배"
value={appConfigDraft.planCost.warningCostThresholdMultiplier}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
warningCostThresholdMultiplier:
typeof value === 'number' ? value : current.planCost.warningCostThresholdMultiplier,
},
}));
}}
/>
<InputNumber
min={0.1}
max={100}
step={0.1}
style={{ width: screens.sm ? '33.33%' : '100%' }}
addonBefore="높음"
addonAfter="배"
value={appConfigDraft.planCost.highCostThresholdMultiplier}
onChange={(value) => {
updateAppConfigDraft((current) => ({
...current,
planCost: {
...current.planCost,
highCostThresholdMultiplier:
typeof value === 'number' ? value : current.planCost.highCostThresholdMultiplier,
},
}));
}}
/>
</Space>
<Text type="secondary"> , , .</Text>
<Text type="secondary"> : {getPlanCostThresholdPreview(appConfigDraft.planCost)}</Text>
</Space>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
</Button>
<Button onClick={handleResetAppConfig}> </Button>
</Space>
</Space>
);
const automationNotificationPanel = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
showIcon
type="info"
message="자동화 알림을 항목별로 설정합니다."
description="알림 사용이 켜진 기기에서만 Web Push를 받을 수 있습니다."
/>
<Alert
showIcon
type={automationNotificationSettingsDirty ? 'warning' : 'success'}
message={
automationNotificationSettingsDirty
? 'DB 저장값과 편집 중인 자동화 알림 설정이 다릅니다.'
: '자동화 알림 설정이 현재 DB 저장값과 같습니다.'
}
description={
automationNotificationSettingsDirty
? `변경 항목: ${automationNotificationDiffLabels.join(', ')}`
: `알림 항목: ${AUTOMATION_NOTIFICATION_OPTIONS.map((option) => option.label).join(', ')}`
}
/>
{appConfigFeedback ? (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
showIcon
type={appConfigFeedback.tone}
message={appConfigFeedback.message}
action={
<Button
type="text"
size="small"
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
}}
/>
}
/>
{appConfigCopyFeedback ? (
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
) : null}
</Space>
) : null}
<Space direction="vertical" size={10} style={{ width: '100%' }}>
{AUTOMATION_NOTIFICATION_OPTIONS.map((option) => (
<Checkbox
key={option.key}
checked={appConfigDraft.automation[option.key]}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
automation: {
...current.automation,
[option.key]: event.target.checked,
},
}));
}}
>
<Space direction="vertical" size={0}>
<Text>{option.label}</Text>
<Text type="secondary">{option.description}</Text>
</Space>
</Checkbox>
))}
</Space>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
</Button>
<Button onClick={handleResetAppConfig}> </Button>
</Space>
</Space>
);
const gestureShortcutsPanel = (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
showIcon
type="info"
message="모바일 제스처와 같은 액션을 키보드 단축키로도 실행합니다."
description="저장 후 즉시 반영됩니다. `Mod`는 macOS에서는 Command, Windows/Linux에서는 Ctrl로 처리합니다."
/>
<Alert
showIcon
type={gestureShortcutSettingsDirty ? 'warning' : 'success'}
message={
gestureShortcutSettingsDirty
? 'DB 저장값과 편집 중인 제스처 단축키 설정이 다릅니다.'
: '제스처 단축키 설정이 현재 DB 저장값과 같습니다.'
}
description={
gestureShortcutSettingsDirty
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, Window UI 검색 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, Window UI 검색 ${appConfig.gestureShortcuts.openWindowSearch}`
}
/>
{appConfigFeedback ? (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Alert
showIcon
type={appConfigFeedback.tone}
message={appConfigFeedback.message}
action={
<Button
type="text"
size="small"
aria-label="메시지 복사"
icon={<CopyOutlined />}
onClick={() => {
void copyText(appConfigFeedback.message)
.then(() => {
setAppConfigCopyFeedback({ tone: 'success', message: '메시지를 복사했습니다.' });
})
.catch(() => {
setAppConfigCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' });
});
}}
/>
}
/>
{appConfigCopyFeedback ? (
<Alert showIcon type={appConfigCopyFeedback.tone} message={appConfigCopyFeedback.message} />
) : null}
</Space>
) : null}
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Input
value={appConfigDraft.gestureShortcuts.openSearch}
placeholder={DEFAULT_APP_CONFIG.gestureShortcuts.openSearch}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
gestureShortcuts: {
...current.gestureShortcuts,
openSearch: event.target.value,
},
}));
}}
/>
<Text type="secondary"> . : `Mod+K`, `Alt+/`</Text>
</Space>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Input
value={appConfigDraft.gestureShortcuts.openWindowSearch}
placeholder={DEFAULT_APP_CONFIG.gestureShortcuts.openWindowSearch}
onChange={(event) => {
updateAppConfigDraft((current) => ({
...current,
gestureShortcuts: {
...current.gestureShortcuts,
openWindowSearch: event.target.value,
},
}));
}}
/>
<Text type="secondary"> `Window UI` . : `Mod+Shift+K`</Text>
</Space>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
</Button>
<Button onClick={handleResetAppConfig}> </Button>
</Space>
</Space>
);
const settingsMenu = (
<div className="app-header__settings-menu">
{hasAccess ? (
<>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('working');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.working > 0
? 'app-header__status-dot--active'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.working}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('release-pending-main');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.releasePendingMain > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
release
<Text type="secondary"> {planShortcutCounts.releasePendingMain}</Text>
</span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
setSettingsOpen(false);
onOpenPlanQuickFilter('automation-failed');
}}
>
<span className="app-header__settings-icon">
<ReloadOutlined />
<span
className={`app-header__status-dot ${
planShortcutCounts.automationFailed > 0
? 'app-header__status-dot--warning'
: 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label">
<Text type="secondary"> {planShortcutCounts.automationFailed}</Text>
</span>
</button>
</>
) : null}
<button
type="button"
className="app-header__settings-item"
disabled={!hasAccess}
onClick={() => {
openSettingsModal('appSettings', 'planDefaults');
}}
>
<span className="app-header__settings-icon">
<SettingOutlined />
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
</span>
<span className="app-header__settings-label"> </span>
</button>
<button
type="button"
className="app-header__settings-item"
disabled={!hasAccess}
onClick={() => {
openSettingsModal('notification');
}}
>
<span className="app-header__settings-icon">
<BellOutlined />
<span
className={`app-header__status-dot ${notificationStatusClassName}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label"></span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
openSettingsModal('token');
}}
>
<span className="app-header__settings-icon">
<ProfileOutlined />
<span
className={`app-header__status-dot ${
hasAccess ? 'app-header__status-dot--active' : 'app-header__status-dot--inactive'
}`}
aria-hidden="true"
/>
</span>
<span className="app-header__settings-label"></span>
</button>
<button
type="button"
className="app-header__settings-item"
onClick={() => {
openSettingsModal('update');
}}
>
<span className="app-header__settings-icon">
<ReloadOutlined />
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
</span>
<span className="app-header__settings-label"></span>
</button>
</div>
);
return (
<>
{modalContextHolder}
<Header className="app-header">
<Space size={12} className="app-header__row">
<Space size={12} className="app-header__menu-side">
<Button
type="text"
icon={sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggleSidebar}
/>
<Segmented
value={headerTopMenu}
options={
hasAccess
? [
{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> },
{ label: '작업', value: 'plans', icon: <ProfileOutlined /> },
]
: [{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> }]
}
onChange={(value) => {
onChangeTopMenu(value as 'docs' | 'plans');
}}
/>
</Space>
<Space size={4} className="app-header__actions">
{hasAccess ? (
<>
<button
type="button"
className={connectionIndicatorClassName}
aria-label={chatConnectionLabel}
title={chatConnectionLabel}
onClick={() => {
setIsRuntimeModalOpen(true);
}}
>
<span className="app-header__settings-icon" aria-hidden="true">
<ApiOutlined />
<span className={`app-header__status-dot ${chatConnectionStatusClassName}`} />
</span>
{runningRuntimeCount > 0 ? (
<span
className={connectionCountBadgeClassName}
aria-label={`실행 중 요청 ${runningRuntimeCount}`}
title={`실행 중 요청 ${runningRuntimeCount}`}
>
{runningRuntimeBadgeLabel}
</span>
) : null}
</button>
<HeaderMessageCenter isMobileViewport={isMobileViewport} />
<Dropdown
trigger={['click']}
open={settingsOpen}
onOpenChange={setSettingsOpen}
placement="bottomRight"
popupRender={() => settingsMenu}
>
{settingsTriggerButton}
</Dropdown>
</>
) : (
tokenTriggerButton
)}
</Space>
</Space>
</Header>
<Modal
title="Codex 런타임"
open={isRuntimeModalOpen}
footer={null}
onCancel={() => {
setIsRuntimeModalOpen(false);
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div className="app-header__runtime-summary">
<div className="app-header__runtime-summary-card" title="실행 중 요청">
<LoadingOutlined />
<Text type="secondary"></Text>
<Text strong>{runningRuntimeCount}</Text>
</div>
<div className="app-header__runtime-summary-card" title="대기 요청">
<ClockCircleOutlined />
<Text type="secondary"></Text>
<Text strong>{queuedRuntimeCount}</Text>
</div>
<div className="app-header__runtime-summary-card" title="활성 세션">
<ProfileOutlined />
<Text type="secondary"></Text>
<Text strong>{runtimeSessionCount}</Text>
</div>
</div>
<Alert
type={
chatConnection.connectionState === 'connected'
? 'success'
: chatConnection.connectionState === 'connecting'
? 'info'
: 'warning'
}
showIcon
message={
chatConnection.connectionState === 'connected'
? '웹소켓으로 실시간 반영 중입니다.'
: chatConnection.connectionState === 'connecting'
? '웹소켓을 다시 연결하는 중입니다.'
: '웹소켓 연결이 끊겨 최신 상태가 지연될 수 있습니다.'
}
description={
chatRuntimeSnapshot?.generatedAt
? `마지막 반영 ${formatRuntimeRelativeLabel(chatRuntimeSnapshot.generatedAt)}`
: '아직 수신된 런타임 정보가 없습니다.'
}
/>
<div className="app-header__runtime-list">
<Text strong> </Text>
{chatRuntimeSnapshot && chatRuntimeSnapshot.running.length > 0 ? (
chatRuntimeSnapshot.running.map((item) => (
<div key={item.requestId} className="app-header__runtime-list-item">
<div className="app-header__runtime-list-row">
<div className="app-header__runtime-list-copy">
<Text strong>{getConversationLabel(item.sessionId)}</Text>
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
<div className="app-header__runtime-meta">
<span className="app-header__runtime-badge">
<LoadingOutlined />
</span>
<span className="app-header__runtime-badge">{formatRuntimeRelativeLabel(item.startedAt)}</span>
</div>
</div>
<Space size={8} wrap>
<Button
size="small"
type="default"
onClick={() => {
void openRuntimeLog(item.requestId);
}}
>
</Button>
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
</Button>
</Space>
</div>
</div>
))
) : (
<Text type="secondary"> .</Text>
)}
</div>
<div className="app-header__runtime-list">
<Text strong> </Text>
{chatRuntimeSnapshot && chatRuntimeSnapshot.queued.length > 0 ? (
chatRuntimeSnapshot.queued.map((item) => (
<div key={item.requestId} className="app-header__runtime-list-item">
<div className="app-header__runtime-list-row">
<div className="app-header__runtime-list-copy">
<Text strong>{getConversationLabel(item.sessionId)}</Text>
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
<div className="app-header__runtime-meta">
<span className="app-header__runtime-badge">
<ClockCircleOutlined />
</span>
<span className="app-header__runtime-badge">{formatRuntimeRelativeLabel(item.enqueuedAt)}</span>
</div>
</div>
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
</Button>
</div>
</div>
))
) : (
<Text type="secondary"> .</Text>
)}
</div>
<div className="app-header__runtime-list">
<Text strong> </Text>
{chatRuntimeSnapshot && chatRuntimeSnapshot.recent.length > 0 ? (
chatRuntimeSnapshot.recent.map((item) => (
<div key={`recent-${item.requestId}-${item.lastUpdatedAt}`} className="app-header__runtime-list-item">
<div className="app-header__runtime-list-row">
<div className="app-header__runtime-list-copy">
<Text strong>{getConversationLabel(item.sessionId)}</Text>
<Text className="app-header__runtime-summary-text">{item.summary || '요약 없음'}</Text>
<div className="app-header__runtime-meta">
<span className="app-header__runtime-badge">{buildRuntimeTerminalLabel(item.terminalStatus)}</span>
<span className="app-header__runtime-badge">
{formatRuntimeRelativeLabel(item.lastUpdatedAt)}
</span>
</div>
</div>
<Space size={8} wrap>
<Button
size="small"
type="default"
onClick={() => {
void openRuntimeLog(item.requestId);
}}
>
</Button>
<Button size="small" type="default" onClick={() => navigateToConversation(item.sessionId)}>
</Button>
</Space>
</div>
</div>
))
) : (
<Text type="secondary"> .</Text>
)}
</div>
</Space>
</Modal>
<Drawer
open={isRuntimeLogDrawerOpen}
title="실행 로그"
placement="right"
width="100vw"
onClose={() => {
setIsRuntimeLogDrawerOpen(false);
setRuntimeLogDetail(null);
setRuntimeLogError('');
}}
styles={{
body: {
padding: 24,
},
}}
>
{runtimeLogLoading ? (
<Text type="secondary"> .</Text>
) : runtimeLogError ? (
<Text type="danger">{runtimeLogError}</Text>
) : runtimeLogDetail ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{runtimeLogDetail.item?.sessionId ? (
<Button
size="small"
type="default"
style={{ alignSelf: 'flex-start' }}
onClick={() => {
navigateToConversation(runtimeLogDetail.item?.sessionId ?? '');
setIsRuntimeLogDrawerOpen(false);
}}
>
</Button>
) : null}
<div className="app-chat-runtime__job-meta">
<Text type="secondary">: {runtimeLogDetail.item?.requestId ?? '-'}</Text>
<Text type="secondary">: {runtimeLogDetail.item?.sessionId ?? '-'}</Text>
<Text type="secondary"> : {formatRuntimeTimestamp(runtimeLogDetail.lastUpdatedAt)}</Text>
<Text type="secondary">
: {buildRuntimeTerminalLabel(runtimeLogDetail.terminalStatus)}
</Text>
</div>
<Paragraph className="app-chat-runtime__job-summary">
{runtimeLogDetail.item?.summary ?? '요약 없음'}
</Paragraph>
<pre className="app-chat-runtime__log-viewer">
{runtimeLogDetail.logs.length > 0 ? runtimeLogDetail.logs.join('\n') : '아직 기록된 로그가 없습니다.'}
</pre>
</Space>
) : (
<Text type="secondary"> .</Text>
)}
</Drawer>
<Modal
title={getSettingsModalTitle(activeSettingsModal)}
open={settingsModalOpen}
footer={null}
confirmLoading={notificationLoading}
onCancel={() => {
setSettingsModalOpen(false);
}}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{activeSettingsModal === 'appSettings' ? (
<>
<Segmented
block
value={activeAppSettingsCategory}
options={[...APP_SETTINGS_CATEGORIES]}
onChange={(value) => {
setActiveAppSettingsCategory(value as AppSettingsCategoryKey);
}}
/>
<Select
value={activeAppSettingsSection}
options={activeAppSettingsSectionOptions}
onChange={(value) => {
setActiveAppSettingsSection(value);
}}
/>
{activeAppSettingsSection === 'planDefaults' ? planDefaultsPanel : null}
{activeAppSettingsSection === 'planCost' ? planCostPanel : null}
{activeAppSettingsSection === 'chatSettings' ? chatSettingsPanel : null}
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
</>
) : null}
{activeSettingsModal === 'notification' ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
`On` Web Push .
</Paragraph>
<Text type="secondary"> : {getClientNotificationPermissionLabel(clientNotificationPermission)}</Text>
<Text type="secondary">PWA : {isStandaloneMode ? '홈 화면 앱' : '브라우저 탭'}</Text>
<Text type="secondary"> Web Push : {webPushConfigured ? '준비됨' : '미설정'}</Text>
{isAppleMobileDevice() && !isStandaloneMode ? (
<Text type="warning"> PWA에서만 .</Text>
) : null}
{clientNotificationPermission === 'denied' ? (
<Text type="warning"> .</Text>
) : null}
{clientNotificationPermission === 'unsupported' ? (
<Text type="warning"> Web Push .</Text>
) : null}
{!hasSecureOrigin() ? (
<Text type="warning"> HTTPS localhost .</Text>
) : null}
{renderFeedback(notificationFeedback, notificationCopyFeedback, setNotificationCopyFeedback)}
<Checkbox
checked={notificationEnabled}
disabled={notificationLoading}
onChange={(event) => {
void syncNotificationEnabled(event.target.checked);
}}
>
</Checkbox>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong>PWA </Text>
<Text type="secondary">
PWA에서 .
</Text>
<Text type="secondary">PWA : {registeredPwaNotificationToken ? '등록됨' : '미등록'}</Text>
<Input.TextArea
value={pwaNotificationTokenInput}
placeholder="PWA에서 받은 알림 토큰 입력"
autoSize={{ minRows: 2, maxRows: 4 }}
onChange={(event) => {
setPwaNotificationTokenInput(event.target.value);
}}
/>
{renderFeedback(
pwaNotificationTokenFeedback,
pwaNotificationTokenCopyFeedback,
setPwaNotificationTokenCopyFeedback,
)}
<Space wrap>
<Button
type="primary"
loading={pwaNotificationTokenSaving}
onClick={() => {
void handleRegisterPwaNotificationToken();
}}
>
PWA
</Button>
<Button
disabled={pwaNotificationTokenSaving || !registeredPwaNotificationToken}
onClick={() => {
void handleClearPwaNotificationToken();
}}
>
PWA
</Button>
</Space>
</Space>
</Space>
) : null}
{activeSettingsModal === 'token' ? (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Text type="secondary">
Codex .
</Text>
<Text type="secondary"> : {hasAccess ? '허용됨' : '차단됨'}</Text>
<Input.Password
value={tokenInput}
placeholder="권한 토큰 입력"
onChange={(event) => {
setTokenInput(event.target.value);
}}
/>
{tokenFeedback ? <Alert showIcon type={tokenFeedback.tone} message={tokenFeedback.message} /> : null}
<Space wrap>
<Button type="primary" onClick={handleRegisterToken}>
</Button>
<Button onClick={handleClearToken} disabled={!registeredToken}>
</Button>
</Space>
</Space>
) : null}
{activeSettingsModal === 'update' ? (
<Space direction="vertical" size={6} style={{ width: '100%' }}>
<Text strong> </Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(testServerStatus)}`}
aria-label={getServerVersionStatusTitle(testServerStatus, '테스트')}
title={getServerVersionStatusTitle(testServerStatus, '테스트')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">
: {getServerLastSourceChangedDateLabel(testServerStatus)}
</Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(workServerStatus)}`}
aria-label={getServerVersionStatusTitle(workServerStatus, '워크')}
title={getServerVersionStatusTitle(workServerStatus, '워크')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">
: {getServerLastSourceChangedDateLabel(workServerStatus)}
</Text>
<Text type="secondary">
<span
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
aria-hidden="true"
/>
</Text>
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
<Button
block
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={workServerStatusLoading}
disabled={!canRefreshWorkServerStatus}
onClick={() => {
void refreshUpdateTargets();
}}
>
</Button>
<Text strong style={{ marginTop: 8 }}>
/
</Text>
<Text type="secondary">
, , / .
</Text>
{renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)}
<Button
block
danger
icon={clientResetting ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={clientResetting}
disabled={!canResetClientState}
onClick={() => {
void handleResetClientState();
}}
>
{clientResetting ? '초기화 진행 중' : '초기화'}
</Button>
<Text strong style={{ marginTop: 8 }}>
</Text>
<Text type="secondary"> TEST와 WORK .</Text>
<Text type="secondary">
: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
</Text>
<Text type="secondary">
: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
</Text>
{!hasAccess ? (
<Text type="warning"> .</Text>
) : null}
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
<Button
block={screens.xs}
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'test'}
disabled={!canRestartServers}
onClick={() => {
void handleRestartSingleServer('test');
}}
>
</Button>
<Button
block={screens.xs}
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'work-server'}
disabled={!canRestartServers}
onClick={() => {
void handleRestartSingleServer('work-server');
}}
>
</Button>
<Button
type="primary"
block={screens.xs}
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'all'}
disabled={!canRestartServers}
onClick={() => {
void handleRestartBothServers();
}}
>
</Button>
</Space>
<Text strong style={{ marginTop: 8 }}>
PROD
</Text>
<Text type="secondary"> : {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
<Text type="secondary">
PROD , .
</Text>
<Button
type="primary"
danger
block
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'prod'}
disabled={!canRestartServers}
onClick={handleConfirmRestartProdServer}
>
PROD
</Button>
</Space>
) : null}
</Space>
</Modal>
</>
);
}