feat: refresh shared chat and server workflows

This commit is contained in:
2026-05-26 12:26:33 +09:00
parent 51e0099bea
commit c1d0f4c1db
82 changed files with 18604 additions and 12461 deletions

View File

@@ -1,15 +1,21 @@
import { App as AntdApp } from 'antd';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { getOrCreateClientId } from './app/main/clientIdentity';
import { reportClientError } from './app/main/errorLogApi';
import { AppShell } from './app/main';
import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay';
import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal';
import { buildChatPath } from './app/main/routes';
import { isPreviewRuntime } from './app/main/previewRuntime';
import { bindViewportCssVars } from './app/main/viewportCssVars';
import { reportVisitorPageView } from './features/history/api';
import { useAppStore } from './store';
const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried';
const CACHE_RECOVERY_SESSION_KEY = 'ai-code-app.cache-recovery-completed';
const INITIAL_LOADING_MIN_VISIBLE_MS = 450;
const CACHE_RECOVERY_NOTICE = '캐시된 화면 정보가 맞지 않아 홈으로 이동합니다. 다시 열어 주세요.';
const CACHE_RECOVERY_DELAY_MS = 900;
function shouldRetryChunkLoad(errorMessage: string) {
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test(
@@ -17,6 +23,12 @@ function shouldRetryChunkLoad(errorMessage: string) {
);
}
function shouldRecoverFromCacheError(errorMessage: string) {
return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError|Loading chunk|Failed to load module script|does not provide an export named|Cannot find module/i.test(
errorMessage,
);
}
function retryChunkLoadOnce(errorMessage: string) {
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return false;
@@ -39,7 +51,45 @@ function retryChunkLoadOnce(errorMessage: string) {
}
}
function getHomeRecoveryUrl() {
if (typeof window === 'undefined') {
return buildChatPath('live');
}
return new URL(buildChatPath('live'), window.location.origin).toString();
}
function tryRecoverToHomeFromCacheError(errorMessage: string, notify: (text: string) => void) {
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
return false;
}
if (isPreviewRuntime()) {
return false;
}
if (!shouldRecoverFromCacheError(errorMessage)) {
return false;
}
try {
if (sessionStorage.getItem(CACHE_RECOVERY_SESSION_KEY) === '1') {
return false;
}
sessionStorage.setItem(CACHE_RECOVERY_SESSION_KEY, '1');
notify(CACHE_RECOVERY_NOTICE);
window.setTimeout(() => {
window.location.replace(getHomeRecoveryUrl());
}, CACHE_RECOVERY_DELAY_MS);
return true;
} catch {
return false;
}
}
function App() {
const { message } = AntdApp.useApp();
const { currentPage } = useAppStore();
const lastTrackedPageIdRef = useRef<string | null>(null);
const [showInitialLoading, setShowInitialLoading] = useState(true);
@@ -51,6 +101,13 @@ function App() {
return undefined;
}
const notifyCacheRecovery = (text: string) => {
message.warning({
content: text,
duration: 1.5,
});
};
const handleError = (event: ErrorEvent) => {
const reportedError = event.error instanceof Error ? event.error : null;
const errorMessage = event.message || reportedError?.message || '클라이언트 오류가 발생했습니다.';
@@ -71,6 +128,8 @@ function App() {
column: event.colno || null,
},
});
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
@@ -93,6 +152,8 @@ function App() {
reasonType: typeof reason,
},
});
tryRecoverToHomeFromCacheError(errorMessage, notifyCacheRecovery);
};
window.addEventListener('error', handleError);
@@ -102,7 +163,7 @@ function App() {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
}, [message]);
useEffect(() => {
getOrCreateClientId();

View File

@@ -7,6 +7,7 @@ import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage';
import { buildChatPath, buildDocsPath } from './routes';
import { isPreviewRuntime } from './previewRuntime';
export function AppShell() {
return (
@@ -14,7 +15,7 @@ export function AppShell() {
<Route path="/chat-share/:token" element={<ChatSharePage />} />
<Route path="/chat/share/:token" element={<ChatSharePage />} />
<Route path="/" element={<MainLayout />}>
<Route index element={<Navigate to={buildChatPath('live')} replace />} />
<Route index element={<Navigate to={isPreviewRuntime() ? buildDocsPath() : buildChatPath('live')} replace />} />
<Route path="docs/:folder" element={<DocsPage />} />
<Route path="apis/:section" element={<ApisPage />} />
<Route path="plans/:section" element={<PlansPage />} />

View File

@@ -9,7 +9,7 @@ import {
showLocalClientNotification,
} from './notificationApi';
import { chatGateway } from './chatV2';
import { resolveChatPathForSession } from './isolatedChatRooms';
import { resolveChatPathForSession } from './chatSessionRouting';
import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types';
const MAX_NOTIFICATION_DETAIL_POLLS = 3;

View File

@@ -138,7 +138,7 @@ import type {
import { consumeCodexLiveDraft } from './codexLiveDraftBridge';
import { useChatActionContextSnapshot } from './chatActionContextStore';
import { getOrCreateClientId } from './clientIdentity';
import { requestScopedChatRoomsWindowAction } from './ScopedChatRoomsWindow';
import { requestChatWindowAction } from './chatWindowActions';
import {
buildIsolatedChatRoomContextSupplement,
buildIsolatedChatRoomRequestBadgeLabel,
@@ -146,6 +146,7 @@ import {
createIsolatedChatRoomSessionId,
doesIsolatedChatRoomScopeMatch,
isIsolatedChatRoomSessionId,
normalizeIsolatedChatRoomScope,
resolveChatPathForSession,
shouldShowConversationForMode,
type IsolatedChatRoomScope,
@@ -771,6 +772,7 @@ function buildOptimisticConversationSummary(args: {
roomScope: normalizedRoomScope,
notifyOffline: true,
hasUnreadResponse: false,
hasPendingAttention: false,
currentRequestId: null,
currentJobStatus: null,
currentJobMessage: null,
@@ -1924,6 +1926,32 @@ function hasDuplicateActivePromptRequest(
});
}
function hasConversationAttentionPromptPart(part: ChatMessagePart) {
return part.type === 'prompt' && part.readOnly !== true && part.resolvedBy == null && !(part.resolvedAt?.trim() ?? '');
}
function hasConversationAttentionResponseTarget(message: ChatMessage) {
if (message.author !== 'codex' && message.author !== 'system') {
return false;
}
if ((message.parts ?? []).some((part) => hasConversationAttentionPromptPart(part))) {
return true;
}
const normalizedText = String(message.text ?? '').trim();
if (!normalizedText) {
return false;
}
if (normalizedText.length > 720) {
return true;
}
return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalizedText);
}
function mergeConversationRequestPreservingContent(
previousItem: ChatConversationRequest | null | undefined,
nextItem: ChatConversationRequest,
@@ -2526,6 +2554,7 @@ function mergeConversationSummaryPreservingChatType(
contextDescription: nextItem.contextDescription?.trim() || null,
isPendingWork: nextItem.isPendingWork ?? previousItem.isPendingWork ?? false,
pendingWorkReason: nextItem.pendingWorkReason ?? previousItem.pendingWorkReason ?? null,
hasPendingAttention: nextItem.hasPendingAttention === true || previousItem.hasPendingAttention === true,
lastRequestPreview: nextItem.lastRequestPreview?.trim() || previousItem.lastRequestPreview?.trim() || '',
lastMessagePreview: nextItem.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: nextItem.lastResponsePreview?.trim() || previousItem.lastResponsePreview?.trim() || '',
@@ -4267,6 +4296,7 @@ export function MainChatPanel({
);
const hasMeaningfulCodexResponse = isMeaningfulCodexResponseMessage(incomingMessage);
const hasPendingAttentionResponse = hasConversationAttentionResponseTarget(incomingMessage);
const isForegroundSession = isActiveChatSessionInForeground({
sessionId,
activeSessionId,
@@ -4292,6 +4322,8 @@ export function MainChatPanel({
updatedAt: responseTimestamp,
hasUnreadResponse:
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
hasPendingAttention:
hasPendingAttentionResponse && incomingMessage.author === 'codex' ? true : item.hasPendingAttention,
}
: item,
),
@@ -4906,7 +4938,7 @@ export function MainChatPanel({
);
}, [conversationItems, effectiveActiveRoomScope, mode, resolveConversationRoomScope, roomLaunchScope]);
const unreadFilteredConversationItems = useMemo(
() => filteredConversationItems.filter((item) => item.hasUnreadResponse),
() => filteredConversationItems.filter((item) => item.hasUnreadResponse || item.hasPendingAttention),
[filteredConversationItems],
);
useEffect(() => {
@@ -4932,7 +4964,7 @@ export function MainChatPanel({
return;
}
if (item.hasUnreadResponse) {
if (item.hasUnreadResponse || item.hasPendingAttention) {
groupedItems.unread.push(item);
return;
}
@@ -4984,7 +5016,7 @@ export function MainChatPanel({
return [
{ key: 'processing', title: '처리 중', tone: 'processing', items: groupedItems.processing, defaultOpen: true },
{ key: 'failed', title: '오류', tone: 'failed', items: groupedItems.failed, defaultOpen: true },
{ key: 'unread', title: '답변 도착', tone: 'unread', items: groupedItems.unread, defaultOpen: true },
{ key: 'unread', title: '답변 도착·미확인', tone: 'unread', items: groupedItems.unread, defaultOpen: true },
...normalizedGeneralSectionOrder
.map((key) => generalSectionMap.get(key))
.filter((section): section is ConversationListSection => Boolean(section)),
@@ -5563,9 +5595,10 @@ export function MainChatPanel({
item: ConversationListViewItem,
sectionKey = 'general',
) => {
const isUnread = item.hasUnreadResponse;
const isUnread = item.hasUnreadResponse || item.hasPendingAttention;
const isProcessing = isConversationProcessing(item);
const isFailed = isConversationFailed(item);
const unreadFlagLabel = item.hasUnreadResponse ? '답변 도착' : '미확인';
const generalSectionName = normalizeGeneralSectionName(item.generalSectionName);
const isUnreadSection = sectionKey === 'unread';
const isFailedSection = sectionKey === 'failed';
@@ -5634,7 +5667,7 @@ export function MainChatPanel({
) : null}
{isUnread ? (
<span className="app-chat-panel__conversation-item-flag app-chat-panel__conversation-item-flag--unread">
{unreadFlagLabel}
</span>
) : null}
{generalSectionName ? (
@@ -6250,7 +6283,9 @@ export function MainChatPanel({
setIsResourceStripOpen,
setIsConversationPaneClosed,
setIsMobileConversationView,
setPreserveEmptyConversationSelection,
setPreserveEmptyConversationSelection: (value: boolean) => {
setPreserveEmptyConversationSelection(mode === 'rooms' ? value : false);
},
updatePendingMessageStatus,
sendChatRequest,
createLocalMessage,
@@ -6367,7 +6402,7 @@ export function MainChatPanel({
if (succeededSessionIdSet.has(activeSessionId)) {
isClosingConversationRef.current = true;
handledRequestedSessionIdRef.current = '';
setPreserveEmptyConversationSelection(true);
setPreserveEmptyConversationSelection(mode === 'rooms');
replaceChatSessionInUrl('');
chatConnectionGateway.resetLastReceivedEventId('');
setActiveSessionId('');
@@ -6918,7 +6953,7 @@ export function MainChatPanel({
return;
}
if (activeView !== 'chat' || isConversationListLoading) {
if (isConversationListLoading) {
return;
}
@@ -6940,14 +6975,16 @@ export function MainChatPanel({
return;
}
const initialChatType = selectedChatType ?? availableChatTypes[0] ?? null;
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null) ?? availableChatTypes[0] ?? null;
if (!initialChatType) {
return;
}
hasAttemptedInitialConversationRef.current = true;
setActiveView('chat');
setSelectedChatTypeId(initialChatType.id);
void handleCreateConversation({
chatTypeOverride: initialChatType,
persist: true,
@@ -6965,6 +7002,7 @@ export function MainChatPanel({
preserveEmptyConversationSelection,
requestedSessionId,
selectedChatType,
isSelectedChatTypeAllowed,
]);
useEffect(() => {
@@ -7407,6 +7445,7 @@ export function MainChatPanel({
skipped?: boolean;
}>;
summaryText?: string | null;
attachments?: ChatComposerAttachment[];
};
}) => {
const trimmed = text.trim();
@@ -7459,6 +7498,7 @@ export function MainChatPanel({
freeText: selection.freeText,
stepSelections: selection.stepSelections,
summaryText: selection.summaryText ?? null,
attachments: selection.attachments ?? [],
followupText: trimmed,
mode: resolvedMode,
contextRef: contextRef ?? null,
@@ -7618,11 +7658,12 @@ export function MainChatPanel({
);
return nextItems;
});
void reloadConversationItems();
} catch (error) {
throw error;
}
},
[activeSessionId, setRequestItems],
[activeSessionId, reloadConversationItems, setRequestItems],
);
const handleRetryFailedRequest = useCallback(
@@ -7707,7 +7748,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('close');
requestChatWindowAction('close');
return;
}
@@ -7882,7 +7923,45 @@ export function MainChatPanel({
conversationSections.map(renderConversationListSection)
) : (
<div className="app-chat-panel__conversation-empty-list">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
{isRoomsMode ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
) : (
<Space direction="vertical" size={12} align="center">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="저장된 대화가 없습니다." />
<Space size={8} wrap>
<Button
type="primary"
disabled={availableChatTypes.length === 0}
onClick={() => {
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
?? availableChatTypes[0]
?? null;
if (!initialChatType) {
openCreateConversationModal();
return;
}
setSelectedChatTypeId(initialChatType.id);
void handleCreateConversation({
chatTypeOverride: initialChatType,
persist: true,
});
}}
>
</Button>
<Button
onClick={() => {
openCreateConversationModal();
}}
disabled={availableChatTypes.length === 0}
>
</Button>
</Space>
</Space>
)}
</div>
)}
</div>
@@ -8038,7 +8117,7 @@ export function MainChatPanel({
: '웹소켓 끊김'}
</span>
<span className="app-chat-panel__rooms-share-summary">
{activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '시스템 채팅방'}
{activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '채팅방'}
</span>
</div>
) : null}
@@ -8106,7 +8185,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('minimize');
requestChatWindowAction('minimize');
return;
}
@@ -8116,7 +8195,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('close');
requestChatWindowAction('close');
}
},
}}
@@ -8132,15 +8211,15 @@ export function MainChatPanel({
<Button
size="small"
icon={<MinusOutlined />}
aria-label="시스템 채팅 최소화"
title="시스템 채팅 최소화"
aria-label="채팅 최소화"
title="채팅 최소화"
onClick={() => {
if (onRoomsMinimize) {
onRoomsMinimize();
return;
}
requestScopedChatRoomsWindowAction('minimize');
requestChatWindowAction('minimize');
}}
/>
) : null}
@@ -8149,15 +8228,15 @@ export function MainChatPanel({
size="small"
danger
icon={<CloseOutlined />}
aria-label="시스템 채팅 닫기"
title="시스템 채팅 닫기"
aria-label="채팅 닫기"
title="채팅 닫기"
onClick={() => {
if (onRoomsClose) {
onRoomsClose();
return;
}
requestScopedChatRoomsWindowAction('close');
requestChatWindowAction('close');
}}
/>
) : null}
@@ -8407,7 +8486,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('minimize');
requestChatWindowAction('minimize');
return;
}
@@ -8417,7 +8496,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('close');
requestChatWindowAction('close');
}
}}
onRoomsHeaderMinimize={
@@ -8428,7 +8507,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('minimize');
requestChatWindowAction('minimize');
})
: undefined
}
@@ -8440,7 +8519,7 @@ export function MainChatPanel({
return;
}
requestScopedChatRoomsWindowAction('close');
requestChatWindowAction('close');
})
: undefined
}
@@ -8556,15 +8635,53 @@ export function MainChatPanel({
/>
) : (
<div className="app-chat-panel__conversation-empty">
<Empty
description={
isRoomsMode
? canReturnToRoomsList
{isRoomsMode ? (
<Empty
description={
canReturnToRoomsList
? '설정 메뉴에서 대화 목록을 열어 대화를 선택하세요.'
: '시스템 채팅방을 준비하는 중입니다.'
: '왼쪽 목록에서 대화를 선택하세요.'
}
/>
: '채팅방을 준비하는 중입니다.'
}
/>
) : conversationItems.length === 0 ? (
<Space direction="vertical" size={12} align="center">
<Empty description="저장된 대화가 없습니다. 바로 새 대화를 시작할 수 있습니다." />
<Space size={8} wrap>
<Button
type="primary"
disabled={availableChatTypes.length === 0}
onClick={() => {
const initialChatType = (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : null)
?? availableChatTypes[0]
?? null;
if (!initialChatType) {
openCreateConversationModal();
return;
}
setSelectedChatTypeId(initialChatType.id);
void handleCreateConversation({
chatTypeOverride: initialChatType,
persist: true,
});
}}
>
</Button>
<Button
onClick={() => {
openCreateConversationModal();
}}
disabled={availableChatTypes.length === 0}
>
</Button>
</Space>
</Space>
) : (
<Empty description="왼쪽 목록에서 대화를 선택하세요." />
)}
</div>
)}
</div>

View File

@@ -19,7 +19,6 @@ import { SharedChatManagementPage } from './SharedChatManagementPage';
import { ChatTypeManagementPage } from './ChatTypeManagementPage';
import { ChatSourceChangesPage } from './ChatSourceChangesPage';
import { MainChatPanel } from './MainChatPanel';
import { SystemChatPanel } from './SystemChatPanel';
import { PlayAppOverlay } from './PlayAppOverlay';
import { PreviewAppOverlay } from './PreviewAppOverlay';
import { SharedResourceManagementPage } from './SharedResourceManagementPage';
@@ -273,9 +272,6 @@ export function MainContent({
return <MainChatPanel initialView="live" />;
}
if (selectionId === 'page:chat:rooms') {
return <SystemChatPanel />;
}
if (selectionId === 'page:chat:errors') {
return <MainChatPanel initialView="errors" />;

View File

@@ -58,16 +58,16 @@ import {
type PlanCostTimeUnit,
} from './appConfig';
import { renderModalWithEnterConfirm } from './modalKeyboard';
import {
fetchWebPushConfig,
registerWebPushSubscription,
unregisterWebPushSubscription,
type WebPushSubscriptionPayload,
} from './notificationApi';
import { fetchWebPushConfig } from './notificationApi';
import {
clearNotificationIdentity,
getSavedNotificationDeviceId,
} from './notificationIdentity';
import {
clearWebPushSubscriptionRegistration,
ensureWebPushSubscriptionRegistered,
syncExistingWebPushSubscriptionRegistration,
} from './webPushRegistration';
import { resetNonAuthClientState } from './appMaintenance';
import {
ALLOWED_REGISTRATION_TOKEN,
@@ -658,7 +658,7 @@ function areGestureShortcutSettingsEqual(
left: AppConfig['gestureShortcuts'],
right: AppConfig['gestureShortcuts'],
) {
return left.openSearch === right.openSearch && left.openWindowSearch === right.openWindowSearch;
return left.openSearch === right.openSearch;
}
function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) {
@@ -668,10 +668,6 @@ function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draf
changedLabels.push('통합 검색 열기');
}
if (saved.openWindowSearch !== draft.openWindowSearch) {
changedLabels.push('시스템 채팅 열기');
}
return changedLabels;
}
@@ -1326,55 +1322,6 @@ function getAppHeaderDomainClassName() {
return 'app-header--prod';
}
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;
@@ -1954,45 +1901,6 @@ export function MainHeader({
);
const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory);
const ensureWebPushSubscriptionRegistered = async (registration: ServiceWorkerRegistration) => {
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());
};
const clearWebPushSubscriptionRegistration = async (registration: ServiceWorkerRegistration) => {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await unregisterWebPushSubscription(subscription.endpoint);
await subscription.unsubscribe();
}
};
const syncRegisteredWebPushStatus = async () => {
const permission = getClientNotificationPermission();
setClientNotificationPermission(permission);
@@ -2036,7 +1944,9 @@ export function MainHeader({
return;
}
await ensureWebPushSubscriptionRegistered(registration);
await syncExistingWebPushSubscriptionRegistration(registration, {
deviceId: getSavedNotificationDeviceId(),
});
setNotificationEnabled(true);
setNotificationPendingRegistration(false);
setNotificationFeedback(null);
@@ -2078,7 +1988,9 @@ export function MainHeader({
throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.');
}
await ensureWebPushSubscriptionRegistered(registration);
await ensureWebPushSubscriptionRegistered(registration, {
deviceId: getSavedNotificationDeviceId(),
});
if (cancelled) {
return;
@@ -2118,6 +2030,7 @@ export function MainHeader({
};
window.addEventListener('focus', handleSync);
window.addEventListener('online', handleSync);
window.addEventListener('pageshow', handleSync);
document.addEventListener('visibilitychange', handleSync);
if ('serviceWorker' in navigator) {
@@ -2130,6 +2043,7 @@ export function MainHeader({
return () => {
window.removeEventListener('focus', handleSync);
window.removeEventListener('online', handleSync);
window.removeEventListener('pageshow', handleSync);
document.removeEventListener('visibilitychange', handleSync);
};
@@ -2472,7 +2386,9 @@ export function MainHeader({
}
if (nextEnabled) {
await ensureWebPushSubscriptionRegistered(registration);
await ensureWebPushSubscriptionRegistered(registration, {
deviceId: getSavedNotificationDeviceId(),
});
setNotificationEnabled(true);
setNotificationPendingRegistration(false);
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 서버에 등록했습니다.' });
@@ -2957,92 +2873,29 @@ export function MainHeader({
});
};
const handleRestartBothServers = async () => {
if (!hasAccess || serverRestartingKey) {
return;
}
const testBaselineStatus = testServerStatus;
const progressTaskId = beginRestartProgress(getServerRestartTargetLabel('all'));
setServerRestartCopyFeedback(null);
setServerRestartFeedback(null);
setServerRestartingKey('all');
try {
const testRestartedItem = await restartServerWithVerification('test', 'all', progressTaskId);
if (!testRestartedItem) {
closeRestartProgress();
return;
}
updateRestartProgress(
progressTaskId,
'다음 서버 진행 중',
'TEST 서버 부팅 확인이 끝났습니다. 이어서 WORK 서버 재기동을 진행합니다.',
);
const workServerRestartedItem = await restartServerWithVerification('work-server', 'all', progressTaskId);
if (!workServerRestartedItem) {
closeRestartProgress();
return;
}
const completionAction = shouldResetClientStateAfterRestart('test', testBaselineStatus, testRestartedItem)
? 'reset-client-state'
: 'reload';
setServerRestartFeedback({
tone: 'success',
message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.',
});
updateRestartProgress(
progressTaskId,
'전체 재기동 확인 완료',
`TEST 서버와 WORK 서버 모두 실제 부팅 완료를 확인했습니다. ${getRestartCompletionActionLabel(completionAction)} 여부를 선택해 주세요.`,
{ cancellable: false },
);
closeRestartProgress();
openRestartCompletionConfirm({
title: '전체 재기동 완료',
targetLabel: 'TEST 서버와 WORK 서버',
action: completionAction,
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
setServerRestartFeedback({
tone: 'warning',
message: '전체 재기동 대기를 취소했습니다. 이미 접수된 서버 재기동은 계속될 수 있으니 상태를 다시 확인해 주세요.',
});
return;
}
setServerRestartFeedback({
tone: 'error',
message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.',
});
} finally {
closeRestartProgress();
setServerRestartingKey(null);
}
};
const handleScheduleServerRestartReservation = async () => {
const handleScheduleServerRestartReservation = async (
target: 'all' | 'work-server' = 'all',
) => {
if (!hasAccess || serverRestartReservationLoading || serverRestartingKey) {
return;
}
setServerRestartReservationLoading(true);
setServerRestartReservationFeedback(null);
setServerRestartFeedback(null);
try {
const nextReservation = await scheduleServerRestartReservation({
target,
autoExecuteDelaySeconds: appConfig.chat.restartReservationCompletionDelaySeconds,
});
setServerRestartReservation(nextReservation);
setServerRestartReservationFeedback({
tone: 'success',
message: `전체 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`,
message:
target === 'work-server'
? `WORK 서버 무중단 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`
: `전체 재기동 예약을 등록했습니다. 작업이 모두 끝나면 ${appConfig.chat.restartReservationCompletionDelaySeconds}초 뒤 자동 실행합니다.`,
});
} catch (error) {
setServerRestartReservationFeedback({
@@ -4364,8 +4217,8 @@ export function MainHeader({
}
description={
gestureShortcutSettingsDirty
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfigDraft.gestureShortcuts.openWindowSearch}`
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}, 시스템 채팅 ${appConfig.gestureShortcuts.openWindowSearch}`
? `변경 항목: ${gestureShortcutDiffLabels.join(', ')} / DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch} / 편집 중: 통합 검색 ${appConfigDraft.gestureShortcuts.openSearch}`
: `DB 저장값 기준: 통합 검색 ${appConfig.gestureShortcuts.openSearch}`
}
/>
{appConfigFeedback ? (
@@ -4417,23 +4270,6 @@ export function MainHeader({
/>
<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"> . . : `Mod+Shift+K`</Text>
</Space>
<Space wrap>
<Button type="primary" onClick={handleSaveAppConfig} loading={appConfigSaving}>
@@ -5198,7 +5034,7 @@ export function MainHeader({
<Text strong style={{ marginTop: 8 }}>
</Text>
<Text type="secondary"> TEST와 WORK .</Text>
<Text type="secondary"> TEST와 WORK .</Text>
<Text type="secondary">
: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
</Text>
@@ -5225,24 +5061,24 @@ export function MainHeader({
block={screens.xs}
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'work-server'}
disabled={!canRestartServers}
disabled={!canRestartServers || serverRestartReservationLoading}
onClick={() => {
void handleRestartSingleServer('work-server');
void handleScheduleServerRestartReservation('work-server');
}}
>
</Button>
<Button
type="primary"
block={screens.xs}
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
loading={serverRestartingKey === 'all'}
disabled={!canRestartServers}
loading={serverRestartingKey === 'all' || serverRestartReservationLoading}
disabled={!canRestartServers || serverRestartReservationLoading}
onClick={() => {
void handleRestartBothServers();
void handleScheduleServerRestartReservation('all');
}}
>
</Button>
</Space>
<Text strong style={{ marginTop: 8 }}>

View File

@@ -1,6 +1,7 @@
import { CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { useEffect } from 'react';
import { BaseballTicketBayPlayAppView } from '../../views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView';
import { EReaderAppView } from '../../views/play/apps/e-reader/EReaderAppView';
import { PhotoPuzzleAppView } from '../../views/play/apps/photo-puzzle/PhotoPuzzleAppView';
import { PhotoPrismAppView } from '../../views/play/apps/photoprism/PhotoPrismAppView';
@@ -20,6 +21,10 @@ function renderPlayApp(appId: string, onClose: () => void) {
return <EReaderAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'baseball-ticket-bay') {
return <BaseballTicketBayPlayAppView onBack={onClose} launchContext="embedded" />;
}
if (appId === 'photoprism') {
return <PhotoPrismAppView onBack={onClose} launchContext="embedded" />;
}

View File

@@ -1,245 +0,0 @@
.scoped-chat-rooms-window {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 1400;
display: flex;
flex-direction: column;
width: min(620px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
border: 1px solid rgba(196, 210, 226, 0.92);
border-radius: 26px;
overflow: hidden;
background:
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 30%);
box-shadow:
0 30px 72px rgba(15, 23, 42, 0.16),
0 10px 24px rgba(148, 163, 184, 0.18);
backdrop-filter: blur(14px);
}
.scoped-chat-rooms-window--mobile {
right: 0;
bottom: 0;
width: 100vw;
height: 100dvh;
border-radius: 0;
border-inline: 0;
border-bottom: 0;
}
.scoped-chat-rooms-window--minimized {
right: auto;
bottom: auto;
width: 176px;
height: auto;
border: 0;
border-radius: 20px;
overflow: visible;
box-shadow: none;
touch-action: none;
background: transparent;
}
.scoped-chat-rooms-window__header {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 0 14px 0 16px;
background:
linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
border-bottom: 1px solid rgba(148, 163, 184, 0.24);
cursor: default;
}
.scoped-chat-rooms-window--minimized .scoped-chat-rooms-window__header {
min-height: 0;
padding: 8px 8px 10px;
border: 1px solid rgba(196, 210, 226, 0.92);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(247, 249, 252, 0.98), rgba(242, 245, 250, 0.98)),
radial-gradient(circle at top left, rgba(22, 93, 255, 0.08), transparent 32%);
box-shadow:
0 18px 34px rgba(15, 23, 42, 0.14),
0 6px 18px rgba(148, 163, 184, 0.16);
flex-direction: column;
align-items: stretch;
}
.scoped-chat-rooms-window__title {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1 1 auto;
padding: 0;
border: 0;
background: transparent;
text-align: left;
font-size: 13px;
font-weight: 700;
color: #0f172a;
cursor: default;
}
.scoped-chat-rooms-window__title-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex: 0 0 28px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: #2563eb;
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
0 6px 16px rgba(148, 163, 184, 0.12);
font-size: 14px;
}
.scoped-chat-rooms-window__title-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.scoped-chat-rooms-window__title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.scoped-chat-rooms-window__title-subtitle {
min-width: 0;
color: #64748b;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scoped-chat-rooms-window__actions {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
.scoped-chat-rooms-window__action.ant-btn {
width: 30px;
min-width: 30px;
height: 30px;
color: #334155;
border-radius: 999px;
border: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(241, 245, 249, 0.9));
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.24),
0 6px 16px rgba(148, 163, 184, 0.12);
}
.scoped-chat-rooms-window__action.ant-btn:hover {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(219, 234, 254, 0.94));
box-shadow:
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
0 8px 18px rgba(96, 165, 250, 0.16);
}
.scoped-chat-rooms-window__action--close.ant-btn:hover {
color: #b91c1c;
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98), rgba(254, 226, 226, 0.92));
box-shadow:
inset 0 0 0 1px rgba(248, 113, 113, 0.3),
0 8px 18px rgba(248, 113, 113, 0.14);
}
.scoped-chat-rooms-window__drag-handle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 20px;
flex: 0 0 auto;
padding: 0;
border: 0;
background: transparent;
color: #334155;
cursor: grab;
touch-action: none;
user-select: none;
}
.scoped-chat-rooms-window__drag-handle:active {
cursor: grabbing;
}
.scoped-chat-rooms-window__drag-grip {
width: 20px;
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
background:
radial-gradient(circle, rgba(100, 116, 139, 0.9) 1.2px, transparent 1.4px) 0 0 / 6px 6px;
opacity: 0.85;
}
.scoped-chat-rooms-window__drag-title {
min-width: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.scoped-chat-rooms-window__minimized-copy {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.scoped-chat-rooms-window__actions--minimized {
gap: 6px;
}
.scoped-chat-rooms-window__restore-button.ant-btn {
flex: 1 1 auto;
height: 32px;
padding-inline: 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 0;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.2);
}
.scoped-chat-rooms-window__body {
flex: 1;
min-height: 0;
padding: 0;
background: transparent;
}
.scoped-chat-rooms-window__body .app-chat-panel {
height: 100%;
max-height: 100%;
border-radius: 0;
background: transparent;
}
@media (max-width: 768px) {
.scoped-chat-rooms-window--minimized {
width: min(176px, calc(100vw - 24px));
}
}

View File

@@ -1,152 +0,0 @@
import { AppstoreOutlined, CloseOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import { useCallback, useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { FullscreenPreviewModal } from '../../components/previewer/FullscreenPreviewModal';
import {
removeMinimizedIsolatedChatRoomEntry,
upsertMinimizedIsolatedChatRoomEntry,
useActiveIsolatedChatRoomScope,
useMinimizedIsolatedChatRoomEntries,
writeActiveIsolatedChatRoomScope,
writeIsolatedChatRoomsWindowOpen,
} from './isolatedChatRoomScopeStore';
import './ScopedChatRoomsWindow.css';
const SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT = 'scoped-chat-rooms-window:action';
const MODAL_Z_INDEX = 1400;
const MINIMIZED_Z_INDEX = MODAL_Z_INDEX + 5;
type ScopedChatRoomsWindowProps = {
children: ReactNode;
onClose?: (() => void) | null;
};
export function requestScopedChatRoomsWindowAction(action: 'minimize' | 'close') {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(
new CustomEvent(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, {
detail: { action },
}),
);
}
export function ScopedChatRoomsWindow({ children, onClose = null }: ScopedChatRoomsWindowProps) {
const activeScope = useActiveIsolatedChatRoomScope();
const title = activeScope?.featureTitle?.trim() || activeScope?.menuTitle?.trim() || '시스템 채팅방';
const handleMinimize = useCallback(() => {
upsertMinimizedIsolatedChatRoomEntry(activeScope);
writeIsolatedChatRoomsWindowOpen(false);
}, [activeScope]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleWindowAction = (event: Event) => {
const detail =
event instanceof CustomEvent && event.detail && typeof event.detail === 'object'
? (event.detail as { action?: 'minimize' | 'close' })
: null;
if (detail?.action === 'close') {
writeIsolatedChatRoomsWindowOpen(false);
onClose?.();
return;
}
if (detail?.action === 'minimize') {
handleMinimize();
}
};
window.addEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
return () => {
window.removeEventListener(SCOPED_CHAT_ROOMS_WINDOW_ACTION_EVENT, handleWindowAction);
};
}, [handleMinimize, onClose]);
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<FullscreenPreviewModal
open
hideHeader
zIndex={MODAL_Z_INDEX}
maskClosable={false}
className="scoped-chat-rooms-window__program-modal scoped-chat-rooms-window__program-modal--system-chat-room"
contentClassName="scoped-chat-rooms-window__program-modal-content"
fillContent
title={title}
onMinimize={handleMinimize}
onClose={() => {
writeIsolatedChatRoomsWindowOpen(false);
onClose?.();
}}
>
<div className="scoped-chat-rooms-window__program-app-shell scoped-chat-rooms-window__program-app-shell--system-chat-room">
{children}
</div>
</FullscreenPreviewModal>,
document.body,
);
}
export function ScopedChatRoomsWindowDock() {
const minimizedEntries = useMinimizedIsolatedChatRoomEntries();
if (typeof document === 'undefined' || minimizedEntries.length === 0) {
return null;
}
return createPortal(
<div className="scoped-chat-rooms-window__dock" style={{ zIndex: MINIMIZED_Z_INDEX }}>
{minimizedEntries.map((entry) => {
const title = entry.scope.featureTitle?.trim() || entry.scope.menuTitle?.trim() || '시스템 채팅방';
return (
<div key={entry.id} className="scoped-chat-rooms-window__program-minimized">
<div className="scoped-chat-rooms-window__program-minimized-drag">
<span className="scoped-chat-rooms-window__program-minimized-drag-grip" aria-hidden="true" />
<span className="scoped-chat-rooms-window__program-minimized-title">{title}</span>
</div>
<div className="scoped-chat-rooms-window__program-minimized-actions">
<Button
type="primary"
size="small"
icon={<AppstoreOutlined />}
className="scoped-chat-rooms-window__program-minimized-button"
onClick={() => {
writeActiveIsolatedChatRoomScope(entry.scope);
removeMinimizedIsolatedChatRoomEntry(entry.id);
writeIsolatedChatRoomsWindowOpen(true);
}}
>
</Button>
<Button
type="text"
size="small"
className="scoped-chat-rooms-window__program-minimized-icon scoped-chat-rooms-window__program-minimized-close"
icon={<CloseOutlined />}
aria-label="최소화 항목 닫기"
onClick={() => {
removeMinimizedIsolatedChatRoomEntry(entry.id);
}}
/>
</div>
</div>
);
})}
</div>,
document.body,
);
}

View File

@@ -237,9 +237,6 @@ export function SharedAppSettingsPage({ shareToken }: SharedAppSettingsPageProps
<Form.Item label="검색 단축키" name={['gestureShortcuts', 'openSearch']}>
<Input />
</Form.Item>
<Form.Item label="시스템 채팅 단축키" name={['gestureShortcuts', 'openWindowSearch']}>
<Input />
</Form.Item>
</Card>
</div>
</Form>

View File

@@ -6,7 +6,7 @@ import { useTokenSettingRegistry, type TokenSettingRecord } from './tokenSetting
import { useTokenAccess } from './tokenAccess';
import { createManagedChatShareRoom, type ManagedChatShareRoom } from './mainChatPanel';
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
import { resolveChatPathForSession } from './isolatedChatRooms';
import { resolveChatPathForSession } from './chatSessionRouting';
import { copyTextToClipboard } from '../../utils/clipboard';
import './SharedChatManagementPage.css';
@@ -74,10 +74,10 @@ export function SharedChatManagementPage() {
() =>
Boolean(
selectedTokenSetting &&
(hasAllowedApp(selectedTokenSetting, 'chat-rooms') ||
hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
(hasAllowedApp(selectedTokenSetting, 'chat-room-settings') ||
hasAllowedApp(selectedTokenSetting, 'token-setting') ||
hasAllowedApp(selectedTokenSetting, 'shared-resource')),
hasAllowedApp(selectedTokenSetting, 'shared-resource') ||
hasAllowedApp(selectedTokenSetting, 'server-command')),
),
[selectedTokenSetting],
);
@@ -241,10 +241,10 @@ export function SharedChatManagementPage() {
tokenSettingId: item.id,
allowManageAccess:
previous.allowManageAccess &&
(hasAllowedApp(item, 'chat-rooms') ||
hasAllowedApp(item, 'chat-room-settings') ||
(hasAllowedApp(item, 'chat-room-settings') ||
hasAllowedApp(item, 'token-setting') ||
hasAllowedApp(item, 'shared-resource')),
hasAllowedApp(item, 'shared-resource') ||
hasAllowedApp(item, 'server-command')),
}));
}}
>

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState, type Key, type MouseEvent as ReactMouseEv
import { getReadyPlayAppEntries } from '../../views/play/apps/apps/appsRegistry';
import { copyTextToClipboard } from '../../utils/clipboard';
import { openExternalLinkInNewWindow } from './mainChatPanel/linkNavigation';
import { resolveChatPathForSession } from './isolatedChatRooms';
import { resolveChatPathForSession } from './chatSessionRouting';
import { useTokenAccess } from './tokenAccess';
import { confirmWithKeyboard } from './modalKeyboard';
import {
@@ -44,12 +44,12 @@ const RESOURCE_TYPE_OPTIONS: Array<{ value: SharedResourceType; label: string }>
const MANAGEMENT_APP_OPTIONS = [
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
{ value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' },
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
] as const;
@@ -1514,7 +1514,7 @@ export function SharedResourceManagementPage({
<div className="shared-resource-management-page__inline-option-row">
<Button onClick={applyAdminPreset}> </Button>
<Text type="secondary">
, , / .
, / .
</Text>
</div>
<Form.Item

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,915 @@
import {
AppstoreOutlined,
PlusOutlined,
CheckOutlined,
CopyOutlined,
EyeOutlined,
LeftOutlined,
RightOutlined,
SearchOutlined,
SendOutlined,
SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { App, Alert, Button, Dropdown, Input, Modal, Select, Tag, Typography, type MenuProps } from 'antd';
import type { ChangeEvent } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ChatLinkCardPreview } from './mainChatPanel/ChatLinkCardPreview';
import { ChatPreviewBody, type ChatPreviewTarget } from './mainChatPanel/ChatPreviewBody';
import {
ChatPromptCard,
buildPromptTargetSignature,
type PromptDraftSelection,
type PromptSubmitPayload,
} from './mainChatPanel/ChatPromptCard';
import { extractChatMessageParts } from './mainChatPanel/messageParts';
import { extractPreviewItems, type PreviewItem } from './mainChatPanel/previewItems';
import { stripHiddenPreviewTags } from './mainChatPanel/previewMarkers';
import type { ChatMessage, ChatMessagePart } from './mainChatPanel/types';
import './systemChatStyles/MainChatPanel.conversation.css';
import './systemChatStyles/MainChatPanel.preview-runtime.css';
import './SystemChatPage.css';
const { Paragraph, Text, Title } = Typography;
type MockRequestStatus = 'completed' | 'pending';
type MockRequest = {
requestId: string;
createdAt: string;
status: MockRequestStatus;
statusLabel: string;
statusColor: string;
question: string;
answer: string;
answerParts?: ChatMessagePart[];
};
type SearchPanelMode = 'all' | 'apps';
type ExpandMode = 'latest' | 'pending' | 'all';
type SearchResult = {
key: string;
title: string;
description: string;
category: 'request' | 'response' | 'resource' | 'activity';
};
type SystemRenderedMessage = {
visibleText: string;
linkCardParts: Extract<ChatMessagePart, { type: 'link_card' }>[];
promptParts: Extract<ChatMessagePart, { type: 'prompt' }>[];
previewItems: PreviewItem[];
};
const INITIAL_REQUESTS: MockRequest[] = [
{
requestId: 'system-request-001',
createdAt: '05. 25. 21:30:00',
status: 'completed',
statusLabel: '완료',
statusColor: 'green',
question: '시스템 채팅 전용 기본 화면을 공유채팅과 같은 감성으로 맞춰 주세요.',
answer: [
'현재 화면은 UI 전용 mock 입니다. 서버 호출 없이 공유채팅과 같은 구조를 그대로 보이도록 구성했습니다.',
'',
'[[link-card:시스템 채팅 화면 열기|/chat/system|열기]]',
'',
'[[preview:/src/app/main/SystemChatPage.tsx]]',
].join('\n'),
},
{
requestId: 'system-request-002',
createdAt: '05. 25. 21:31:10',
status: 'pending',
statusLabel: '처리중·미확인',
statusColor: 'gold',
question: 'Codex Live, 공유채팅과 소스를 분리한 상태에서 이후 시스템 채팅 전용 기능만 추가할 수 있게 해 주세요.',
answer: '이 페이지는 별도 파일과 시스템 채팅 전용 CSS 레이어를 사용합니다. 렌더 표현은 Codex Live와 같은 컴포넌트를 쓰더라도 상태와 배치는 여기서만 분리합니다.',
answerParts: [
{
type: 'prompt',
title: '답변 방식 선택',
description: '시스템 채팅 mock prompt 입니다.',
submitLabel: 'mock 전송',
mode: 'queue',
freeTextLabel: '추가 요청',
freeTextPlaceholder: '시스템 채팅에서 이어서 확인할 내용을 입력하세요.',
options: [
{
value: 'brief',
label: '짧게 정리',
description: '핵심만 바로 답합니다.',
preview: {
type: 'markdown',
title: '짧게 정리 예시',
content: '짧은 답변 예시: 핵심만 먼저 전달하고 필요시 후속 설명을 확장합니다.',
url: null,
alt: null,
},
},
{
value: 'full',
label: '상세 설명',
description: '배경과 이유까지 함께 답합니다.',
preview: {
type: 'markdown',
title: '상세 설명 예시',
content: '상세 설명 예시: 시스템 채팅 전용 흐름의 배경과 이유까지 함께 설명합니다.',
url: null,
alt: null,
},
},
],
},
],
},
{
requestId: 'system-request-003',
createdAt: '05. 25. 21:33:40',
status: 'completed',
statusLabel: '완료',
statusColor: 'green',
question: '시스템 채팅 검색 모달과 토큰 관리 모달 배치를 실제 화면처럼 먼저 잡아 주세요.',
answer: [
'검색 모달, 토큰 관리, 채팅방 설정 모달은 현재 mock 데이터로 열리며, 향후 시스템 채팅 전용 서버 연결만 붙일 수 있게 비워 두었습니다.',
'',
'[[link-card:토큰 관리 UI 안내|https://preview.sm-home.cloud/docs|열기]]',
'',
'[[preview:/src/app/main/SystemChatPage.css]]',
].join('\n'),
},
{
requestId: 'system-request-004',
createdAt: '05. 25. 21:35:15',
status: 'pending',
statusLabel: '처리중·미확인',
statusColor: 'gold',
question: '이전 다음 이동이 현재 보이는 필터 기준으로만 동작하게 해 주세요.',
answer: '필터가 전체면 전체 목록 기준, 처리중·미확인이면 해당 건들만 기준, 마지막건이면 현재 선택된 1건만 보이도록 맞춥니다.',
},
{
requestId: 'system-request-005',
createdAt: '05. 25. 21:38:55',
status: 'pending',
statusLabel: '처리중·미확인',
statusColor: 'gold',
question: '웹소켓 상태는 제목 옆 점으로만 간단히 보여 주고, 현재 진행 상황 패널은 빼 주세요.',
answer: '상태 표현은 헤더 옆 점으로 단순화하고, 별도 진행 상황 카드 없이 채팅 흐름만 보도록 구성할 수 있습니다.',
},
];
const SEARCH_RESULTS: SearchResult[] = [
{
key: 'request-001',
title: '질문 / 시스템 채팅 전용 기본 화면',
description: '공유채팅과 같은 레이아웃으로 구성된 mock 질문 카드입니다.',
category: 'request',
},
{
key: 'response-001',
title: '답변 / UI 전용 mock 상태',
description: '서버 호출 없이 동작하는 현재 화면 설명입니다.',
category: 'response',
},
{
key: 'resource-001',
title: '리소스 / 시스템 채팅 전용 스타일 복사본',
description: '공유채팅과 분리된 전용 CSS 파일을 사용합니다.',
category: 'resource',
},
{
key: 'activity-001',
title: '활동 / 필터와 이전 다음 mock 흐름',
description: '마지막건, 처리중·미확인, 전체 필터에 따라 보이는 흐름이 달라집니다.',
category: 'activity',
},
];
function dedupePromptParts(parts: Extract<ChatMessagePart, { type: 'prompt' }>[]) {
const promptByKey = new Map<string, Extract<ChatMessagePart, { type: 'prompt' }>>();
parts.forEach((part) => {
const key = `${part.title}:${buildPromptTargetSignature(part)}`;
if (!promptByKey.has(key)) {
promptByKey.set(key, part);
}
});
return [...promptByKey.values()];
}
function dedupeLinkCardParts(parts: Extract<ChatMessagePart, { type: 'link_card' }>[]) {
const linkCardByKey = new Map<string, Extract<ChatMessagePart, { type: 'link_card' }>>();
parts.forEach((part) => {
const key = `${part.title}:${part.url}:${part.actionLabel ?? ''}`;
if (!linkCardByKey.has(key)) {
linkCardByKey.set(key, part);
}
});
return [...linkCardByKey.values()];
}
function extractSystemMessageRenderPayload(message: ChatMessage): SystemRenderedMessage {
const extracted = extractChatMessageParts(message.text);
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
const visibleText = stripHiddenPreviewTags(extracted.strippedText || message.text).trim();
const promptParts = dedupePromptParts([
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
]);
const linkCardParts = dedupeLinkCardParts([
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
...extracted.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
]);
return {
visibleText,
linkCardParts,
promptParts,
previewItems: extractPreviewItems([message]),
};
}
function buildSystemPromptSelectionKey(messageId: number, promptIndex: number, target: Extract<ChatMessagePart, { type: 'prompt' }>) {
return `${messageId}:${promptIndex}:${target.title}:${buildPromptTargetSignature(target)}`;
}
function SystemChatPreviewCard({ item }: { item: PreviewItem }) {
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [previewText, setPreviewText] = useState('');
const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState('');
const [isLoading, setIsLoading] = useState(false);
const target = useMemo<ChatPreviewTarget>(
() => ({
label: item.label,
url: item.url,
kind: item.kind,
}),
[item.kind, item.label, item.url],
);
useEffect(() => {
if (!isPreviewOpen) {
return undefined;
}
if (item.kind === 'image' || item.kind === 'video' || item.kind === 'pdf' || item.kind === 'file') {
setPreviewText('');
setPreviewError('');
setPreviewContentType('');
setIsLoading(false);
return undefined;
}
const controller = new AbortController();
setIsLoading(true);
setPreviewError('');
fetch(item.url, {
cache: 'no-store',
credentials: 'include',
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`.trim());
}
setPreviewContentType(response.headers.get('content-type') ?? '');
const text = await response.text();
setPreviewText(text);
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}
setPreviewText('');
setPreviewContentType('');
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
return () => controller.abort();
}, [isPreviewOpen, item.kind, item.url]);
return (
<section className="system-chat-page__preview-card app-chat-preview-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{item.label}</span>
<span className="app-chat-preview-card__kind">{item.kind} preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__open-link"
icon={<EyeOutlined />}
aria-label={isPreviewOpen ? `${item.label} 접기` : `${item.label} 미리보기`}
onClick={() => setIsPreviewOpen((current) => !current)}
/>
</div>
</div>
{isPreviewOpen ? (
<div className="app-chat-preview-card__body system-chat-page__preview-card-body">
<ChatPreviewBody
target={target}
previewText={previewText}
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType || undefined}
renderHtmlAsFrame
maxMarkdownBlocks={5}
/>
</div>
) : null}
</section>
);
}
function SystemChatMessageArtifacts({
message,
promptDraftSelections,
promptSubmittedSelections,
onPromptSelectionChange,
onPromptSubmitted,
onPromptSubmit,
}: {
message: ChatMessage;
promptDraftSelections: Record<string, PromptDraftSelection | null>;
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void;
onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void;
onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise<boolean>;
}) {
const { linkCardParts, promptParts, previewItems } = useMemo(
() => extractSystemMessageRenderPayload(message),
[message],
);
if (linkCardParts.length === 0 && promptParts.length === 0 && previewItems.length === 0) {
return null;
}
return (
<div className="system-chat-page__artifact-stack">
{linkCardParts.length > 0 ? (
<div className="system-chat-page__artifact-list">
{linkCardParts.map((target) => (
<ChatLinkCardPreview key={`${target.title}:${target.url}`} target={target} />
))}
</div>
) : null}
{promptParts.length > 0 ? (
<div className="system-chat-page__artifact-list">
{promptParts.map((target, promptIndex) => {
const selectionKey = buildSystemPromptSelectionKey(message.id, promptIndex, target);
return (
<ChatPromptCard
key={selectionKey}
target={target}
draftSelection={promptDraftSelections[selectionKey] ?? null}
submittedSelection={promptSubmittedSelections[selectionKey] ?? null}
onSelectionChange={(selection) => onPromptSelectionChange(selectionKey, selection)}
onSubmitted={(selection) => onPromptSubmitted(selectionKey, selection)}
onSubmit={(payload) => onPromptSubmit(selectionKey, payload)}
/>
);
})}
</div>
) : null}
{previewItems.length > 0 ? (
<div className="system-chat-page__artifact-list">
{previewItems.map((item) => (
<SystemChatPreviewCard key={item.id} item={item} />
))}
</div>
) : null}
</div>
);
}
function SystemChatRequestCard({
request,
isReplyActive,
promptDraftSelections,
promptSubmittedSelections,
onReplyToggle,
onComplete,
onPromptSelectionChange,
onPromptSubmitted,
onPromptSubmit,
}: {
request: MockRequest;
isReplyActive: boolean;
promptDraftSelections: Record<string, PromptDraftSelection | null>;
promptSubmittedSelections: Record<string, PromptDraftSelection | null>;
onReplyToggle: (requestId: string) => void;
onComplete: (requestId: string) => void;
onPromptSelectionChange: (key: string, selection: PromptDraftSelection | null) => void;
onPromptSubmitted: (key: string, selection: PromptDraftSelection) => void;
onPromptSubmit: (key: string, payload: PromptSubmitPayload) => Promise<boolean>;
}) {
const isCompleted = request.status === 'completed';
const questionMessage = useMemo<ChatMessage>(
() => ({
id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}1`),
author: 'user',
text: request.question,
timestamp: request.createdAt,
}),
[request.createdAt, request.question, request.requestId],
);
const answerMessage = useMemo<ChatMessage>(
() => ({
id: Number(`${request.requestId.replace(/\D+/g, '') || '0'}2`),
author: 'codex',
text: request.answer,
timestamp: request.createdAt,
clientRequestId: request.requestId,
parts: request.answerParts,
}),
[request.answer, request.answerParts, request.createdAt, request.requestId],
);
return (
<section id={`system-chat-request-${request.requestId}`} className="chat-share-page__request-block">
<span className="chat-share-page__message-time">{request.createdAt}</span>
<div className="chat-share-page__message-tone chat-share-page__message-tone--question">
<span className="chat-share-page__message-tone-label"></span>
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
{extractSystemMessageRenderPayload(questionMessage).visibleText}
</Paragraph>
</div>
<SystemChatMessageArtifacts
message={questionMessage}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onPromptSelectionChange={onPromptSelectionChange}
onPromptSubmitted={onPromptSubmitted}
onPromptSubmit={onPromptSubmit}
/>
<div className="chat-share-page__message-divider" aria-hidden="true" />
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
{!isCompleted ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
icon={<CheckOutlined />}
onClick={() => onComplete(request.requestId)}
>
</Button>
) : null}
<Button
type="text"
size="small"
className={`chat-share-page__prompt-complete-button chat-share-page__response-reply-button${isReplyActive ? ' chat-share-page__response-reply-button--active' : ''}`}
icon={<SendOutlined />}
onClick={() => onReplyToggle(request.requestId)}
>
{isReplyActive ? '답변 참조 중' : '답변하기'}
</Button>
</div>
<div className="chat-share-page__message-tone chat-share-page__message-tone--answer">
<span className="chat-share-page__message-tone-label"></span>
<Paragraph className="chat-share-page__message-body system-chat-page__message-text" style={{ marginBottom: 0 }}>
{extractSystemMessageRenderPayload(answerMessage).visibleText}
</Paragraph>
</div>
<SystemChatMessageArtifacts
message={answerMessage}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onPromptSelectionChange={onPromptSelectionChange}
onPromptSubmitted={onPromptSubmitted}
onPromptSubmit={onPromptSubmit}
/>
</section>
);
}
export function SystemChatPage() {
const { message } = App.useApp();
const [requests, setRequests] = useState<MockRequest[]>(INITIAL_REQUESTS);
const [draftText, setDraftText] = useState('');
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState<string | null>('system-request-002');
const [promptDraftSelections, setPromptDraftSelections] = useState<Record<string, PromptDraftSelection | null>>({});
const [promptSubmittedSelections, setPromptSubmittedSelections] = useState<Record<string, PromptDraftSelection | null>>({});
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false);
const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchPanelMode, setSearchPanelMode] = useState<SearchPanelMode>('all');
const [expandMode, setExpandMode] = useState<ExpandMode>('pending');
const [selectedRequestId, setSelectedRequestId] = useState('system-request-002');
const [selectedAppEnvironment, setSelectedAppEnvironment] = useState<'preview' | 'test' | 'prod'>('preview');
const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState('system-chat');
const [editingRoomUseAccessPin, setEditingRoomUseAccessPin] = useState(true);
const [editingRoomAccessPin, setEditingRoomAccessPin] = useState('1234');
const [editingRoomAccessPinPromptTtl, setEditingRoomAccessPinPromptTtl] = useState('30');
const attachInputRef = useRef<HTMLInputElement | null>(null);
const filteredSearchResults = useMemo(() => {
const normalizedKeyword = searchKeyword.trim().toLowerCase();
if (!normalizedKeyword) {
return SEARCH_RESULTS;
}
return SEARCH_RESULTS.filter((item) => `${item.title} ${item.description} ${item.category}`.toLowerCase().includes(normalizedKeyword));
}, [searchKeyword]);
const pendingRequests = useMemo(() => requests.filter((request) => request.status === 'pending'), [requests]);
const navigationRequests = useMemo(() => (expandMode === 'pending' ? pendingRequests : requests), [expandMode, pendingRequests, requests]);
useEffect(() => {
if (navigationRequests.length === 0) {
return;
}
if (!navigationRequests.some((request) => request.requestId === selectedRequestId)) {
setSelectedRequestId(navigationRequests[0].requestId);
}
}, [navigationRequests, selectedRequestId]);
const selectedRequest = useMemo(
() => navigationRequests.find((request) => request.requestId === selectedRequestId) ?? navigationRequests[0] ?? requests[0],
[navigationRequests, requests, selectedRequestId],
);
const displayedRequests = useMemo(() => {
if (expandMode === 'latest') {
return selectedRequest ? [selectedRequest] : [];
}
return navigationRequests;
}, [expandMode, navigationRequests, selectedRequest]);
const selectedRequestIndex = useMemo(
() => navigationRequests.findIndex((request) => request.requestId === selectedRequest?.requestId),
[navigationRequests, selectedRequest],
);
const canMoveToPreviousRequest = expandMode === 'latest' && selectedRequestIndex > 0;
const canMoveToNextRequest = expandMode === 'latest' && selectedRequestIndex >= 0 && selectedRequestIndex < navigationRequests.length - 1;
const hiddenPreviousCount = expandMode === 'latest' && selectedRequestIndex > 0 ? selectedRequestIndex : 0;
const hiddenNextCount = expandMode === 'latest' && selectedRequestIndex >= 0 ? Math.max(0, navigationRequests.length - selectedRequestIndex - 1) : 0;
const pendingCount = pendingRequests.length;
const aggregateStatusTag = pendingCount > 0 ? { color: 'gold', label: '처리중·미확인' } : { color: 'green', label: '완료' };
const headerSummaryLabel = `입력 대기 · 처리 건수 ${requests.length}건 · 미확인 ${pendingCount}`;
const shareHeaderSettingsItems = useMemo<MenuProps['items']>(
() => [
{ key: 'conversation-summary', label: '현재 시스템 채팅방' },
{ key: 'conversation-search', label: '통합검색' },
{ key: 'conversation-refresh', label: '화면 새로고침' },
{ key: 'conversation-apps', label: 'Apps' },
{ type: 'divider' },
{ key: 'conversation-filter-title', label: '콘텐츠 필터', disabled: true },
{ key: 'conversation-filter-latest', label: '마지막건' },
{ key: 'conversation-filter-pending', label: '처리중·미확인' },
{ key: 'conversation-filter-all', label: '전체' },
{ type: 'divider' },
{ key: 'conversation-token-usage', label: '토큰 관리' },
{ key: 'conversation-room-settings', label: '채팅방 설정' },
{ key: 'conversation-clear', label: '채팅방 비우기' },
],
[],
);
const handleHeaderMenuClick = ({ key }: { key: string }) => {
if (key === 'conversation-search') {
setIsSearchOpen(true);
setSearchPanelMode('all');
return;
}
if (key === 'conversation-apps') {
setIsSearchOpen(true);
setSearchPanelMode('apps');
return;
}
if (key === 'conversation-filter-latest') {
setExpandMode('latest');
return;
}
if (key === 'conversation-filter-pending') {
setExpandMode('pending');
return;
}
if (key === 'conversation-filter-all') {
setExpandMode('all');
return;
}
if (key === 'conversation-token-usage') {
setIsTokenUsageOpen(true);
return;
}
if (key === 'conversation-room-settings') {
setIsRoomSettingsOpen(true);
return;
}
message.info('시스템 채팅 UI mock 화면입니다.');
};
const handleSend = () => {
message.info('시스템 채팅 UI mock 화면입니다. 현재는 서버 호출 없이 형태만 제공합니다.');
};
const handleOpenAttachPicker = () => {
attachInputRef.current?.click();
};
const handleAttachSelection = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
if (files.length > 0) {
message.info('파일 또는 사진 ' + String(files.length) + '건 선택 mock 상태입니다.');
}
event.target.value = '';
};
const handleMoveRequest = (direction: -1 | 1) => {
const nextIndex = selectedRequestIndex + direction;
const nextRequest = navigationRequests[nextIndex];
if (nextRequest) {
setSelectedRequestId(nextRequest.requestId);
}
};
const handleCompleteRequest = (requestId: string) => {
setRequests((current) =>
current.map((request) =>
request.requestId === requestId
? {
...request,
status: 'completed',
statusLabel: '완료',
statusColor: 'green',
}
: request,
),
);
message.success('완료 처리 mock 상태를 반영했습니다.');
};
const handleReplyToggle = (requestId: string) => {
setReplyReferenceRequestId((current) => (current === requestId ? null : requestId));
};
const handlePromptSelectionChange = (key: string, selection: PromptDraftSelection | null) => {
setPromptDraftSelections((current) => ({
...current,
[key]: selection,
}));
};
const handlePromptSubmitted = (key: string, selection: PromptDraftSelection) => {
setPromptSubmittedSelections((current) => ({
...current,
[key]: selection,
}));
};
const handlePromptSubmit = async (key: string, payload: PromptSubmitPayload) => {
setPromptSubmittedSelections((current) => ({
...current,
[key]: payload.selection,
}));
message.success(`${payload.promptTitle} mock 선택을 반영했습니다.`);
return true;
};
return (
<div className="chat-share-page system-chat-page">
<div className="chat-share-page__shell">
<div className="chat-share-page__prompt-layout">
<section className="chat-share-page__panel chat-share-page__conversation-panel">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<div className="chat-share-page__section-title-row">
<div className="system-chat-page__title-status">
<Title level={5}></Title>
<span className="system-chat-page__ws-indicator system-chat-page__ws-indicator--connected" aria-label="웹소켓 연결 정상" title="웹소켓 연결 정상" />
</div>
<Tag color={aggregateStatusTag.color}>{aggregateStatusTag.label}</Tag>
<Text type="secondary" className="chat-share-page__header-summary">{headerSummaryLabel}</Text>
</div>
</div>
<div className="chat-share-page__section-actions">
<div className="chat-share-page__request-nav" aria-label="요청 이동">
<Button type="text" size="small" className="chat-share-page__section-action" icon={<LeftOutlined />} disabled={!canMoveToPreviousRequest} onClick={() => handleMoveRequest(-1)}>
</Button>
<Button type="text" size="small" className="chat-share-page__section-action" icon={<RightOutlined />} iconPosition="end" disabled={!canMoveToNextRequest} onClick={() => handleMoveRequest(1)}>
</Button>
</div>
<Dropdown
trigger={['click']}
menu={{ items: shareHeaderSettingsItems, className: 'chat-share-page__settings-menu', onClick: handleHeaderMenuClick }}
placement="bottomRight"
>
<Button type="text" size="small" className="chat-share-page__section-action chat-share-page__section-action--tool system-chat-page__icon-tool-button" aria-label="채팅 설정" title="채팅 설정" icon={<SettingOutlined />} />
</Dropdown>
</div>
</div>
<div className="chat-share-page__message-list">
{hiddenPreviousCount > 0 ? (
<div className="system-chat-page__omitted-divider" aria-label={'이전 요청 ' + String(hiddenPreviousCount) + '건 생략'}>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
<span className="system-chat-page__omitted-divider-text"> {hiddenPreviousCount} </span>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
</div>
) : null}
{displayedRequests.map((request) => (
<SystemChatRequestCard
key={request.requestId}
request={request}
isReplyActive={replyReferenceRequestId === request.requestId}
promptDraftSelections={promptDraftSelections}
promptSubmittedSelections={promptSubmittedSelections}
onReplyToggle={handleReplyToggle}
onComplete={handleCompleteRequest}
onPromptSelectionChange={handlePromptSelectionChange}
onPromptSubmitted={handlePromptSubmitted}
onPromptSubmit={handlePromptSubmit}
/>
))}
{hiddenNextCount > 0 ? (
<div className="system-chat-page__omitted-divider" aria-label={'다음 요청 ' + String(hiddenNextCount) + '건 생략'}>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
<span className="system-chat-page__omitted-divider-text"> {hiddenNextCount} </span>
<span className="system-chat-page__omitted-divider-line" aria-hidden="true" />
</div>
) : null}
</div>
</section>
<section className="chat-share-page__panel chat-share-page__composer-panel">
<div className="chat-share-page__composer-shell app-chat-panel__composer">
<input ref={attachInputRef} className="chat-share-page__composer-file-input app-chat-panel__composer-file-input" type="file" multiple accept="image/*,.pdf,.txt,.md,.csv,.json,.zip,.heic,.heif" onChange={handleAttachSelection} style={{ display: 'none' }} />
<div className="chat-share-page__composer-topline">
<div className="app-chat-panel__composer-utility-buttons">
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-attach-button" icon={<PlusOutlined />} aria-label="파일" title="파일" onClick={handleOpenAttachPicker} />
</div>
<div className="app-chat-panel__composer-type chat-share-page__composer-type-readonly">
<Select value="시스템 채팅" aria-label="현재 채팅유형" options={[{ value: '시스템 채팅', label: '시스템 채팅' }]} disabled />
</div>
<div className="app-chat-panel__composer-actions chat-share-page__composer-topline-actions">
<div className="app-chat-panel__composer-action-buttons system-chat-page__composer-action-buttons">
<Button className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--instant" icon={<ThunderboltOutlined />} aria-label="즉시전송" title="즉시전송" onClick={handleSend} />
<Button type="primary" className="system-chat-page__composer-icon-button system-chat-page__composer-icon-button--send" icon={<SendOutlined />} aria-label="답변 전송" title="답변 전송" onClick={handleSend} />
</div>
</div>
</div>
{replyReferenceRequestId ? (
<div className="chat-share-page__reply-reference system-chat-page__reply-reference">
<div className="chat-share-page__reply-reference-copy">
<span className="chat-share-page__reply-reference-label"> </span>
<span className="chat-share-page__reply-reference-text">{requests.find((request) => request.requestId === replyReferenceRequestId)?.question ?? '선택된 요청'}</span>
</div>
<Button type="text" size="small" className="chat-share-page__reply-reference-clear" onClick={() => setReplyReferenceRequestId(null)}></Button>
</div>
) : null}
<div className="chat-share-page__composer-entry-row">
<div className="app-chat-panel__composer-input-shell chat-share-page__composer-input-shell">
<Input.TextArea value={draftText} onChange={(event) => setDraftText(event.target.value)} placeholder="시스템 채팅에 보낼 내용을 입력하세요. 현재는 UI 형태만 제공됩니다." rows={6} maxLength={20000} autoSize={{ minRows: 6, maxRows: 10 }} />
</div>
</div>
</div>
</section>
</div>
</div>
<Modal open={isTokenUsageOpen} footer={null} title="토큰 관리" className="chat-share-page__token-usage-modal" onCancel={() => setIsTokenUsageOpen(false)}>
<div className="chat-share-page__token-usage-modal-body">
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<Text strong> UI mock-admin</Text>
</div>
<div className="chat-share-page__token-usage-overview-card" aria-label="토큰 집계 요약">
<div className="chat-share-page__token-usage-overview-head">
<div>
<div className="chat-share-page__token-usage-overview-label"> </div>
<div className="chat-share-page__token-usage-overview-value">100,000</div>
</div>
<Tag color="gold">5 12%</Tag>
</div>
<div className="chat-share-page__token-usage-meter-card">
<div className="chat-share-page__token-usage-meter-track chat-share-page__token-usage-meter-track--merged" aria-hidden="true">
<span className="chat-share-page__token-usage-meter-fill chat-share-page__token-usage-meter-fill--overall" style={{ width: '12%' }} />
</div>
<div className="chat-share-page__token-usage-meter-legend">
<div className="chat-share-page__token-usage-meter-row">
<span className="chat-share-page__token-usage-meter-dot chat-share-page__token-usage-meter-fill--overall" aria-hidden="true" />
<span className="chat-share-page__token-usage-meter-label"> </span>
<span className="chat-share-page__token-usage-meter-value">12,000 / 100,000</span>
</div>
</div>
</div>
<div className="chat-share-page__token-usage-summary-copy"> UI mock .</div>
</div>
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<div className="chat-share-page__token-usage-share-url-row">
<Paragraph className="chat-share-page__token-usage-share-url" style={{ maxWidth: '100%', marginBottom: 0 }}>
https://preview.sm-home.cloud/chat/system
</Paragraph>
<Button type="text" size="small" className="chat-share-page__token-usage-copy-button" icon={<CopyOutlined />} onClick={() => message.success('예시 URL을 복사했습니다.')} />
</div>
<div className="chat-share-page__token-usage-token-meta">
<Text type="secondary">UI mock · </Text>
</div>
</div>
</div>
</Modal>
<Modal
open={isRoomSettingsOpen}
title="시스템 채팅 설정"
okText="저장"
cancelText="취소"
onCancel={() => setIsRoomSettingsOpen(false)}
onOk={() => {
message.success('시스템 채팅 UI 설정을 저장한 것처럼 표시합니다. 현재는 mock 상태입니다.');
setIsRoomSettingsOpen(false);
}}
>
<div className="chat-share-page__token-usage-modal">
<Alert showIcon type="info" message="현재 화면은 서버 연결 없는 UI 전용 시스템 채팅입니다." />
<div className="chat-share-page__token-usage-panel">
<Text strong></Text>
<Select value={editingRoomChatTypeId} onChange={setEditingRoomChatTypeId} options={[{ value: 'system-chat', label: '시스템 채팅' }]} />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<Input value="시스템 채팅 기본 규칙" readOnly />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<Input.TextArea rows={8} value="이 화면은 공유채팅과 동일한 구조의 UI mock이며, 향후 시스템 채팅 전용 기능을 여기에만 추가합니다." readOnly />
</div>
<div className="chat-share-page__token-usage-panel">
<Text strong> </Text>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input type="checkbox" checked={editingRoomUseAccessPin} onChange={(event) => setEditingRoomUseAccessPin(event.target.checked)} />
<span> 4 </span>
</label>
<Input.Password value={editingRoomAccessPin} maxLength={4} onChange={(event) => setEditingRoomAccessPin(event.target.value)} placeholder="숫자 4자리" />
<Text strong style={{ marginTop: 12 }}> </Text>
<Select value={editingRoomAccessPinPromptTtl} onChange={setEditingRoomAccessPinPromptTtl} options={[{ value: 'always', label: '매번 묻기' }, { value: '30', label: '30분 유지' }, { value: '60', label: '1시간 유지' }]} />
</div>
</div>
</Modal>
<Modal open={isSearchOpen} footer={null} title={searchPanelMode === 'apps' ? '시스템 채팅 Apps' : '시스템 채팅 통합검색'} className="chat-share-page__search-modal" onCancel={() => setIsSearchOpen(false)}>
<div className="chat-share-page__search-modal-body">
<Input autoFocus allowClear size="large" prefix={<SearchOutlined />} placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
<div className="chat-share-page__search-summary">
<Text type="secondary">{searchKeyword.trim() ? `검색 결과 ${filteredSearchResults.length}` : searchPanelMode === 'apps' ? '시스템 채팅에서 허용할 앱 예시를 보여줍니다.' : '질문, 답변, 리소스, 활동 로그 mock 데이터를 함께 찾습니다.'}</Text>
</div>
{searchPanelMode === 'apps' ? (
<div className="chat-share-page__search-app-environment">
<Text type="secondary"> </Text>
<Select value={selectedAppEnvironment} options={[{ value: 'preview', label: 'preview' }, { value: 'test', label: 'test' }, { value: 'prod', label: 'prod' }]} onChange={setSelectedAppEnvironment} />
</div>
) : null}
<div className="chat-share-page__search-results">
{filteredSearchResults.length > 0 ? (
filteredSearchResults.map((result) => (
<div key={result.key} className="chat-share-page__search-result">
<button type="button" className="chat-share-page__search-result-main" onClick={() => message.info(`${result.title} 항목은 UI mock 데이터입니다.`)}>
<span className="chat-share-page__search-result-title">{result.title}</span>
<span className="chat-share-page__search-result-description">{result.description}</span>
</button>
{searchPanelMode === 'apps' ? (
<div className="chat-share-page__search-result-action-group">
<Tag bordered={false} className="chat-share-page__search-result-tag"> {selectedAppEnvironment}</Tag>
<Button type="text" size="small" className="chat-share-page__search-result-action chat-share-page__search-result-action--environment" icon={<AppstoreOutlined />} onClick={() => message.info('앱 실행은 연결되지 않았습니다.')}> </Button>
</div>
) : null}
</div>
))
) : (
<div className="chat-share-page__search-empty">
<Text type="secondary"> .</Text>
</div>
)}
</div>
</div>
</Modal>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
@import './mainChatPanel/styles/MainChatPanel.layout.css';
@import './mainChatPanel/styles/MainChatPanel.conversation.css';
@import './mainChatPanel/styles/MainChatPanel.preview-runtime.css';

File diff suppressed because it is too large Load Diff

View File

@@ -53,12 +53,12 @@ type SharedTokenSettingAccess = {
const MANAGEMENT_APP_OPTIONS: AppOption[] = [
{ value: 'chat-live', label: 'Codex Live', description: '채팅방 조회와 응답 흐름 진입', category: '관리' },
{ value: 'chat-rooms', label: '시스템 채팅방', description: '메뉴별 시스템 채팅방 화면 접근', category: '관리' },
{ value: 'chat-room-settings', label: '채팅방 설정', description: 'Codex Live 채팅방 Context 설정 편집 접근', category: '관리' },
{ value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', category: '관리' },
{ value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', category: '관리' },
{ value: 'plan-board', label: '자동화 현황', description: '작업 현황과 요청 보드 접근', category: '관리' },
{ value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', category: '관리' },
{ value: 'server-command', label: '서버관리', description: '서버 상태 확인과 재기동 예약/실행 접근', category: '관리' },
{ value: 'resource-manager', label: '리소스 관리', description: '세션 리소스와 파일 미리보기 접근', category: '관리' },
{ value: 'error-log', label: '에러 로그', description: '앱 로그와 장애 이력 조회', category: '관리' },
];

View File

@@ -0,0 +1,13 @@
const MANAGED_CHAT_SHARE_SESSION_PREFIX = 'chat-share-room-';
export function isManagedChatShareSessionId(sessionId: string | null | undefined) {
return String(sessionId ?? '').trim().startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX);
}
export function resolveChatPathForSession(_sessionId: string) {
return '/chat/live';
}
export function shouldShowPrimaryConversation(sessionId: string) {
return !isManagedChatShareSessionId(sessionId);
}

View File

@@ -1,6 +1,6 @@
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { shouldShowConversationForMode } from '../../isolatedChatRooms';
import { shouldShowPrimaryConversation } from '../../chatSessionRouting';
const { Text } = Typography;
@@ -26,7 +26,7 @@ export function ConversationListPane({
onSelectSession,
onCreateConversation,
}: ConversationListPaneProps) {
const visibleItems = items.filter((item) => shouldShowConversationForMode(item.sessionId, 'live'));
const visibleItems = items.filter((item) => shouldShowPrimaryConversation(item.sessionId));
return (
<section className="chat-v2__pane chat-v2__pane--list">

View File

@@ -5,6 +5,11 @@ import type {
ChatRuntimeSnapshot,
} from '../../mainChatPanel/types';
const SCROLL_JUMP_HIDE_THRESHOLD = 24;
const SCROLL_JUMP_MIN_OVERFLOW = 48;
const SCROLL_JUMP_DIRECTION_THRESHOLD = 6;
const SCROLL_JUMP_IDLE_HIDE_DELAY_MS = 900;
type UseConversationViewportControllerOptions = {
activeConversation: ChatConversationSummary | null;
activeQueuedComposerRequestsCount: number;
@@ -50,6 +55,7 @@ export function useConversationViewportController({
const systemStatusTimerRef = useRef<number | null>(null);
const restoreAutoScrollFrameRef = useRef<number | null>(null);
const showScrollToBottomRef = useRef(false);
const scrollJumpIdleTimerRef = useRef<number | null>(null);
const shouldStickToBottomRef = useRef(true);
const lastViewportScrollTopRef = useRef(0);
const autoScrollSuspendedUntilRef = useRef(0);
@@ -74,6 +80,13 @@ export function useConversationViewportController({
}
}, []);
const clearScrollJumpIdleTimer = useCallback(() => {
if (scrollJumpIdleTimerRef.current !== null) {
window.clearTimeout(scrollJumpIdleTimerRef.current);
scrollJumpIdleTimerRef.current = null;
}
}, []);
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
if (showScrollToBottomRef.current === nextValue) {
return;
@@ -144,8 +157,11 @@ export function useConversationViewportController({
}
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
const isNearBottom = remainingDistance <= 24;
const isScrollingUp = viewport.scrollTop < lastViewportScrollTopRef.current - 2;
const maxScrollDistance = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
const currentScrollTop = viewport.scrollTop;
const scrollDelta = currentScrollTop - lastViewportScrollTopRef.current;
const isNearBottom = remainingDistance <= SCROLL_JUMP_HIDE_THRESHOLD;
const isScrollingUp = scrollDelta < -2;
if (isNearBottom) {
releaseAutoScrollSuspension();
@@ -155,9 +171,30 @@ export function useConversationViewportController({
const shouldStickToBottom = isNearBottom && !isAutoScrollSuspended();
shouldStickToBottomRef.current = shouldStickToBottom;
lastViewportScrollTopRef.current = viewport.scrollTop;
syncShowScrollToBottom(!shouldStickToBottom);
}, [isAutoScrollSuspended, releaseAutoScrollSuspension, syncShowScrollToBottom, viewportRef]);
lastViewportScrollTopRef.current = currentScrollTop;
if (maxScrollDistance < SCROLL_JUMP_MIN_OVERFLOW || shouldStickToBottom) {
clearScrollJumpIdleTimer();
syncShowScrollToBottom(false);
return;
}
if (Math.abs(scrollDelta) >= SCROLL_JUMP_DIRECTION_THRESHOLD) {
clearScrollJumpIdleTimer();
scrollJumpIdleTimerRef.current = window.setTimeout(() => {
scrollJumpIdleTimerRef.current = null;
syncShowScrollToBottom(false);
}, SCROLL_JUMP_IDLE_HIDE_DELAY_MS);
}
syncShowScrollToBottom(true);
}, [
clearScrollJumpIdleTimer,
isAutoScrollSuspended,
releaseAutoScrollSuspension,
syncShowScrollToBottom,
viewportRef,
]);
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
if (options?.forceStickToBottom) {
@@ -491,12 +528,13 @@ export function useConversationViewportController({
useEffect(() => {
return () => {
clearSystemStatusTimer();
clearScrollJumpIdleTimer();
if (restoreAutoScrollFrameRef.current !== null) {
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
}
};
}, [clearSystemStatusTimer]);
}, [clearScrollJumpIdleTimer, clearSystemStatusTimer]);
return {
activeSystemStatus,

View File

@@ -0,0 +1,3 @@
export function requestChatWindowAction(_action: 'minimize' | 'close') {
// System chat was removed; shared/live panels no longer use a separate floating window.
}

View File

@@ -1,23 +1,12 @@
import { Layout } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
import { useAppStore } from '../../../store';
import { useTokenAccess } from '../tokenAccess';
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
import { getChatActionContextSnapshot } from '../chatActionContextStore';
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
import { SystemChatPanel } from '../SystemChatPanel';
import { ScopedChatRoomsWindow, ScopedChatRoomsWindowDock } from '../ScopedChatRoomsWindow';
import {
removeMinimizedIsolatedChatRoomEntryByScope,
useActiveIsolatedChatRoomScope,
useIsolatedChatRoomsWindowOpen,
writeActiveIsolatedChatRoomScope,
writeIsolatedChatRoomsWindowOpen,
} from '../isolatedChatRoomScopeStore';
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
import { useTokenAccess } from '../tokenAccess';
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
import { normalizeIsolatedChatRoomScope } from '../isolatedChatRooms';
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
import { MainContent } from '../MainContent';
import { MainHeader } from '../MainHeader';
@@ -120,7 +109,7 @@ function parseRoute(pathname: string): {
if (
top === 'chat' &&
(first === 'live' ||
first === 'rooms' ||
first === 'system' ||
first === 'changes' ||
first === 'resources' ||
first === 'errors' ||
@@ -254,8 +243,6 @@ export function MainLayout() {
const [searchParams] = useSearchParams();
const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore();
const { hasAccess } = useTokenAccess();
const activeScopedChatRoomScope = useActiveIsolatedChatRoomScope();
const isScopedChatRoomsWindowOpen = useIsolatedChatRoomsWindowOpen();
const appConfig = useAppConfig();
const { openSearch, setOptions: setSearchOptions } = useSearchLayer();
const layoutData = useMainLayoutData();
@@ -286,30 +273,6 @@ export function MainLayout() {
navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace });
};
const openScopedChatRooms = useCallback(() => {
const actionSnapshot = getChatActionContextSnapshot();
const scope = normalizeIsolatedChatRoomScope({
topMenu: currentPage.topMenu,
menuTitle: currentPage.title,
featureTitle: actionSnapshot.featureTitle ?? focusedComponentId ?? currentPage.title,
focusedComponentId,
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
selectionSummary: actionSnapshot.selectionSummary,
selectionIds: actionSnapshot.selectionIds,
sourceAppId: actionSnapshot.sourceAppId,
launchedAt: new Date().toISOString(),
});
writeActiveIsolatedChatRoomScope(scope);
removeMinimizedIsolatedChatRoomEntryByScope(scope);
if (routeState.chatMenu === 'rooms') {
writeIsolatedChatRoomsWindowOpen(false);
return;
}
writeIsolatedChatRoomsWindowOpen(true);
}, [currentPage.title, currentPage.topMenu, focusedComponentId, routeState.chatMenu]);
useEffect(() => {
void syncAppConfigFromServer();
@@ -421,20 +384,9 @@ export function MainLayout() {
openSearch();
},
},
{
id: 'mobile-middle-right-search-window',
activeStates: ['anyway'],
mobileOnly: true,
trigger: 'pull-left-middle-right' as const,
hotZoneSize: 36,
minDistance: 180,
minViewportDistanceRatio: 0.35,
maxHorizontalDrift: 72,
onTrigger: openScopedChatRooms,
},
],
}),
[isEReaderImmersiveActive, isMobileViewport, openScopedChatRooms, openSearch, routeState.docsMenu, routeState.topMenu],
[isEReaderImmersiveActive, isMobileViewport, openSearch, routeState.docsMenu, routeState.topMenu],
);
useGesturePageState('anyway');
@@ -446,11 +398,6 @@ export function MainLayout() {
return;
}
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
event.preventDefault();
openScopedChatRooms();
return;
}
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
event.preventDefault();
@@ -465,9 +412,7 @@ export function MainLayout() {
};
}, [
appConfig.gestureShortcuts.openSearch,
appConfig.gestureShortcuts.openWindowSearch,
isEReaderImmersiveActive,
openScopedChatRooms,
openSearch,
]);
@@ -664,16 +609,6 @@ export function MainLayout() {
<Outlet />
</MainContent>
</Layout>
{routeState.chatMenu !== 'rooms' && isScopedChatRoomsWindowOpen ? (
<ScopedChatRoomsWindow
onClose={() => {
writeIsolatedChatRoomsWindowOpen(false);
}}
>
<SystemChatPanel lockOuterScrollOnMobile />
</ScopedChatRoomsWindow>
) : null}
{routeState.chatMenu !== 'rooms' ? <ScopedChatRoomsWindowDock /> : null}
</Layout>
</MainLayoutContextProvider>
);

View File

@@ -237,18 +237,6 @@ export function buildSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:rooms',
label: '시스템 채팅 / 시스템 채팅',
group: 'Page',
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('rooms'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:live',
label: 'Codex Live / Codex Live',
@@ -261,6 +249,18 @@ export function buildSearchOptions({
},
onSelectWindow,
},
{
id: 'page:chat:system',
label: '채팅 / 시스템 채팅',
group: 'Page',
keywords: ['system chat', 'system', 'chat', '시스템 채팅', '시스템', '채팅 ui'],
onSelect: () => {
requestPlanQuickFilter(null);
navigateTo(buildChatPath('system'));
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:changes',
label: 'Codex Live / 변경 이력',

View File

@@ -1,5 +1,6 @@
import {
AppstoreOutlined,
ArrowLeftOutlined,
CheckOutlined,
CloseOutlined,
ControlOutlined,
@@ -19,6 +20,7 @@ import {
PaperClipOutlined,
PlusOutlined,
ProfileOutlined,
RedoOutlined,
SendOutlined,
ShareAltOutlined,
SettingOutlined,
@@ -482,7 +484,6 @@ const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
const IMMEDIATE_SEND_TOGGLE_HOLD_MS = 2000;
const SYSTEM_EXECUTION_JUMP_MAX_RETRIES = 4;
const SYSTEM_EXECUTION_JUMP_TOP_OFFSET = 12;
const MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH = 767;
const DEFAULT_QUEUE_SUMMARY_MAX_LENGTH = 32;
const TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 88;
@@ -690,6 +691,47 @@ function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLEle
return targetRect.top - containerRect.top + container.scrollTop;
}
function getContainerScrollPaddingTop(container: HTMLElement) {
if (typeof window === 'undefined') {
return 0;
}
const paddingTop = Number.parseFloat(window.getComputedStyle(container).paddingTop || '0');
return Number.isFinite(paddingTop) ? Math.max(0, paddingTop) : 0;
}
function resolveScrollableAnchorContainer(target: HTMLElement, preferredContainer?: HTMLElement | null) {
const candidates: Array<HTMLElement | null | undefined> = [preferredContainer, target.parentElement];
let currentAncestor = target.parentElement;
while (currentAncestor) {
candidates.push(currentAncestor);
currentAncestor = currentAncestor.parentElement;
}
for (const candidate of candidates) {
if (!(candidate instanceof HTMLElement)) {
continue;
}
const style = window.getComputedStyle(candidate);
const overflowY = style.overflowY;
const isScrollableOverflow = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
if (!isScrollableOverflow) {
continue;
}
if (candidate.scrollHeight <= candidate.clientHeight + 1) {
continue;
}
return candidate;
}
return null;
}
function isPhoneLikeViewport() {
if (typeof window === 'undefined') {
return false;
@@ -703,6 +745,25 @@ function isPhoneLikeViewport() {
return isNarrowViewport && (hasCoarsePointer || hasTouchPoints);
}
function getSystemExecutionJumpTargetPriority(
target: ReturnType<typeof resolveSystemExecutionJumpTarget>,
) {
if (!target) {
return -1;
}
switch (target.kind) {
case 'prompt':
return 3;
case 'response':
return 2;
case 'request':
return 1;
default:
return 0;
}
}
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
const fileName = buildPreviewFileName(item).toLowerCase();
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
@@ -1502,6 +1563,18 @@ function isRequestRunningStatus(status: ChatConversationRequestStatus | undefine
return status === 'started';
}
function isDisconnectedRequestNeedingAttention(request: ChatConversationRequest | undefined) {
if (!request || request.hasResponse) {
return false;
}
if (request.status !== 'failed') {
return false;
}
return (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청';
}
function isRequestUserFinalized(
request: ChatConversationRequest,
attentionState?: SystemExecutionAttentionState,
@@ -1589,6 +1662,21 @@ function resolveAggregatedRequestStatusSummary(
};
}
function resolveActiveSystemExecutionActionTargetRequest(
requests: ChatConversationRequest[],
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
) {
const activeRequests = requests.filter(
(request) => isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status),
);
if (activeRequests.length === 0) {
return null;
}
return resolveRepresentativeSystemExecutionRequest(activeRequests, attentionStateByRequestId) ?? activeRequests[0]!;
}
function getRepresentativeAttentionPriority(
request: ChatConversationRequest,
attentionState: SystemExecutionAttentionState | undefined,
@@ -1659,13 +1747,22 @@ function resolveRepresentativeSystemExecutionRequest(
function formatRequestStatusLabel(
request: ChatConversationRequest | undefined,
attentionState?: SystemExecutionAttentionState,
options?: {
hideFinalizedLabel?: boolean;
},
) {
const hideFinalizedLabel = options?.hideFinalizedLabel === true;
if (hasAnsweredRequest(request)) {
if (request?.status === "completed") {
if (hideFinalizedLabel) {
return null;
}
return attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료";
}
return "답변도착";
return hideFinalizedLabel ? null : "답변도착";
}
switch (request?.status) {
@@ -1845,10 +1942,11 @@ function hasVisibleActivityOverviewContent(
request.status === 'accepted' ||
request.status === 'queued' ||
request.status === 'started';
const hasPendingAttention = attentionState?.hasOwnAttentionState === true;
const hasChecklistEntries = buildChatActivityChecklistEntries(activityOverview.lines, request).length > 0;
const hasExecutorEntries = activityOverview.executors.length > 0;
if (isUserFinalized || !isInProgress) {
if (isUserFinalized || (!isInProgress && !hasPendingAttention)) {
return false;
}
@@ -3206,15 +3304,40 @@ const ChatComposerInput = memo(function ChatComposerInput({
function SharedRoomsRequestCard({
request,
onSelect,
}: {
request: ChatConversationRequest;
onSelect?: (() => void) | null;
}) {
const questionText = (request.userText ?? "").trim() || "-";
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
const requestStatusLabel = formatRequestStatusLabel(request);
return (
<section className="app-chat-message-group">
<section
className={`app-chat-message-group${onSelect ? ' app-chat-message-group--interactive' : ''}`}
role={onSelect ? 'button' : undefined}
tabIndex={onSelect ? 0 : undefined}
onClick={
onSelect
? () => {
onSelect();
}
: undefined
}
onKeyDown={
onSelect
? (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onSelect();
}
: undefined
}
>
<header className="app-chat-message-group__header">
<div className="app-chat-message-group__header-meta">
{requestStatusLabel ? (
@@ -3367,6 +3490,7 @@ export function ChatConversationView({
const childComposerRefs = useRef(new Map<string, TextAreaRef | null>());
const messageAnchorRefs = useRef(new Map<number, HTMLDivElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const promptCardAnchorRefs = useRef(new Map<number, HTMLElement>());
const systemExecutionBodyRef = useRef<HTMLDivElement | null>(null);
const systemExecutionJumpFrameRef = useRef<number | null>(null);
const systemExecutionOlderLoadRequestedRef = useRef(false);
@@ -3374,6 +3498,14 @@ export function ChatConversationView({
const previousSystemExecutionDisplayModeRef = useRef<SystemExecutionDisplayMode>('collapsed');
const previousSessionIdRef = useRef(sessionId);
const shouldFollowLatestRoomShareGroupRef = useRef(false);
const pendingRoomShareJumpRef = useRef<
| {
groupId: string;
requestId?: string;
fallbackMessageId?: number;
}
| null
>(null);
const immediateSendHoldTimerRef = useRef<number | null>(null);
const suppressImmediateSendClickRef = useRef(false);
const composerDraftValueRef = useRef(draft);
@@ -3773,6 +3905,27 @@ export function ChatConversationView({
return nextMap;
}, [messageRenderPayloadById, orderedMessages]);
const firstPromptMessageIdByRequestId = useMemo(() => {
const nextMap = new Map<string, number>();
orderedMessages.forEach((message) => {
const requestId = message.clientRequestId?.trim();
if (!requestId || nextMap.has(requestId) || (message.author !== 'codex' && message.author !== 'system')) {
return;
}
const { promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
if (promptTargets.length === 0) {
return;
}
nextMap.set(requestId, message.id);
});
return nextMap;
}, [messageRenderPayloadById, orderedMessages]);
const promptFollowupCountByParentRequestId = useMemo(() => {
const nextMap = new Map<string, number>();
@@ -4041,7 +4194,8 @@ export function ChatConversationView({
hasVerificationTarget,
hasConfirmedVerificationTarget,
});
const hasOwnAttentionState = hasPendingPromptBadge || hasPendingVerificationBadge;
const hasOwnAttentionState =
hasPendingPromptBadge || hasPendingVerificationBadge || isDisconnectedRequestNeedingAttention(request);
nextMap.set(request.requestId, {
activityLines,
@@ -4251,6 +4405,21 @@ export function ChatConversationView({
return roomShareRequestGroups;
}, [roomShareExpandMode, roomShareRequestGroups]);
const roomShareGroupIdByRequestId = useMemo(() => {
const nextMap = new Map<string, string>();
roomShareRequestGroups.forEach((entry) => {
entry.groupedRequests.forEach((groupedRequest) => {
const normalizedRequestId = groupedRequest.requestId.trim();
if (normalizedRequestId) {
nextMap.set(normalizedRequestId, entry.groupId);
}
});
});
return nextMap;
}, [roomShareRequestGroups]);
useEffect(() => {
if (!showRoomsShareHeader) {
return;
@@ -4269,6 +4438,9 @@ export function ChatConversationView({
shouldFollowLatestRoomShareGroupRef.current = false;
if (nextLatestGroupId && nextLatestGroupId !== selectedRoomShareGroupId) {
pendingRoomShareJumpRef.current = {
groupId: nextLatestGroupId,
};
setSelectedRoomShareGroupId(nextLatestGroupId);
}
return;
@@ -4514,6 +4686,18 @@ export function ChatConversationView({
messageBodyRefs.current.delete(messageId);
};
const setPromptCardAnchorRef = (messageId: number, element: HTMLElement | null) => {
if (!Number.isFinite(messageId)) {
return;
}
if (element) {
promptCardAnchorRefs.current.set(messageId, element);
return;
}
promptCardAnchorRefs.current.delete(messageId);
};
useEffect(() => {
if (typeof window === 'undefined') {
@@ -4729,16 +4913,15 @@ export function ChatConversationView({
visibleSystemExecutionRequests.length,
]);
const scrollToMessageAnchor = (messageId: number, behavior: ScrollBehavior) => {
const anchorElement = messageAnchorRefs.current.get(messageId);
const scrollToAnchorElement = (anchorElement: HTMLElement | null | undefined, behavior: ScrollBehavior) => {
if (!anchorElement) {
return false;
}
const viewportElement = viewportRef.current;
const scrollContainer = resolveScrollableAnchorContainer(anchorElement, viewportElement);
if (!viewportElement) {
if (!scrollContainer) {
anchorElement.scrollIntoView({
behavior,
block: 'start',
@@ -4748,16 +4931,22 @@ export function ChatConversationView({
const nextTop = Math.max(
0,
getElementOffsetWithinContainer(anchorElement, viewportElement) - SYSTEM_EXECUTION_JUMP_TOP_OFFSET,
getElementOffsetWithinContainer(anchorElement, scrollContainer) - getContainerScrollPaddingTop(scrollContainer),
);
viewportElement.scrollTo({
scrollContainer.scrollTo({
top: nextTop,
behavior,
});
return true;
};
const scrollToMessageAnchor = (messageId: number, behavior: ScrollBehavior) =>
scrollToAnchorElement(messageAnchorRefs.current.get(messageId), behavior);
const scrollToPromptCardAnchor = (messageId: number | null | undefined, behavior: ScrollBehavior) =>
typeof messageId === 'number' && Number.isFinite(messageId)
? scrollToAnchorElement(promptCardAnchorRefs.current.get(messageId), behavior)
: false;
const triggerOlderSystemExecutionLoad = () => {
if (
@@ -4799,31 +4988,94 @@ export function ChatConversationView({
triggerOlderSystemExecutionLoad();
};
const scheduleSystemExecutionJump = (anchorMessageId: number, attempt = 0) => {
const scheduleSystemExecutionJump = (scroll: (behavior: ScrollBehavior) => boolean, attempt = 0) => {
if (systemExecutionJumpFrameRef.current !== null) {
window.cancelAnimationFrame(systemExecutionJumpFrameRef.current);
}
systemExecutionJumpFrameRef.current = window.requestAnimationFrame(() => {
systemExecutionJumpFrameRef.current = null;
const didScroll = scrollToMessageAnchor(anchorMessageId, attempt === 0 ? 'smooth' : 'auto');
const didScroll = scroll(attempt === 0 ? 'smooth' : 'auto');
if (!didScroll && attempt < SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
scheduleSystemExecutionJump(anchorMessageId, attempt + 1);
if (attempt < SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
scheduleSystemExecutionJump(scroll, attempt + 1);
}
if (!didScroll && attempt >= SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
return;
}
});
};
const scrollToSystemExecutionRequest = (requestId: string, fallbackMessageId?: number) => {
const resolveSystemExecutionJumpTarget = (request: ChatConversationRequest | undefined) => {
if (!request) {
return null;
}
const normalizedRequestId = request.requestId.trim();
if (!normalizedRequestId) {
return null;
}
const firstPromptMessageId = firstPromptMessageIdByRequestId.get(normalizedRequestId) ?? null;
const hasPromptTarget = firstPromptMessageId != null || (promptTargetsByRequestId.get(normalizedRequestId)?.length ?? 0) > 0;
if (hasPromptTarget) {
const anchorMessageId =
[firstPromptMessageId, request.responseMessageId].find(
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
) ?? null;
return {
kind: 'prompt' as const,
requestId: normalizedRequestId,
promptMessageId: firstPromptMessageId,
anchorMessageId,
buttonLabel: 'prompt 위치로 이동',
};
}
if (typeof request.responseMessageId === 'number' && Number.isFinite(request.responseMessageId)) {
return {
kind: 'response' as const,
requestId: normalizedRequestId,
anchorMessageId: request.responseMessageId,
buttonLabel: '답변 위치로 이동',
};
}
if (typeof request.userMessageId === 'number' && Number.isFinite(request.userMessageId)) {
return {
kind: 'request' as const,
requestId: normalizedRequestId,
anchorMessageId: request.userMessageId,
buttonLabel: '요청 위치로 이동',
};
}
return null;
};
const scrollToSystemExecutionRequest = (
requestId: string,
options?: {
fallbackMessageId?: number;
jumpTarget?: ReturnType<typeof resolveSystemExecutionJumpTarget>;
},
) => {
const request = requestStateMap.get(requestId);
const normalizedRequestId = requestId.trim();
const rootRequestId =
(normalizedRequestId ? resolveConversationRootRequestId(normalizedRequestId, requestStateMap) : '') ||
normalizedRequestId;
const resolvedJumpTarget = options?.jumpTarget ?? resolveSystemExecutionJumpTarget(request);
const anchorMessageId =
[request?.responseMessageId, request?.userMessageId, fallbackMessageId].find(
resolvedJumpTarget?.anchorMessageId ??
[request?.responseMessageId, request?.userMessageId, options?.fallbackMessageId].find(
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
) ?? null;
) ??
null;
if (!anchorMessageId) {
return false;
@@ -4833,7 +5085,16 @@ export function ChatConversationView({
setExpandedGroupIds((current) => (current.includes(rootRequestId) ? current : [...current, rootRequestId]));
}
setExpandedMessageIds((current) => (current.includes(anchorMessageId) ? current : [...current, anchorMessageId]));
scheduleSystemExecutionJump(anchorMessageId);
scheduleSystemExecutionJump((behavior) => {
if (resolvedJumpTarget?.kind === 'prompt') {
return (
scrollToPromptCardAnchor(resolvedJumpTarget.promptMessageId, behavior) ||
scrollToMessageAnchor(anchorMessageId, behavior)
);
}
return scrollToMessageAnchor(anchorMessageId, behavior);
});
return true;
};
@@ -4847,7 +5108,10 @@ export function ChatConversationView({
scrollToSystemExecutionRequest(
representativeRequest.requestId,
representativeRequest.responseMessageId ?? representativeRequest.userMessageId ?? undefined,
{
fallbackMessageId: representativeRequest.responseMessageId ?? representativeRequest.userMessageId ?? undefined,
jumpTarget: resolveSystemExecutionJumpTarget(representativeRequest),
},
);
};
@@ -4867,15 +5131,72 @@ export function ChatConversationView({
scrollToRoomShareGroup(nextGroup.groupId);
};
useEffect(() => {
const pendingJump = pendingRoomShareJumpRef.current;
if (!pendingJump || pendingJump.groupId !== selectedRoomShareGroupId) {
return;
}
pendingRoomShareJumpRef.current = null;
const frameId = window.requestAnimationFrame(() => {
if (pendingJump.requestId) {
const pendingRequest = requestStateMap.get(pendingJump.requestId);
const pendingJumpTarget = resolveSystemExecutionJumpTarget(pendingRequest);
const didScroll = scrollToSystemExecutionRequest(pendingJump.requestId, {
fallbackMessageId: pendingJump.fallbackMessageId,
jumpTarget: pendingJumpTarget,
});
if (
didScroll &&
(pendingJumpTarget?.kind === 'prompt' || pendingJumpTarget?.kind === 'response') &&
isPhoneLikeViewport() &&
systemExecutionDisplayMode === 'expanded'
) {
setSystemExecutionDisplayMode('hidden');
}
return;
}
scrollToRoomShareGroup(pendingJump.groupId);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [requestStateMap, scrollToRoomShareGroup, selectedRoomShareGroupId, systemExecutionDisplayMode]);
const handleSystemExecutionJump = (request: ChatConversationRequest) => {
const didScroll = scrollToSystemExecutionRequest(
request.requestId,
request.responseMessageId ?? request.userMessageId ?? undefined,
);
const jumpTarget = resolveSystemExecutionJumpTarget(request);
const normalizedRequestId = request.requestId.trim();
const targetGroupId = normalizedRequestId ? roomShareGroupIdByRequestId.get(normalizedRequestId) ?? null : null;
const fallbackMessageId = request.responseMessageId ?? request.userMessageId ?? undefined;
if (
showRoomsShareHeader &&
roomShareExpandMode === 'latest' &&
targetGroupId &&
targetGroupId !== selectedRoomShareGroupId
) {
pendingRoomShareJumpRef.current = {
groupId: targetGroupId,
requestId: normalizedRequestId,
fallbackMessageId,
};
setSelectedRoomShareGroupId(targetGroupId);
return;
}
const didScroll = scrollToSystemExecutionRequest(request.requestId, {
fallbackMessageId,
jumpTarget,
});
if (
didScroll &&
request.responseMessageId &&
(jumpTarget?.kind === 'prompt' || jumpTarget?.kind === 'response') &&
isPhoneLikeViewport() &&
systemExecutionDisplayMode === 'expanded'
) {
@@ -5082,13 +5403,27 @@ export function ChatConversationView({
systemExecutionActivityOverviewByRequestId,
systemExecutionAttentionStateByRequestId,
);
const activityActionTargetRequest = resolveActiveSystemExecutionActionTargetRequest(
depth === 0 ? groupedRequests : [request],
systemExecutionAttentionStateByRequestId,
);
const activityOverview = activityOverviewTargetRequest
? systemExecutionActivityOverviewByRequestId.get(activityOverviewTargetRequest.requestId) ?? null
: null;
const canToggleActivityOverview = activityOverviewTargetRequest != null;
const canToggleActivityOverview = activityActionTargetRequest != null;
const timestampLabel = resolveSystemExecutionRequestTimestamp(representativeRequest);
const elapsedLabel = formatSystemExecutionElapsedLabel(representativeRequest);
const jumpButtonLabel = representativeRequest.responseMessageId ? '답변 위치로 이동' : '요청 위치로 이동';
const summaryJumpTarget = resolveSystemExecutionJumpTarget(summaryRequest);
const representativeJumpTarget = resolveSystemExecutionJumpTarget(representativeRequest);
const jumpTargetRequest =
getSystemExecutionJumpTargetPriority(representativeJumpTarget) >
getSystemExecutionJumpTargetPriority(summaryJumpTarget)
? representativeRequest
: summaryRequest;
const jumpButtonLabel =
(jumpTargetRequest.requestId === representativeRequest.requestId
? representativeJumpTarget
: summaryJumpTarget)?.buttonLabel ?? '위치로 이동';
return (
<div
@@ -5109,7 +5444,7 @@ export function ChatConversationView({
className="app-chat-panel__system-execution-record-main"
aria-label={jumpButtonLabel}
onClick={() => {
handleSystemExecutionJump(representativeRequest);
handleSystemExecutionJump(jumpTargetRequest);
}}
>
{depth > 0 ? (
@@ -5184,12 +5519,10 @@ export function ChatConversationView({
icon={<ProfileOutlined />}
aria-label="Plan 체크리스트와 실행기 보기"
onClick={() => {
if (!activityOverviewTargetRequest) {
return;
}
setSystemExecutionDisplayMode('expanded');
setExpandedSystemExecutionActivityRequestId(activityOverviewTargetRequest.requestId);
setExpandedSystemExecutionActivityRequestId(
activityOverviewTargetRequest?.requestId ?? activityActionTargetRequest?.requestId ?? null,
);
}}
/>
) : null}
@@ -5603,7 +5936,9 @@ export function ChatConversationView({
const attentionState = message.clientRequestId
? systemExecutionAttentionStateByRequestId.get(message.clientRequestId)
: undefined;
const requestStatusLabel = formatRequestStatusLabel(requestState, attentionState);
const requestStatusLabel = formatRequestStatusLabel(requestState, attentionState, {
hideFinalizedLabel: message.author === 'user',
});
const requestDetailText = getRequestDetailText(requestState);
const responsePromptTargets = attentionState?.promptTargets ?? [];
const responsePromptSubmittedCount = attentionState?.promptSubmittedCount ?? 0;
@@ -5904,7 +6239,6 @@ export function ChatConversationView({
</Button>
) : null}
{hasChildRequest ? <Tag color="processing"> </Tag> : null}
{!canCompleteVerificationFromResponse && responsePromptTargets.length === 0 && isResponseVerificationManuallyCompleted ? <Tag color="success"> </Tag> : null}
{canReplyToResponse && message.clientRequestId ? (
<Button
@@ -6019,6 +6353,13 @@ export function ChatConversationView({
sourceMessageId: message.id,
})
}
anchorRef={
index === 0 && message.clientRequestId
? (element) => {
setPromptCardAnchorRef(message.id, element);
}
: null
}
onSharePrompt={
promptParentRequestId && onSharePromptTarget
? () => {
@@ -6360,7 +6701,7 @@ export function ChatConversationView({
const composerPlaceholder = isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: replyReferenceRequest
? '선택한 답변을 바탕으로 시스템 채팅에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
? '선택한 답변을 바탕으로 새 대화에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
: showRoomsShareHeader
? isMobileViewport
? '공유채팅에 보낼 내용을 입력하세요.'
@@ -6535,8 +6876,8 @@ export function ChatConversationView({
size="small"
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon"
icon={<MinusOutlined />}
aria-label="시스템 채팅 최소화"
title="시스템 채팅 최소화"
aria-label="채팅 최소화"
title="채팅 최소화"
onMouseDown={(event) => {
event.preventDefault();
}}
@@ -6552,8 +6893,8 @@ export function ChatConversationView({
danger
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon app-chat-panel__rooms-share-action--close"
icon={<CloseOutlined />}
aria-label="시스템 채팅 닫기"
title="시스템 채팅 닫기"
aria-label="채팅 닫기"
title="채팅 닫기"
onMouseDown={(event) => {
event.preventDefault();
}}
@@ -6666,7 +7007,16 @@ export function ChatConversationView({
}
if (visibleMessages.length === 0) {
return <SharedRoomsRequestCard key={entry.groupId} request={representativeRequest} />;
return (
<SharedRoomsRequestCard
key={entry.groupId}
request={representativeRequest}
onSelect={() => {
setSelectedRoomShareGroupId(entry.groupId);
scrollToRoomShareGroup(entry.groupId);
}}
/>
);
}
return (

View File

@@ -10,6 +10,7 @@ import {
LoadingOutlined,
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
RightOutlined,
ShareAltOutlined,
UpOutlined,
@@ -62,6 +63,7 @@ export type PromptDraftSelection = {
freeText: string;
stepSelections?: PromptStepDraftSelection[];
summaryText?: string | null;
attachments?: ChatComposerAttachment[];
};
export type PromptSubmitPayload = {
@@ -155,16 +157,18 @@ function mergePromptComposerAttachments(previous: ChatComposerAttachment[], next
}
function resolvePromptPasteFiles(clipboardData: DataTransfer) {
const files = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
if (files.length > 0) {
return files;
}
return Array.from(clipboardData.items ?? [])
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file) && file.size > 0);
const files = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : files;
if (candidateFiles.length > 0) {
return candidateFiles;
}
return [];
}
function normalizePromptContextText(value: string | null | undefined, maxLength?: number) {
@@ -291,6 +295,7 @@ function normalizePromptDraftSelection(
freeText,
stepSelections: undefined,
summaryText: null,
attachments: [],
} satisfies PromptDraftSelection;
}
@@ -1046,6 +1051,14 @@ function shouldExpandAllPromptPreviews(step: PromptStep | undefined, selection:
}
function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Record<string, PromptStepDraftSelection>) {
return buildPromptSelectionPayloadWithAttachments(target, stepSelections, []);
}
function buildPromptSelectionPayloadWithAttachments(
target: PromptTarget,
stepSelections: Record<string, PromptStepDraftSelection>,
attachments: ChatComposerAttachment[],
) {
const steps = normalizePromptSteps(target);
const draftSelections = steps
.map((step) => stepSelections[step.key])
@@ -1071,6 +1084,7 @@ function buildPromptSelectionPayload(target: PromptTarget, stepSelections: Recor
freeText: aggregatedFreeText,
stepSelections: meaningfulSelections,
}),
attachments,
} satisfies PromptDraftSelection;
}
@@ -1103,6 +1117,7 @@ export function ChatPromptCard({
allowAttachments = false,
attachmentAccept,
onUploadAttachment,
anchorRef,
}: {
target: PromptTarget;
onSubmit: (payload: PromptSubmitPayload) => Promise<boolean>;
@@ -1118,6 +1133,7 @@ export function ChatPromptCard({
allowAttachments?: boolean;
attachmentAccept?: string;
onUploadAttachment?: ((file: File) => Promise<ChatComposerAttachment>) | null;
anchorRef?: ((element: HTMLElement | null) => void) | null;
}) {
const { message } = App.useApp();
const steps = useMemo(() => normalizePromptSteps(target), [target]);
@@ -1165,6 +1181,7 @@ export function ChatPromptCard({
draftSelection?.summaryText ||
(draftSelection ? buildPromptDraftSummaryText(target, draftSelection) : '');
const submittedFreeTextValue = submittedFreeText || submittedSelection?.freeText.trim() || '';
const submittedAttachments = submittedSelection?.attachments ?? target.attachments ?? [];
const displayedSubmittedSummary = submittedSummary || externallySubmittedSummary;
const displayedParentQuestionText = useMemo(
() => normalizePromptContextText(parentQuestionText, PROMPT_PARENT_QUESTION_PREVIEW_MAX_LENGTH),
@@ -1208,6 +1225,7 @@ export function ChatPromptCard({
buildPromptSelectionPayload(target, stepSelections) ?? {
selectedValues: [],
freeText: '',
attachments: [],
},
)
: resolvedSelectionSummary);
@@ -1216,7 +1234,7 @@ export function ChatPromptCard({
const canAdvance = !isLocked && !isSubmitting && !isUploadingAttachment && canProceed;
const canSubmit = !isLocked && !isSubmitting && !isUploadingAttachment && isFinalStep && canProceed;
const submitLabel = activeStep?.submitLabel?.trim() || target.submitLabel?.trim() || '선택 전달';
const progressPayload = buildPromptSelectionPayload(target, stepSelections);
const progressPayload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
const expandAllOptionPreviews = shouldExpandAllPromptPreviews(activeStep, activeSelection, isLocked);
const expandedOptionPreviewUrl = expandedOption?.preview ? resolvePromptPreviewUrl(expandedOption.preview.url) : '';
@@ -1230,7 +1248,7 @@ export function ChatPromptCard({
return;
}
onSelectionChange(buildPromptSelectionPayload(target, nextSelections));
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, nextSelections, attachments));
};
useEffect(() => {
@@ -1247,7 +1265,7 @@ export function ChatPromptCard({
if (isLocked) {
emitSelectionChange({});
}
}, [isLocked]);
}, [attachments, isLocked]);
useEffect(() => {
if (defaultCollapsed) {
@@ -1260,6 +1278,14 @@ export function ChatPromptCard({
setIsUploadingAttachment(false);
}, [promptResetKey]);
useEffect(() => {
if (isLocked || !onSelectionChange) {
return;
}
onSelectionChange(buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments));
}, [attachments, isLocked, onSelectionChange, stepSelections, target]);
useEffect(() => {
if (!expandedOption?.preview) {
return;
@@ -1375,7 +1401,7 @@ export function ChatPromptCard({
return;
}
const payload = buildPromptSelectionPayload(target, stepSelections);
const payload = buildPromptSelectionPayloadWithAttachments(target, stepSelections, attachments);
if (!payload) {
return;
@@ -1409,7 +1435,7 @@ export function ChatPromptCard({
return (
<>
<section className="app-chat-preview-card app-chat-preview-card--prompt">
<section ref={anchorRef} className="app-chat-preview-card app-chat-preview-card--prompt">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--prompt" aria-hidden="true">
@@ -1703,51 +1729,73 @@ export function ChatPromptCard({
disabled={isSubmitting || isUploadingAttachment}
/>
<Text className="app-chat-prompt-card__free-text-hint">Ctrl+Enter / Cmd+Enter , Enter </Text>
{!isLocked && allowAttachments && onUploadAttachment ? (
<>
<input
ref={attachmentInputRef}
type="file"
multiple
accept={attachmentAccept}
className="app-chat-panel__composer-file-input"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void uploadAttachments(files);
}}
/>
<div className="app-chat-prompt-card__attachment-actions">
<Button
size="small"
className="app-chat-prompt-card__attachment-trigger"
icon={<PlusOutlined />}
onClick={() => attachmentInputRef.current?.click()}
loading={isUploadingAttachment}
disabled={isSubmitting || isUploadingAttachment}
aria-label="첨부 파일 추가"
title="첨부 파일 추가"
/>
<Text className="app-chat-prompt-card__free-text-hint"> Ctrl+V/Cmd+V .</Text>
</div>
{attachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{attachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
setAttachments((current) => current.filter((item) => item.id !== attachment.id));
}}
/>
</div>
))}
</div>
) : null}
</>
) : null}
</div>
) : null}
{!isLocked && allowAttachments && onUploadAttachment ? (
{isLocked && submittedAttachments.length > 0 ? (
<div className="app-chat-prompt-card__free-text">
<input
ref={attachmentInputRef}
type="file"
multiple
accept={attachmentAccept}
className="app-chat-panel__composer-file-input"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void uploadAttachments(files);
}}
/>
<Button
size="small"
icon={<PaperClipOutlined />}
onClick={() => attachmentInputRef.current?.click()}
loading={isUploadingAttachment}
disabled={isSubmitting || isUploadingAttachment}
>
</Button>
{attachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{attachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
setAttachments((current) => current.filter((item) => item.id !== attachment.id));
}}
/>
</div>
))}
</div>
) : null}
<Text className="app-chat-prompt-card__free-text-hint"> .</Text>
<Text className="app-chat-prompt-card__free-text-label"> </Text>
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{submittedAttachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<a
className="app-chat-panel__composer-attachment-name"
href={normalizeChatResourceUrl(attachment.publicUrl)}
target="_blank"
rel="noreferrer"
>
{attachment.name}
</a>
</div>
))}
</div>
</div>
) : null}
<div className="app-chat-prompt-card__footer">

View File

@@ -433,6 +433,7 @@ function mergeConversationSummaries(
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
hasPendingAttention: preferred.hasPendingAttention === true || fallback.hasPendingAttention === true,
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
@@ -1708,6 +1709,7 @@ export async function fetchChatConversations() {
response.items.map((item) => ({
...item,
hasUnreadResponse: resolveStoredConversationUnreadState(item),
hasPendingAttention: item.hasPendingAttention === true,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
})),
);
@@ -2254,6 +2256,7 @@ export async function submitChatPromptSelection(
skipped?: boolean;
}>;
summaryText?: string | null;
attachments?: ChatComposerAttachment[];
followupText: string;
mode?: 'queue' | 'direct';
contextRef?: ChatPromptContextRef | null;
@@ -2599,6 +2602,7 @@ export async function submitChatSharePrompt(
skipped?: boolean;
}>;
summaryText?: string | null;
attachments?: ChatComposerAttachment[];
followupText: string;
contextRef?: ChatPromptContextRef | null;
},
@@ -2644,6 +2648,44 @@ export async function completeChatShareManualBadge(
return normalizeChatConversationRequest(response.item);
}
export async function cancelChatShareRequest(
token: string,
payload: {
parentRequestId: string;
},
) {
const response = await requestChatApi<{ ok: boolean; item: ChatConversationRequest }>(
`/shares/${encodeURIComponent(token)}/request-cancel`,
{
method: 'POST',
body: JSON.stringify(payload),
},
{
allowUnauthenticated: true,
},
);
return normalizeChatConversationRequest(response.item);
}
export async function retryChatShareRequest(
token: string,
payload: {
parentRequestId: string;
},
) {
return requestChatApi<{ ok: boolean; queuedRequestId: string }>(
`/shares/${encodeURIComponent(token)}/request-retry`,
{
method: 'POST',
body: JSON.stringify(payload),
},
{
allowUnauthenticated: true,
},
);
}
type HandleChatServerEventOptions = {
eventData: string;
currentPageUrl: string;

View File

@@ -267,6 +267,53 @@ function normalizePromptSelectedValues(value: unknown) {
.filter((item, index, array) => array.indexOf(item) === index);
}
function normalizePromptAttachment(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const record = value as Record<string, unknown>;
const id = normalizeText(record.id);
const name = normalizeText(record.name);
const path = normalizeText(record.path);
const publicUrl = normalizeText(record.publicUrl);
const size = Number(record.size);
const mimeType = normalizeText(record.mimeType);
if (!id || !name || !path || !publicUrl || !Number.isFinite(size) || size < 0 || !mimeType) {
return null;
}
return {
id,
name,
path,
publicUrl,
size,
mimeType,
};
}
function normalizePromptAttachments(value: unknown) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
return value
.map((item) => normalizePromptAttachment(item))
.filter((item): item is NonNullable<PromptPart['attachments']>[number] => Boolean(item))
.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
function normalizePromptSteps(value: unknown): PromptStep[] {
if (!Array.isArray(value)) {
return [];
@@ -420,6 +467,7 @@ function buildPromptPart(rawBody: string): ChatMessagePart | null {
resolvedBy,
resolvedAt: normalizeText(record.resolvedAt) || null,
resultText: normalizeText(record.resultText) || null,
attachments: normalizePromptAttachments(record.attachments),
options,
};
}

View File

@@ -317,6 +317,28 @@
padding: 0 12px 8px;
}
.app-chat-panel__scroll-jump {
position: absolute;
left: 50%;
bottom: calc(env(safe-area-inset-bottom, 0px) + 104px);
transform: translateX(-50%);
z-index: 6;
pointer-events: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.app-chat-panel__scroll-jump .ant-btn {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
border-radius: 999px;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2);
pointer-events: auto;
}
.app-chat-panel__system-status {
display: inline-flex;
align-items: center;
@@ -937,6 +959,10 @@
}
@media (max-width: 767px) {
.app-chat-panel__scroll-jump {
bottom: calc(env(safe-area-inset-bottom, 0px) + 122px);
}
.app-chat-panel__system-execution-record-tree,
.app-chat-panel__system-execution-record-children {
gap: 6px;
@@ -2233,6 +2259,21 @@
gap: 6px;
}
.app-chat-prompt-card__attachment-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.app-chat-prompt-card__attachment-trigger.ant-btn {
min-width: 0;
width: 30px;
height: 30px;
padding-inline: 0;
border-radius: 999px;
}
.app-chat-prompt-card__free-text-label.ant-typography {
margin: 0;
color: #334155;

View File

@@ -100,6 +100,19 @@
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.app-chat-message-group--interactive {
cursor: pointer;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
}
.app-chat-message-group--interactive:hover,
.app-chat-message-group--interactive:focus-visible {
transform: translateY(-1px);
border-color: rgba(59, 130, 246, 0.34);
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.09);
outline: none;
}
.app-chat-message-group__header {
display: flex;
flex-direction: column;

View File

@@ -1,5 +1,14 @@
import type { ErrorLogItem } from '../errorLogApi';
export type ChatComposerAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type ChatPromptContextRef = {
key: 'prompt_parent_question';
promptTitle: string;
@@ -57,6 +66,7 @@ export type ChatMessagePart =
resolvedBy?: 'user' | 'timeout' | 'system' | null;
resolvedAt?: string | null;
resultText?: string | null;
attachments?: ChatComposerAttachment[];
options: Array<{
value: string;
label: string;
@@ -84,15 +94,6 @@ export type ChatMessage = {
parts?: ChatMessagePart[];
};
export type ChatComposerAttachment = {
id: string;
name: string;
path: string;
publicUrl: string;
size: number;
mimeType: string;
};
export type ChatCodexParticipant = {
id: string;
name: string;
@@ -146,6 +147,7 @@ export type ChatConversationSummary = {
roomScope: Record<string, unknown> | null;
notifyOffline: boolean;
hasUnreadResponse: boolean;
hasPendingAttention: boolean;
currentRequestId: string | null;
currentJobStatus: ChatJobStatus | null;
currentJobMessage: string | null;

View File

@@ -204,18 +204,6 @@ export function buildMainViewSearchOptions({
},
onSelectWindow,
})),
{
id: 'page:chat:rooms',
label: '시스템 채팅 / 시스템 채팅',
group: 'Page',
keywords: ['system chat', 'shared chat', 'room chat', '시스템 채팅', '공유채팅', '채팅방'],
onSelect: () => {
setActiveTopMenu('chat');
setSelectedChatMenu('rooms');
setFocusedComponentId(null);
},
onSelectWindow,
},
{
id: 'page:chat:live',
label: 'Codex Live / Codex Live',

View File

@@ -72,6 +72,20 @@ export type WebPushSubscriptionPayload = {
};
};
export type WebPushSubscriptionItem = {
id: number;
endpoint: string;
deviceId: string;
clientId: string;
userAgent: string;
appOrigin: string;
appDomain: string;
enabled: boolean;
lastRegisteredAt: string | null;
createdAt: string;
updatedAt: string;
};
export type ClientNotificationPayload = {
title: string;
body: string;
@@ -97,6 +111,14 @@ export type ClientNotificationSendResult = {
reason?: string;
sentCount: number;
failedCount: number;
matchedCount?: number;
matchedSubscriptions?: Array<{
endpoint: string;
deviceId: string;
clientId: string;
appOrigin: string;
appDomain: string;
}>;
};
};
@@ -539,6 +561,11 @@ export async function fetchWebPushConfig() {
}
}
export async function fetchWebPushSubscriptions() {
const response = await request<{ items: WebPushSubscriptionItem[] }>('/notifications/subscriptions/web');
return Array.isArray(response.items) ? response.items : [];
}
export async function fetchNotificationMessages(params?: {
status?: NotificationMessageListStatus;
limit?: number;

View File

@@ -3,8 +3,8 @@ import { ResourceManagementPage } from '../ResourceManagementPage';
import { SharedChatManagementPage } from '../SharedChatManagementPage';
import { ChatTypeManagementPage } from '../ChatTypeManagementPage';
import { MainChatPanel } from '../MainChatPanel';
import { SystemChatPanel } from '../SystemChatPanel';
import { ChatSourceChangesPage } from '../ChatSourceChangesPage';
import { SystemChatPage } from '../SystemChatPage';
import { useMainLayoutContext } from '../layout/MainLayoutContext';
export function ChatPage() {
@@ -20,10 +20,10 @@ export function ChatPage() {
<SharedChatManagementPage />
) : selectedChatMenu === 'resources' ? (
<ResourceManagementPage />
) : selectedChatMenu === 'system' ? (
<SystemChatPage />
) : selectedChatMenu === 'changes' ? (
<ChatSourceChangesPage />
) : selectedChatMenu === 'rooms' ? (
<SystemChatPanel lockOuterScrollOnMobile />
) : (
<MainChatPanel initialView={selectedChatMenu} lockOuterScrollOnMobile />
)}

View File

@@ -222,13 +222,33 @@
padding-right: 0;
}
.chat-share-page__search-modal {
top: 16px;
padding-bottom: 16px;
}
.chat-share-page__search-modal .ant-modal-content {
display: flex;
max-height: min(calc(100dvh - 48px), 720px);
flex-direction: column;
border-radius: 20px;
overflow: hidden;
}
.chat-share-page__search-modal .ant-modal-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
overscroll-behavior: contain;
}
.chat-share-page__search-modal-body {
display: grid;
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 12px;
}
@@ -240,9 +260,19 @@
.chat-share-page__search-results {
display: grid;
min-height: 0;
flex: 1 1 auto;
gap: 8px;
max-height: min(60dvh, 520px);
overflow-y: auto;
overflow-anchor: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.chat-share-page__search-results--apps {
grid-template-columns: repeat(auto-fill, minmax(136px, 1fr));
align-content: start;
gap: 10px;
}
.chat-share-page__search-result {
@@ -329,6 +359,99 @@
color: #94a3b8;
}
.chat-share-page__app-tile {
display: grid;
grid-template-rows: auto auto auto auto;
align-content: start;
gap: 6px;
min-height: 128px;
padding: 12px;
border: 0;
border-radius: 16px;
background: linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%);
box-shadow:
inset 0 0 0 1px rgba(191, 204, 220, 0.8),
0 6px 18px rgba(148, 163, 184, 0.1);
text-align: left;
cursor: pointer;
}
.chat-share-page__app-tile:disabled,
.chat-share-page__app-tile--disabled {
opacity: 0.62;
cursor: not-allowed;
}
.chat-share-page__app-tile-icon {
display: inline-flex;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
border-radius: 12px;
background: #dbeafe;
color: #1d4ed8;
font-size: 18px;
}
.chat-share-page__app-tile-title {
color: #0f172a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
word-break: keep-all;
overflow-wrap: anywhere;
}
.chat-share-page__app-tile-description {
color: #64748b;
font-size: 11px;
line-height: 1.4;
display: -webkit-box;
overflow: hidden;
min-height: calc(1.4em * 2);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
word-break: keep-all;
overflow-wrap: anywhere;
}
.chat-share-page__app-tile-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: auto;
}
.chat-share-page__app-tile-meta-label,
.chat-share-page__app-tile-usage {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: normal;
}
.chat-share-page__app-tile-meta-label {
background: #dbeafe;
color: #1d4ed8;
}
.chat-share-page__app-tile-usage {
background: #e2e8f0;
color: #334155;
}
.chat-share-page__search-empty {
padding: 20px 0 8px;
text-align: center;
@@ -568,7 +691,9 @@
}
.chat-share-page__settings-item-title {
display: block;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
color: #0f172a;
@@ -579,6 +704,32 @@
white-space: nowrap;
}
.chat-share-page__settings-version-indicator {
display: inline-flex;
width: 10px;
height: 10px;
flex: 0 0 auto;
border: 2px solid #ffffff;
border-radius: 999px;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.08);
}
.chat-share-page__settings-version-indicator--latest {
background: #2563eb;
}
.chat-share-page__settings-version-indicator--unknown {
background: #94a3b8;
}
.chat-share-page__settings-version-indicator--update-available {
background: #f59e0b;
}
.chat-share-page__settings-version-indicator--build-required {
background: #dc2626;
}
.chat-share-page__settings-item-description {
display: block;
color: #64748b;
@@ -675,6 +826,10 @@
background: rgba(219, 234, 254, 0.88);
}
.chat-share-page__message-headline--inline > .chat-share-page__message-time {
margin-right: 2px;
}
.chat-share-page__activity-summary-list {
display: grid;
gap: 6px;
@@ -838,7 +993,7 @@
}
.chat-share-page__token-usage-meter-fill--overall {
background: linear-gradient(90deg, #0f172a 0%, #334155 100%);
background: linear-gradient(90deg, #0f766e 0%, #14b8a6 100%);
z-index: 1;
}
@@ -971,51 +1126,6 @@
}
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__shell,
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
background:
linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.12), transparent 36%);
}
.chat-share-page__program-modal-content {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.chat-share-page__program-modal-content > * {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
}
.chat-share-page__program-app-shell {
display: flex;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.16), transparent 28%),
#020617;
}
.chat-share-page__program-app-shell > * {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
height: 100%;
}
.chat-share-page__program-app-shell--system-chat-room {
padding: 10px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96)),
radial-gradient(circle at top, rgba(148, 163, 184, 0.08), transparent 36%);
}
.chat-share-page__program-modal--system-chat-room .fullscreen-preview-modal__content {
padding-top: env(safe-area-inset-top, 0px);
}
@@ -1128,6 +1238,46 @@
bottom: calc(env(safe-area-inset-bottom, 0px) + 150px);
}
.chat-share-page__search-results--apps {
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
gap: 8px;
}
.chat-share-page__app-tile {
min-height: 136px;
padding: 10px;
border-radius: 14px;
}
.chat-share-page__app-tile-icon {
width: 32px;
height: 32px;
border-radius: 10px;
font-size: 16px;
}
.chat-share-page__app-tile-title {
font-size: 12px;
line-height: 1.3;
}
.chat-share-page__app-tile-description {
font-size: 10px;
min-height: calc(1.35em * 2);
line-height: 1.35;
}
.chat-share-page__app-tile-meta {
gap: 4px;
}
.chat-share-page__app-tile-meta-label,
.chat-share-page__app-tile-usage {
min-height: 20px;
padding-inline: 7px;
font-size: 10px;
}
.chat-share-page__search-result {
grid-template-columns: minmax(0, 1fr);
}
@@ -1676,11 +1826,19 @@
}
.chat-share-page__composer-topline-actions .app-chat-panel__composer-action-buttons .ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
width: 32px;
min-width: 32px;
height: 32px;
padding-inline: 0;
border-radius: 999px;
border-radius: 10px;
}
.chat-share-page__composer-panel .app-chat-panel__composer-utility-buttons .ant-btn {
min-width: 32px;
width: 32px;
height: 32px;
padding-inline: 0;
border-radius: 10px;
}
.chat-share-page__resource-card {

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ const PREVIEW_TARGET_SAMPLE_ID_QUERY_KEY = 'previewSampleId';
const PREVIEW_RUNTIME_CACHE_RESET_MARKER_KEY = 'work-app.preview-runtime.cache-reset.v1';
const PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY = '__previewRuntimeCacheReset';
const PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS = 2500;
const PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS = 1200;
const PREVIEW_RUNTIME_PRESERVED_QUERY_KEYS = [
PREVIEW_RUNTIME_QUERY_KEY,
PREVIEW_RUNTIME_PARENT_ORIGIN_KEY,
@@ -151,24 +150,23 @@ export async function ensurePreviewRuntimeFreshState() {
return;
}
if (resetSearchParam) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
window.history.replaceState(window.history.state, '', nextUrl.pathname + nextUrl.search + nextUrl.hash);
writePreviewRuntimeCacheResetMarker(currentLocationKey);
return;
}
const changed = await withTimeout(clearPreviewRuntimeServiceWorkersAndCaches(), PREVIEW_RUNTIME_CLEANUP_TIMEOUT_MS, false);
if (!changed) {
if (resetSearchParam) {
const nextUrl = new URL(window.location.href);
nextUrl.searchParams.delete(PREVIEW_RUNTIME_CACHE_RESET_PARAM_KEY);
window.history.replaceState(window.history.state, '', `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
}
writePreviewRuntimeCacheResetMarker(currentLocationKey);
return;
}
writePreviewRuntimeCacheResetMarker(currentLocationKey);
window.location.replace(buildPreviewRuntimeCacheResetUrl());
await new Promise<void>((resolve) => {
window.setTimeout(resolve, PREVIEW_RUNTIME_NAVIGATION_GRACE_TIMEOUT_MS);
});
}
export function getPreviewRuntimeParentOrigin() {

View File

@@ -20,12 +20,12 @@ export type PlanSectionKey =
| 'token-setting'
| 'shared-resource'
| 'server-command';
export type ChatSectionKey = 'live' | 'rooms' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
export type ChatSectionKey = 'live' | 'system' | 'changes' | 'resources' | 'errors' | 'manage' | 'manage-defaults' | 'manage-share';
export type PlaySectionKey = 'layout' | 'draw' | 'apps' | 'test' | 'cbt';
export type PlaySidebarKey = PlaySectionKey | `layout-record:${string}`;
export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live',
rooms: '시스템 채팅',
system: '채팅',
changes: 'Codex Live',
resources: '리소스 관리',
errors: '앱로그',
@@ -35,7 +35,7 @@ export const CHAT_SECTION_GROUP_LABELS: Record<ChatSectionKey, string> = {
};
export const CHAT_SECTION_LABELS: Record<ChatSectionKey, string> = {
live: 'Codex Live',
rooms: '시스템 채팅',
system: '시스템 채팅',
changes: '변경 이력',
resources: '리소스 관리',
errors: '에러 로그',
@@ -324,7 +324,7 @@ export function buildChatMenuItems(_hasAccess = true, unreadCount = 0): MenuProp
label: renderChatUnreadLabel('채팅', unreadCount),
children: [
{ key: 'live', label: 'Codex Live' },
{ key: 'rooms', label: '시스템 채팅' },
{ key: 'system', label: '시스템 채팅' },
{ key: 'changes', label: '변경 이력' },
{ key: 'resources', label: '리소스 관리' },
],

View File

@@ -1,311 +0,0 @@
.app-chat-panel--rooms-shared.ant-card {
border: 0;
border-radius: 0;
height: 100%;
background: transparent;
box-shadow: none;
}
.app-chat-panel--rooms-shared .ant-card-head {
display: block;
}
.app-chat-panel--rooms-shared .ant-card-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
height: 100%;
padding: 0;
border-radius: 14px;
background: linear-gradient(180deg, #edf3fb 0%, #e4edf8 100%);
box-shadow:
inset 0 0 0 1px rgba(196, 210, 226, 0.96),
0 8px 24px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__stack,
.app-chat-panel--rooms-shared .app-chat-panel__stack--chat {
min-height: 0;
height: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main,
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
background: transparent;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-main {
min-height: 0;
height: 100%;
overflow: hidden;
}
.app-chat-panel--rooms-shared .app-chat-panel__conversation-empty {
padding: 18px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header {
padding: 8px 10px;
border-bottom: 1px solid rgba(148, 163, 184, 0.32);
background: linear-gradient(180deg, rgba(237, 243, 251, 0.98) 0%, rgba(228, 237, 248, 0.94) 100%);
backdrop-filter: blur(10px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-copy,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-row {
min-width: 0;
flex-wrap: wrap;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-title {
font-size: 14px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
min-width: 0;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
display: inline-flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 8px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
font-size: 13px;
font-weight: 600;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
height: 36px;
padding-inline: 12px;
border-radius: 999px;
border: 0;
color: #334155;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(241, 245, 249, 0.9) 100%);
box-shadow:
inset 0 0 0 1px rgba(148, 163, 184, 0.26),
0 6px 16px rgba(148, 163, 184, 0.12);
transition:
background-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-inline-end: 0;
border-radius: 999px;
color: #2563eb;
background: rgba(219, 234, 254, 0.92);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.16);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon .anticon {
font-size: 13px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible {
color: #1d4ed8;
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(219, 234, 254, 0.94) 100%);
box-shadow:
inset 0 0 0 1px rgba(96, 165, 250, 0.32),
0 8px 18px rgba(96, 165, 250, 0.16);
transform: translateY(-1px);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:hover .ant-btn-icon,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:focus-visible .ant-btn-icon {
color: #1d4ed8;
background: rgba(191, 219, 254, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn:active {
transform: translateY(0);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
min-width: 34px;
height: 34px;
padding-inline: 0;
border-radius: 999px;
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--icon.ant-btn:focus-visible {
color: #1d4ed8;
background: rgba(219, 234, 254, 0.86);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:hover,
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--close.ant-btn:focus-visible {
color: #b91c1c;
background: rgba(254, 226, 226, 0.96);
}
.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
font-size: 12px;
font-weight: 700;
line-height: 1;
letter-spacing: -0.01em;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 5px;
padding: 5px 8px max(1px, env(safe-area-inset-bottom, 0px));
border: 0;
border-radius: 14px;
background: rgba(248, 250, 252, 0.94);
box-shadow:
inset 0 0 0 1px rgba(219, 226, 236, 0.82),
0 10px 28px rgba(148, 163, 184, 0.12);
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
width: 100%;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-type--readonly {
flex: 1 1 180px;
min-width: 0;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-actions--shared,
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-utility-buttons {
flex: 0 0 auto;
}
.app-chat-panel--rooms-shared .app-chat-panel__composer-topline--shared .app-chat-panel__composer-action-buttons .ant-btn {
width: 36px;
min-width: 36px;
height: 36px;
padding-inline: 0;
border-radius: 999px;
}
@media (max-width: 760px) {
.app-chat-panel--rooms.app-chat-panel--rooms-shared .ant-card-body {
border-radius: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-main,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-sub {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-header-actions {
width: auto;
gap: 4px;
justify-content: flex-end;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav {
width: auto;
gap: 0;
justify-content: flex-end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-window-actions {
width: auto;
justify-self: end;
flex-wrap: nowrap;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn {
width: 34px;
min-width: 34px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
gap: 4px;
padding-bottom: max(1px, env(safe-area-inset-bottom, 0px));
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn {
width: 30px;
min-width: 30px;
padding-inline: 0;
justify-content: center;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn .ant-btn-icon {
margin-inline-end: 0;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-nav .app-chat-panel__rooms-share-action.ant-btn > span:not(.ant-btn-icon) {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-action--tool.ant-btn .ant-btn-icon {
width: 24px;
height: 24px;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-tool-label {
display: none;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-summary,
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__rooms-share-current {
white-space: normal;
}
.app-chat-panel--rooms.app-chat-panel--rooms-shared .app-chat-panel__composer {
padding-bottom: max(12px, calc(env(safe-area-inset-bottom, 0px) + 8px));
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,14 @@ const PREVIEW_APP_ORIGIN = 'https://preview.sm-home.cloud';
let previewRuntimeTokenMemory = '';
function getRegisteredTokenStorage() {
if (typeof window === 'undefined') {
return null;
}
return window.sessionStorage;
}
function normalizeToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
@@ -44,11 +52,21 @@ function bootstrapRegisteredAccessToken() {
}
const tokenFromUrl = readPreviewRuntimeTokenFromUrl();
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
if (isPreviewRuntime()) {
if (!tokenFromUrl) {
previewRuntimeTokenMemory = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
const storedPreviewToken = readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY);
if (storedPreviewToken) {
previewRuntimeTokenMemory = storedPreviewToken;
return;
}
if (window.location.origin === PREVIEW_APP_ORIGIN) {
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
}
return;
}
@@ -60,12 +78,12 @@ function bootstrapRegisteredAccessToken() {
if (!tokenFromUrl) {
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
}
return;
}
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, tokenFromUrl);
clearPreviewRuntimeTokenFromUrl();
}
@@ -84,13 +102,19 @@ export function getRegisteredAccessToken() {
const previewToken =
readStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY) || previewRuntimeTokenMemory;
if (!previewToken && window.location.origin === PREVIEW_APP_ORIGIN) {
previewRuntimeTokenMemory = ALLOWED_REGISTRATION_TOKEN;
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
return ALLOWED_REGISTRATION_TOKEN;
}
return previewToken;
}
const storedToken = readStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY);
const storedToken = readStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY);
if (window.location.origin === PREVIEW_APP_ORIGIN && storedToken !== ALLOWED_REGISTRATION_TOKEN) {
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, ALLOWED_REGISTRATION_TOKEN);
return ALLOWED_REGISTRATION_TOKEN;
}
@@ -112,7 +136,7 @@ export function setRegisteredAccessToken(token: string | null | undefined) {
previewRuntimeTokenMemory = normalizedToken;
writeStorageToken(window.sessionStorage, PREVIEW_RUNTIME_TOKEN_STORAGE_KEY, normalizedToken);
} else {
writeStorageToken(window.localStorage, TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
writeStorageToken(getRegisteredTokenStorage(), TOKEN_ACCESS_STORAGE_KEY, normalizedToken);
}
window.dispatchEvent(new CustomEvent(TOKEN_ACCESS_SYNC_EVENT));

View File

@@ -0,0 +1,208 @@
import {
fetchWebPushConfig,
registerWebPushSubscription,
unregisterWebPushSubscription,
type WebPushSubscriptionPayload,
} from './notificationApi';
import { getOrCreateClientId } from './clientIdentity';
import { getSavedNotificationDeviceId } from './notificationIdentity';
const WEB_PUSH_METADATA_STORAGE_KEY = 'work-server.web-push.registration-meta.v1';
type WebPushRegistrationMetadata = {
deviceId: string;
clientId: string;
userAgent: string;
appOrigin: string;
appDomain: string;
enabled: boolean;
updatedAt: string;
};
function getCurrentAppOrigin() {
if (typeof window === 'undefined') {
return '';
}
return window.location.origin;
}
function getCurrentAppDomain() {
if (typeof window === 'undefined') {
return '';
}
return window.location.hostname;
}
function buildWebPushRegistrationMetadata(enabled: boolean): WebPushRegistrationMetadata {
return {
deviceId: getSavedNotificationDeviceId().trim(),
clientId: getOrCreateClientId().trim(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
appOrigin: getCurrentAppOrigin().trim(),
appDomain: getCurrentAppDomain().trim(),
enabled,
updatedAt: new Date().toISOString(),
};
}
function storeWebPushRegistrationMetadata(metadata: WebPushRegistrationMetadata | null) {
if (typeof window === 'undefined') {
return;
}
try {
if (metadata) {
window.localStorage.setItem(WEB_PUSH_METADATA_STORAGE_KEY, JSON.stringify(metadata));
return;
}
window.localStorage.removeItem(WEB_PUSH_METADATA_STORAGE_KEY);
} catch {
// Ignore storage failures in restricted runtimes.
}
}
async function syncWebPushRegistrationMetadataWithServiceWorker(
registration: ServiceWorkerRegistration | null,
metadata: WebPushRegistrationMetadata | null,
) {
if (!registration) {
return;
}
const target =
registration.active ??
registration.waiting ??
registration.installing ??
(typeof navigator !== 'undefined' && 'serviceWorker' in navigator ? navigator.serviceWorker.controller : null);
if (!target) {
return;
}
target.postMessage({
type: metadata ? 'WEB_PUSH_SYNC_METADATA' : 'WEB_PUSH_CLEAR_METADATA',
payload: metadata,
});
}
export 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, 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;
}
export 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 ?? '',
},
};
}
export async function ensureWebPushSubscriptionRegistered(
registration: ServiceWorkerRegistration,
options?: { deviceId?: string },
) {
const config = await fetchWebPushConfig();
if (!config.enabled || !config.publicKey) {
throw new Error('서버 Web Push 설정이 비어 있습니다.');
}
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
let subscription = await registration.pushManager.getSubscription();
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), options?.deviceId ?? getSavedNotificationDeviceId());
const metadata = buildWebPushRegistrationMetadata(true);
storeWebPushRegistrationMetadata(metadata);
await syncWebPushRegistrationMetadataWithServiceWorker(registration, metadata);
return subscription;
}
export async function syncExistingWebPushSubscriptionRegistration(
registration: ServiceWorkerRegistration,
options?: { deviceId?: string },
) {
const config = await fetchWebPushConfig();
if (!config.enabled || !config.publicKey || typeof Notification === 'undefined' || Notification.permission !== 'granted') {
return null;
}
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
return null;
}
await registerWebPushSubscription(serializePushSubscription(subscription), options?.deviceId ?? getSavedNotificationDeviceId());
const metadata = buildWebPushRegistrationMetadata(true);
storeWebPushRegistrationMetadata(metadata);
await syncWebPushRegistrationMetadataWithServiceWorker(registration, metadata);
return subscription;
}
export async function clearWebPushSubscriptionRegistration(registration: ServiceWorkerRegistration) {
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await unregisterWebPushSubscription(subscription.endpoint);
await subscription.unsubscribe();
}
storeWebPushRegistrationMetadata(null);
await syncWebPushRegistrationMetadataWithServiceWorker(registration, null);
}

View File

@@ -1,6 +1,14 @@
import { CopyOutlined, ReloadOutlined } from '@ant-design/icons';
import {
CheckCircleFilled,
ClockCircleFilled,
CloseCircleFilled,
CopyOutlined,
ExclamationCircleFilled,
ReloadOutlined,
SyncOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Col, Descriptions, Empty, Row, Space, Statistic, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { useTokenAccess } from '../../app/main/tokenAccess';
import { DataStatePanel } from '../../components/dataStatePanel';
import { copyText } from '../../app/main/mainChatPanel';
@@ -24,6 +32,13 @@ import './serverCommand.css';
const { Paragraph, Text, Title } = Typography;
type ServerCommandPageProps = {
sharedAccess?: {
shareToken: string;
allowedKeys?: ServerCommandKey[];
} | null;
};
type RestartErrorInfo = {
tone: 'error' | 'warning';
title: string;
@@ -38,6 +53,26 @@ type LastActionInfo = {
restartState: 'completed' | 'accepted';
};
type SharedStatusTone =
| 'online'
| 'degraded'
| 'offline'
| 'latest'
| 'update-available'
| 'build-required'
| 'unknown'
| 'info';
type SharedStatusCard = {
key: string;
label: string;
value: string;
tone: SharedStatusTone;
icon: ReactNode;
};
const SHARED_SERVER_KEY_ORDER: ServerCommandKey[] = ['work-server', 'test', 'rel', 'prod', 'command-runner'];
function formatDateTime(value: string | null | undefined) {
if (!value) {
return '-';
@@ -92,6 +127,185 @@ function resolveAvailabilityTag(item: ServerCommandItem) {
return <Tag color="error">OFFLINE</Tag>;
}
function resolveAvailabilityTone(item: ServerCommandItem) {
if (item.availability === 'online') {
return 'online';
}
if (item.availability === 'degraded') {
return 'degraded';
}
return 'offline';
}
function resolveVersionTone(item: ServerCommandItem | null) {
if (!item) {
return 'unknown';
}
if (item.buildRequired) {
return 'build-required';
}
if (item.updateAvailable) {
return 'update-available';
}
return 'latest';
}
function resolveVersionLabel(item: ServerCommandItem | null) {
const latestVersion = item?.latestVersion?.trim();
const runningVersion = item?.runningVersion?.trim();
return latestVersion || runningVersion || '확인 필요';
}
function resolveRestartButtonLabel(item: ServerCommandItem) {
return item.key === 'work-server' ? '예약' : '재기동';
}
function resolveReservationTone(status: ServerRestartReservation['status'] | 'idle'): SharedStatusTone {
switch (status) {
case 'ready':
case 'executing':
case 'recovering':
return 'online';
case 'waiting':
return 'degraded';
case 'failed':
return 'offline';
case 'completed':
return 'latest';
case 'cancelled':
return 'unknown';
default:
return 'info';
}
}
function resolveReservationStatusIcon(status: ServerRestartReservation['status'] | 'idle') {
switch (status) {
case 'ready':
return <ClockCircleFilled />;
case 'executing':
case 'recovering':
return <SyncOutlined spin />;
case 'waiting':
return <ClockCircleFilled />;
case 'completed':
return <CheckCircleFilled />;
case 'failed':
return <CloseCircleFilled />;
case 'cancelled':
return <ExclamationCircleFilled />;
default:
return <ClockCircleFilled />;
}
}
function resolveAvailabilityStatusIcon(tone: ReturnType<typeof resolveAvailabilityTone>) {
if (tone === 'online') {
return <CheckCircleFilled />;
}
if (tone === 'degraded') {
return <ExclamationCircleFilled />;
}
return <CloseCircleFilled />;
}
function resolveVersionStatusIcon(tone: ReturnType<typeof resolveVersionTone>) {
if (tone === 'latest') {
return <CheckCircleFilled />;
}
if (tone === 'update-available') {
return <ClockCircleFilled />;
}
if (tone === 'build-required') {
return <ExclamationCircleFilled />;
}
return <ExclamationCircleFilled />;
}
function buildSharedReservationCard(reservation: ServerRestartReservation | null): SharedStatusCard {
const isWorkTarget =
reservation && (reservation.target === 'work-server' || reservation.target === 'all');
const status = isWorkTarget ? reservation.status : 'idle';
switch (status) {
case 'waiting':
return {
key: 'reservation',
label: '재기동 예약',
value: '등록 대기',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'ready':
return {
key: 'reservation',
label: '재기동 예약',
value: '등록됨',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'executing':
return {
key: 'reservation',
label: '재기동 예약',
value: '실행 중',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'recovering':
return {
key: 'reservation',
label: '재기동 예약',
value: '자동 개선 중',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'completed':
return {
key: 'reservation',
label: '재기동 예약',
value: '최근 완료',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'failed':
return {
key: 'reservation',
label: '재기동 예약',
value: '실패',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
case 'cancelled':
return {
key: 'reservation',
label: '재기동 예약',
value: '취소됨',
tone: resolveReservationTone(status),
icon: resolveReservationStatusIcon(status),
};
default:
return {
key: 'reservation',
label: '재기동 예약',
value: '미등록',
tone: 'info',
icon: <ClockCircleFilled />,
};
}
}
function buildRestartErrorInfo(targetLabel: string, detail: string): RestartErrorInfo {
const missingScriptMatch = detail.match(/cannot open\s+([^\n:]+\.sh)\s*:\s*No such file/i);
@@ -155,6 +369,18 @@ function resolveReservationStatusTag(reservation: ServerRestartReservation) {
}
}
function getReservationTargetLabel(target: ServerRestartReservation['target']) {
if (target === 'test') {
return 'TEST 서버';
}
if (target === 'work-server') {
return 'WORK 서버';
}
return 'TEST / WORK 서버';
}
function formatReservationWorkItemTag(item: ServerRestartReservationWorkItem) {
if (item.kind === 'automation') {
if (item.status === 'running') {
@@ -202,32 +428,42 @@ function formatAutoFixStatusLabel(status: ServerRestartReservationAutoFix['statu
}
}
function buildReservationExecutionSteps(phase: ServerRestartReservationExecutionPhase) {
const activeIndex =
phase === 'commit-main-worktree'
? 0
: phase === 'restart-test'
? 1
: phase === 'restart-work-server'
? 2
: phase === 'verify-runtime'
? 3
: -1;
function buildReservationExecutionSteps(
phase: ServerRestartReservationExecutionPhase,
target: ServerRestartReservation['target'],
) {
const steps = [
{ label: 'main 작업트리 커밋', phaseKey: 'commit-main-worktree' },
{ label: 'TEST 재기동', phaseKey: 'restart-test' },
{ label: 'WORK 재기동', phaseKey: 'restart-work-server' },
{ label: '정상 기동 확인', phaseKey: 'verify-runtime' },
] as const;
return [
'main 작업트리 커밋',
'TEST 재기동',
'WORK 재기동',
'정상 기동 확인',
].map((label, index) => ({
label,
const filteredSteps = steps.filter((label) => {
if (target === 'work-server' && label.label === 'TEST 재기동') {
return false;
}
if (target === 'test' && label.label === 'WORK 재기동') {
return false;
}
return true;
});
const activeIndex = filteredSteps.findIndex((step) => step.phaseKey === phase);
return filteredSteps.map((step, index) => ({
label: step.label,
done: activeIndex > index,
active: activeIndex === index,
}));
}
export function ServerCommandPage() {
export function ServerCommandPage({ sharedAccess = null }: ServerCommandPageProps) {
const { hasAccess } = useTokenAccess();
const isSharedManageMode = Boolean(sharedAccess?.shareToken);
const allowedKeysSet = useMemo(() => new Set(sharedAccess?.allowedKeys ?? []), [sharedAccess?.allowedKeys]);
const [messageApi, contextHolder] = message.useMessage();
const [items, setItems] = useState<ServerCommandItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -250,8 +486,12 @@ export function ServerCommandPage() {
setErrorMessage(null);
try {
const nextItems = await fetchServerCommands();
setItems(nextItems);
const nextItems = await fetchServerCommands(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
setItems(
isSharedManageMode && allowedKeysSet.size > 0
? nextItems.filter((item) => allowedKeysSet.has(item.key))
: nextItems,
);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : '서버 정보를 불러오지 못했습니다.');
} finally {
@@ -261,7 +501,7 @@ export function ServerCommandPage() {
const loadReservation = async (options?: { silent?: boolean }) => {
try {
const nextReservation = await fetchServerRestartReservation();
const nextReservation = await fetchServerRestartReservation(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
setReservation(nextReservation);
return nextReservation;
} catch (error) {
@@ -273,7 +513,7 @@ export function ServerCommandPage() {
};
useEffect(() => {
if (!hasAccess) {
if (!hasAccess && !isSharedManageMode) {
setItems([]);
setLoading(false);
setErrorMessage(null);
@@ -285,10 +525,10 @@ export function ServerCommandPage() {
loadItems(),
loadReservation({ silent: true }),
]);
}, [hasAccess]);
}, [allowedKeysSet, hasAccess, isSharedManageMode, sharedAccess?.shareToken]);
useEffect(() => {
if (!hasAccess) {
if (!hasAccess && !isSharedManageMode) {
return;
}
@@ -310,7 +550,7 @@ export function ServerCommandPage() {
return () => {
window.clearInterval(timerId);
};
}, [hasAccess, reservation, restartingKey]);
}, [hasAccess, isSharedManageMode, reservation, restartingKey]);
const summary = useMemo(() => {
return items.reduce(
@@ -322,13 +562,40 @@ export function ServerCommandPage() {
{ total: 0, online: 0, degraded: 0, offline: 0 },
);
}, [items]);
const sharedReservationCard = useMemo(
() => (isSharedManageMode ? buildSharedReservationCard(reservation) : null),
[isSharedManageMode, reservation],
);
const sharedItems = useMemo(() => {
if (!isSharedManageMode) {
return [];
}
return [...items].sort((left, right) => {
const leftIndex = SHARED_SERVER_KEY_ORDER.indexOf(left.key);
const rightIndex = SHARED_SERVER_KEY_ORDER.indexOf(right.key);
const normalizedLeft = leftIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : leftIndex;
const normalizedRight = rightIndex === -1 ? SHARED_SERVER_KEY_ORDER.length : rightIndex;
return normalizedLeft - normalizedRight;
});
}, [isSharedManageMode, items]);
const handleRestart = async (key: ServerCommandKey) => {
setRestartingKey(key);
setRestartErrorInfo(null);
try {
const result = await restartServerCommand(key);
if (key === 'work-server') {
await scheduleServerRestartReservation({
target: 'work-server',
...(isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : {}),
});
await loadReservation({ silent: true });
messageApi.success('WORK-SERVER 무중단 재기동 예약을 등록했습니다.');
return;
}
const result = await restartServerCommand(key, isSharedManageMode ? { shareToken: sharedAccess?.shareToken } : undefined);
setItems((previous) => previous.map((item) => (item.key === result.item.key ? result.item : item)));
void loadReservation({ silent: true });
setLastActionByKey((previous) => ({
@@ -381,10 +648,18 @@ export function ServerCommandPage() {
setSchedulingReservation(true);
try {
await scheduleServerRestartReservation();
await scheduleServerRestartReservation(
isSharedManageMode
? { target: 'work-server', shareToken: sharedAccess?.shareToken }
: undefined,
);
setRestartErrorInfo(null);
await loadReservation({ silent: true });
messageApi.success('전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.');
messageApi.success(
isSharedManageMode
? 'WORK 서버 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.'
: '전체 재기동 예약을 등록했습니다. 진행 중 작업이 끝나면 자동으로 재기동합니다.',
);
} catch (error) {
const detail = error instanceof Error ? error.message : '재기동 예약 등록에 실패했습니다.';
setRestartErrorInfo(buildRestartErrorInfo('전체 서버', detail));
@@ -393,11 +668,11 @@ export function ServerCommandPage() {
}
};
if (!hasAccess) {
if (!hasAccess && !isSharedManageMode) {
return (
<Card className="server-command-page__card" bordered={false}>
<Paragraph className="app-main-copy">
Server Command .
Server Command .
</Paragraph>
</Card>
);
@@ -406,28 +681,64 @@ export function ServerCommandPage() {
return (
<Space direction="vertical" size={16} className="server-command-page">
{contextHolder}
<Card className="server-command-page__card" bordered={false}>
<Card
className={`server-command-page__card${isSharedManageMode ? ' server-command-page__card--shared' : ''}`}
bordered={false}
>
<Space direction="vertical" size={8}>
<Title level={4} className="server-command-page__title">
Server Command
{isSharedManageMode ? '서버관리' : 'Server Command'}
</Title>
<Paragraph className="server-command-page__copy">
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER .
</Paragraph>
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
<Col xs={12} md={6}>
<Statistic title="전체" value={summary.total} />
</Col>
<Col xs={12} md={6}>
<Statistic title="ONLINE" value={summary.online} valueStyle={{ color: '#389e0d' }} />
</Col>
<Col xs={12} md={6}>
<Statistic title="DEGRADED" value={summary.degraded} valueStyle={{ color: '#d48806' }} />
</Col>
<Col xs={12} md={6}>
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
</Col>
</Row>
{isSharedManageMode ? (
<div className="server-command-page__shared-toolbar">
<Space wrap size={[8, 8]} className="server-command-page__shared-toolbar-chips">
<span className="server-command-page__toolbar-chip">
<span className="server-command-page__toolbar-chip-value">{summary.total}</span>
<span className="server-command-page__toolbar-chip-label">ALL</span>
</span>
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--online">
<CheckCircleFilled />
<span className="server-command-page__toolbar-chip-value">{summary.online}</span>
</span>
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--degraded">
<ExclamationCircleFilled />
<span className="server-command-page__toolbar-chip-value">{summary.degraded}</span>
</span>
<span className="server-command-page__toolbar-chip server-command-page__toolbar-chip--offline">
<CloseCircleFilled />
<span className="server-command-page__toolbar-chip-value">{summary.offline}</span>
</span>
{sharedReservationCard ? (
<span
className={`server-command-page__toolbar-chip server-command-page__toolbar-chip--${sharedReservationCard.tone}`}
>
{sharedReservationCard.icon}
<span className="server-command-page__toolbar-chip-value">{sharedReservationCard.value}</span>
</span>
) : null}
</Space>
</div>
) : (
<>
<Paragraph className="server-command-page__copy">
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER .
</Paragraph>
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
<Col xs={12} md={6}>
<Statistic title="전체" value={summary.total} />
</Col>
<Col xs={12} md={6}>
<Statistic title="ONLINE" value={summary.online} valueStyle={{ color: '#389e0d' }} />
</Col>
<Col xs={12} md={6}>
<Statistic title="DEGRADED" value={summary.degraded} valueStyle={{ color: '#d48806' }} />
</Col>
<Col xs={12} md={6}>
<Statistic title="OFFLINE" value={summary.offline} valueStyle={{ color: '#cf1322' }} />
</Col>
</Row>
</>
)}
<Space wrap>
<Button
icon={<ReloadOutlined />}
@@ -490,7 +801,7 @@ export function ServerCommandPage() {
/>
) : null}
{reservation && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
{reservation && !isSharedManageMode && (reservation.enabled || reservation.status !== 'idle' || reservation.autoFix.enabled) ? (
<Card className="server-command-page__card server-command-page__reservation-card" bordered={false}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={8} wrap>
@@ -503,37 +814,44 @@ export function ServerCommandPage() {
<Paragraph className="server-command-page__summary">
{reservation.waitingReason?.trim()
|| (reservation.status === 'completed'
? '예약된 TEST / WORK 서버 재기동이 완료되었습니다.'
? `예약된 ${getReservationTargetLabel(reservation.target)} 재기동이 완료되었습니다.`
: '예약 상태를 확인했습니다.')}
</Paragraph>
<Descriptions
size="small"
column={1}
className="server-command-page__meta"
items={[
{
key: 'requested-at',
label: '요청시각',
children: formatDateTime(reservation.requestedAt),
},
{
key: 'auto-execute-at',
label: '자동실행',
children: formatDateTime(reservation.autoExecuteAt),
},
{
key: 'updated-at',
label: '마지막 갱신',
children: formatDateTime(reservation.updatedAt),
},
]}
/>
{(
<Descriptions
size="small"
column={1}
className="server-command-page__meta"
items={[
{
key: 'target',
label: '대상',
children: getReservationTargetLabel(reservation.target),
},
{
key: 'requested-at',
label: '요청시각',
children: formatDateTime(reservation.requestedAt),
},
{
key: 'auto-execute-at',
label: '자동실행',
children: formatDateTime(reservation.autoExecuteAt),
},
{
key: 'updated-at',
label: '마지막 갱신',
children: formatDateTime(reservation.updatedAt),
},
]}
/>
)}
{reservation.status === 'executing' ? (
<Space direction="vertical" size={8} className="server-command-page__work-list">
<Text strong> </Text>
{buildReservationExecutionSteps(reservation.executionPhase).map((step) => (
{buildReservationExecutionSteps(reservation.executionPhase, reservation.target).map((step) => (
<div key={step.label} className="server-command-page__work-item">
<Space size={8} wrap>
<Tag color={step.active ? 'processing' : step.done ? 'success' : 'default'}>
@@ -626,6 +944,96 @@ export function ServerCommandPage() {
<Card className="server-command-page__card" bordered={false}>
<Empty description="표시할 서버가 없습니다." />
</Card>
) : isSharedManageMode ? (
<div className="server-command-page__shared-server-grid">
{sharedItems.map((item) => {
const availabilityTone = resolveAvailabilityTone(item);
const versionTone = resolveVersionTone(item);
const lastAction = lastActionByKey[item.key];
return (
<Card
key={item.key}
className={`server-command-page__server-card server-command-page__server-card--shared server-command-page__server-card--shared-compact server-command-page__server-card--${availabilityTone}`}
bordered={false}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<div className="server-command-page__shared-server-head">
<Space size={8} wrap className="server-command-page__title-row">
<span
className={`server-command-page__status-dot server-command-page__status-dot--${availabilityTone}`}
aria-hidden="true"
/>
<Title level={5} className="server-command-page__server-title">
{item.label}
</Title>
</Space>
<Button
className="server-command-page__restart-button server-command-page__restart-button--shared-compact"
type={item.key === 'work-server' ? 'primary' : 'default'}
icon={<ReloadOutlined />}
loading={restartingKey === item.key}
onClick={() => {
void handleRestart(item.key);
}}
>
{resolveRestartButtonLabel(item)}
</Button>
</div>
<div className="server-command-page__shared-server-meta">
<span
className={`server-command-page__shared-pill server-command-page__shared-pill--${availabilityTone}`}
title={`상태 ${item.availability}`}
>
{resolveAvailabilityStatusIcon(availabilityTone)}
</span>
<span
className={`server-command-page__shared-pill server-command-page__shared-pill--${versionTone}`}
title={`버전 ${resolveVersionLabel(item)}`}
>
{resolveVersionStatusIcon(versionTone)}
</span>
{item.key === 'work-server' && sharedReservationCard ? (
<span
className={`server-command-page__shared-pill server-command-page__shared-pill--${sharedReservationCard.tone}`}
title={`예약 ${sharedReservationCard.value}`}
>
{sharedReservationCard.icon}
</span>
) : null}
{item.composeStatus ? <span className="server-command-page__shared-pill">{item.composeStatus}</span> : null}
</div>
<div className="server-command-page__shared-server-stats">
<div className="server-command-page__shared-stat">
<Text type="secondary">MS</Text>
<Text strong>{formatResponseTime(item.responseTimeMs)}</Text>
</div>
<div className="server-command-page__shared-stat">
<Text type="secondary">HTTP</Text>
<Text strong>{formatStatusCode(item.httpStatus)}</Text>
</div>
<div className="server-command-page__shared-stat">
<Text type="secondary"></Text>
<Text strong>{formatDateTime(item.checkedAt)}</Text>
</div>
</div>
<Text type="secondary" className="server-command-page__shared-server-summary">
{resolveHostLabel(item.publicUrl ?? item.checkUrl)}
</Text>
{lastAction?.executedAt ? (
<Text type="secondary" className="server-command-page__shared-server-footer">
{lastAction.restartState === 'accepted' ? '요청' : '완료'} {formatDateTime(lastAction.executedAt)}
</Text>
) : null}
</Space>
</Card>
);
})}
</div>
) : (
<div className="server-command-page__grid">
{items.map((item) => (
@@ -652,7 +1060,7 @@ export function ServerCommandPage() {
void handleRestart(item.key);
}}
>
{item.label}
{item.key === 'work-server' ? `${item.label} 무중단 예약` : `${item.label} 재기동`}
</Button>
}
>

View File

@@ -67,7 +67,11 @@ const SERVER_COMMAND_API_FALLBACK_BASE_URL =
? resolveServerCommandFallbackBaseUrl()
: null;
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit) {
type ServerCommandRequestOptions = {
shareToken?: string | null;
};
async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit, options?: ServerCommandRequestOptions) {
const headers = appendClientIdHeader(init?.headers);
const hasBody = init?.body !== undefined && init?.body !== null;
const method = init?.method?.toUpperCase() ?? 'GET';
@@ -100,14 +104,20 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
}
const token = getRegisteredAccessToken();
if (!isAllowedRegistrationToken(token)) {
throw new ServerCommandApiError('권한 토큰 등록 후에만 Work Server API를 호출할 수 있습니다.', 403);
const shareToken = options?.shareToken?.trim() ?? '';
if (!shareToken && !isAllowedRegistrationToken(token)) {
throw new ServerCommandApiError('권한 토큰 등록 또는 허용된 공유채팅 링크에서만 Work Server API를 호출할 수 있습니다.', 403);
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
let response: Response;
try {
@@ -182,9 +192,9 @@ async function requestOnce<T>(baseUrl: string, path: string, init?: RequestInit)
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
async function request<T>(path: string, init?: RequestInit, options?: ServerCommandRequestOptions): Promise<T> {
try {
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init);
return await requestOnce<T>(SERVER_COMMAND_API_BASE_URL, path, init, options);
} catch (error) {
const shouldRetryWithFallback =
SERVER_COMMAND_API_FALLBACK_BASE_URL &&
@@ -197,7 +207,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
throw error;
}
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init);
return requestOnce<T>(SERVER_COMMAND_API_FALLBACK_BASE_URL, path, init, options);
}
}
@@ -512,56 +522,59 @@ function extractServerRestartReservation(response: unknown): ServerRestartReserv
};
}
export async function fetchServerCommands() {
const response = await request<unknown>('/server-commands');
export async function fetchServerCommands(options?: ServerCommandRequestOptions) {
const response = await request<unknown>('/server-commands', undefined, options);
return extractServerCommandItems(response);
}
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal }) {
export async function restartServerCommand(key: ServerCommandKey, options?: { signal?: AbortSignal; shareToken?: string | null }) {
const response = await request<unknown>(`/server-commands/${key}/actions/restart`, {
method: 'POST',
body: JSON.stringify({}),
signal: options?.signal,
timeoutMs: key === 'test' || key === 'rel' ? 30000 : 12000,
});
}, { shareToken: options?.shareToken });
return extractServerCommandActionResult(response);
}
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal }) {
export async function fetchServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
const response = await request<unknown>('/server-commands/restart-reservation', {
signal: options?.signal,
});
}, { shareToken: options?.shareToken });
return extractServerRestartReservation(response);
}
export async function scheduleServerRestartReservation(options?: {
signal?: AbortSignal;
autoExecuteDelaySeconds?: number;
target?: 'all' | 'test' | 'work-server';
shareToken?: string | null;
}) {
const response = await request<unknown>('/server-commands/restart-reservation', {
method: 'PUT',
body: JSON.stringify({
target: options?.target,
autoExecuteDelaySeconds: options?.autoExecuteDelaySeconds,
}),
signal: options?.signal,
});
}, { shareToken: options?.shareToken });
return extractServerRestartReservation(response);
}
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal }) {
export async function cancelServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
const response = await request<unknown>('/server-commands/restart-reservation', {
method: 'DELETE',
signal: options?.signal,
});
}, { shareToken: options?.shareToken });
return extractServerRestartReservation(response);
}
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal }) {
export async function confirmServerRestartReservation(options?: { signal?: AbortSignal; shareToken?: string | null }) {
const response = await request<unknown>('/server-commands/restart-reservation/confirm', {
method: 'POST',
body: JSON.stringify({}),
signal: options?.signal,
});
}, { shareToken: options?.shareToken });
return extractServerRestartReservation(response);
}

View File

@@ -48,10 +48,53 @@
background: linear-gradient(180deg, #f8fbff 0%, #ffffff 100%);
}
.server-command-page__card--shared {
background:
linear-gradient(135deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 41, 59, 0.94) 52%, rgba(37, 99, 235, 0.88) 100%);
color: #e2e8f0;
}
.server-command-page__card--shared .server-command-page__title.ant-typography,
.server-command-page__card--shared .server-command-page__copy.ant-typography {
color: inherit;
}
.server-command-page__server-card {
min-width: 0;
}
.server-command-page__server-card--shared {
border: 1px solid rgba(148, 163, 184, 0.2);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
box-shadow:
0 20px 44px rgba(15, 23, 42, 0.12),
inset 0 0 0 1px rgba(255, 255, 255, 0.75);
}
.server-command-page__server-card--shared-compact {
border-radius: 20px;
box-shadow:
0 12px 28px rgba(15, 23, 42, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.72);
}
.server-command-page__server-card--online {
border-color: rgba(147, 197, 253, 0.72);
}
.server-command-page__server-card--degraded {
border-color: rgba(253, 230, 138, 0.92);
background:
linear-gradient(180deg, rgba(255, 251, 235, 0.95) 0%, rgba(255, 255, 255, 0.98) 100%);
}
.server-command-page__server-card--offline {
border-color: rgba(254, 202, 202, 0.9);
background:
linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 255, 255, 0.98) 100%);
}
.server-command-page__title.ant-typography,
.server-command-page__server-title.ant-typography {
margin-bottom: 0;
@@ -71,10 +114,177 @@
margin-bottom: 0;
}
.server-command-page__shared-toolbar {
display: flex;
justify-content: space-between;
gap: 10px;
}
.server-command-page__shared-toolbar-chips {
min-width: 0;
}
.server-command-page__toolbar-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #e2e8f0;
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.server-command-page__toolbar-chip-label {
color: rgba(226, 232, 240, 0.72);
letter-spacing: 0.06em;
}
.server-command-page__toolbar-chip-value {
min-width: 0;
}
.server-command-page__toolbar-chip--online,
.server-command-page__toolbar-chip--latest {
background: rgba(37, 99, 235, 0.2);
color: #bfdbfe;
}
.server-command-page__toolbar-chip--degraded,
.server-command-page__toolbar-chip--update-available {
background: rgba(245, 158, 11, 0.2);
color: #fde68a;
}
.server-command-page__toolbar-chip--offline,
.server-command-page__toolbar-chip--build-required {
background: rgba(220, 38, 38, 0.2);
color: #fecaca;
}
.server-command-page__toolbar-chip--unknown,
.server-command-page__toolbar-chip--info {
background: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
}
.server-command-page__status-dot {
width: 10px;
height: 10px;
flex: 0 0 auto;
border-radius: 999px;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14);
}
.server-command-page__status-dot--online,
.server-command-page__status-dot--latest {
background: #2563eb;
}
.server-command-page__status-dot--degraded,
.server-command-page__status-dot--update-available {
background: #f59e0b;
}
.server-command-page__status-dot--offline,
.server-command-page__status-dot--build-required {
background: #dc2626;
}
.server-command-page__status-dot--unknown {
background: #94a3b8;
}
.server-command-page__summary-grid {
width: 100%;
}
.server-command-page__shared-server-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
gap: 12px;
}
.server-command-page__shared-server-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.server-command-page__shared-server-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.server-command-page__shared-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
min-height: 32px;
padding: 0 9px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.7);
color: #334155;
font-size: 14px;
font-weight: 600;
line-height: 1;
}
.server-command-page__shared-pill--online,
.server-command-page__shared-pill--latest {
background: rgba(219, 234, 254, 0.95);
color: #1d4ed8;
}
.server-command-page__shared-pill--degraded,
.server-command-page__shared-pill--update-available {
background: rgba(254, 240, 138, 0.55);
color: #b45309;
}
.server-command-page__shared-pill--offline,
.server-command-page__shared-pill--build-required {
background: rgba(254, 226, 226, 0.92);
color: #b91c1c;
}
.server-command-page__shared-server-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.server-command-page__shared-stat {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.92);
}
.server-command-page__shared-server-summary.ant-typography,
.server-command-page__shared-server-footer.ant-typography {
margin-bottom: 0;
}
.server-command-page__shared-server-summary.ant-typography {
font-size: 12px;
}
.server-command-page__restart-button--shared-compact {
min-width: 88px;
height: 36px;
border-radius: 12px;
font-weight: 700;
}
.server-command-page__summary-grid .ant-statistic {
padding: 14px 16px;
border-radius: 18px;
@@ -131,7 +341,36 @@
width: 104px;
}
.server-command-page__restart-button--shared {
min-width: 180px;
height: 44px;
border-radius: 14px;
font-weight: 700;
}
@media (max-width: 768px) {
.server-command-page__shared-server-head {
align-items: stretch;
flex-direction: column;
}
.server-command-page__shared-server-stats {
grid-template-columns: 1fr;
}
.server-command-page__shared-toolbar {
flex-direction: column;
align-items: stretch;
}
.server-command-page__restart-button--shared {
width: 100%;
}
.server-command-page__restart-button--shared-compact {
width: 100%;
}
.server-command-page__server-card .ant-card-head {
padding-inline: 16px;
}

170
src/sw.js
View File

@@ -36,8 +36,103 @@ self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting();
}
if (event.data?.type === 'WEB_PUSH_SYNC_METADATA') {
event.waitUntil(writeWebPushRegistrationMetadata(event.data?.payload));
}
if (event.data?.type === 'WEB_PUSH_CLEAR_METADATA') {
event.waitUntil(clearWebPushRegistrationMetadata());
}
});
const WEB_PUSH_METADATA_CACHE_NAME = 'ai-code-app-web-push-meta-v1';
const WEB_PUSH_METADATA_REQUEST_PATH = '/__web-push-registration-meta__';
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = 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 normalizeWebPushRegistrationMetadata(value) {
if (!value || typeof value !== 'object') {
return null;
}
const deviceId = normalizeNotificationValue(value.deviceId);
const clientId = normalizeNotificationValue(value.clientId);
const appOrigin = normalizeNotificationValue(value.appOrigin);
const appDomain = normalizeNotificationValue(value.appDomain);
const userAgent = normalizeNotificationValue(value.userAgent);
const enabled = value.enabled !== false;
if (!enabled || (!deviceId && !clientId)) {
return null;
}
return {
deviceId,
clientId,
appOrigin,
appDomain,
userAgent,
enabled: true,
updatedAt: normalizeNotificationValue(value.updatedAt),
};
}
async function writeWebPushRegistrationMetadata(value) {
const metadata = normalizeWebPushRegistrationMetadata(value);
if (!metadata) {
await clearWebPushRegistrationMetadata();
return;
}
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
const request = new Request(WEB_PUSH_METADATA_REQUEST_PATH);
await cache.put(
request,
new Response(JSON.stringify(metadata), {
headers: {
'content-type': 'application/json',
},
}),
);
}
async function readWebPushRegistrationMetadata() {
try {
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
const response = await cache.match(new Request(WEB_PUSH_METADATA_REQUEST_PATH));
if (!response) {
return null;
}
return normalizeWebPushRegistrationMetadata(await response.json());
} catch {
return null;
}
}
async function clearWebPushRegistrationMetadata() {
try {
const cache = await caches.open(WEB_PUSH_METADATA_CACHE_NAME);
await cache.delete(new Request(WEB_PUSH_METADATA_REQUEST_PATH));
} catch {
// ignore cache cleanup failures
}
}
function normalizeNotificationValue(value) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -184,6 +279,77 @@ function shouldCloseExistingNotification(notification, payload) {
);
}
async function recoverWebPushSubscription() {
const metadata = await readWebPushRegistrationMetadata();
if (!metadata) {
return;
}
const configResponse = await fetch('/api/notifications/webpush/config', {
headers: {
Accept: 'application/json',
},
});
if (!configResponse.ok) {
throw new Error(`web-push-config:${configResponse.status}`);
}
const config = await configResponse.json();
if (!config?.enabled || !config?.publicKey) {
return;
}
let subscription = null;
try {
subscription = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(String(config.publicKey)),
});
} catch {
subscription = await self.registration.pushManager.getSubscription();
}
if (!subscription) {
return;
}
const subscriptionJson = subscription.toJSON();
const payload = {
subscription: {
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys: {
p256dh: subscriptionJson.keys?.p256dh ?? '',
auth: subscriptionJson.keys?.auth ?? '',
},
},
deviceId: metadata.deviceId || undefined,
clientId: metadata.clientId || undefined,
userAgent: metadata.userAgent || '',
appOrigin: metadata.appOrigin || self.location.origin,
appDomain: metadata.appDomain || self.location.hostname,
enabled: true,
};
await fetch('/api/notifications/subscriptions/web', {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
await writeWebPushRegistrationMetadata({
...metadata,
updatedAt: new Date().toISOString(),
});
}
self.addEventListener('push', (event) => {
if (!event.data) {
return;
@@ -228,6 +394,10 @@ self.addEventListener('push', (event) => {
);
});
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(recoverWebPushSubscription());
});
self.addEventListener('notificationclick', (event) => {
const notificationData = event.notification.data ?? {};
const notificationSessionId = typeof notificationData.sessionId === 'string' ? notificationData.sessionId.trim() : '';

View File

@@ -79,6 +79,12 @@
rgba(255, 255, 255, 0.06);
}
.apps-library__card--baseball-ticket-bay {
background:
linear-gradient(180deg, rgba(255, 128, 82, 0.3), rgba(78, 132, 255, 0.14)),
rgba(255, 255, 255, 0.06);
}
.apps-library__card--beat {
background:
linear-gradient(180deg, rgba(127, 114, 255, 0.18), rgba(255, 255, 255, 0.04)),

View File

@@ -2,6 +2,7 @@ import { Tag } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import './AppsLibraryView.css';
import { BaseballTicketBayPlayAppView } from '../baseball-ticket-bay/BaseballTicketBayPlayAppView';
import { EReaderAppView } from '../e-reader/EReaderAppView';
import { PhotoPrismAppView } from '../photoprism/PhotoPrismAppView';
import { PhotoPuzzleAppView } from '../photo-puzzle/PhotoPuzzleAppView';
@@ -72,6 +73,10 @@ export function AppsLibraryView() {
return <PhotoPrismAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'baseball-ticket-bay') {
return <BaseballTicketBayPlayAppView onBack={closeApp} launchContext={launchContext} />;
}
if (activeAppEntry?.id === 'e-reader') {
return <EReaderAppView onBack={closeApp} launchContext={launchContext} />;
}
@@ -110,6 +115,8 @@ export function AppsLibraryView() {
data-testid={
entry.id === 'e-reader'
? 'apps-library-open-e-reader'
: entry.id === 'baseball-ticket-bay'
? 'apps-library-open-baseball-ticket-bay'
: entry.id === 'photoprism'
? 'apps-library-open-photoprism'
: entry.id === 'photo-puzzle'

View File

@@ -1,5 +1,6 @@
import {
AppstoreOutlined,
BellOutlined,
BookOutlined,
FileImageOutlined,
FireOutlined,
@@ -21,12 +22,25 @@ export type PlayAppEntry = {
statusLabel: string;
isReady: boolean;
icon: ReactNode;
usagePriority?: number;
supportedEnvironments?: PlayAppEnvironment[];
searchKeywords?: string[];
searchDescription?: string;
};
export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
{
id: 'baseball-ticket-bay',
name: '야구-티켓베이',
accentClassName: 'apps-library__card--baseball-ticket-bay',
statusLabel: '알림',
isReady: true,
icon: <BellOutlined />,
usagePriority: 100,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['야구', '티켓베이', 'ticketbay', '야구 티켓', '웹푸시', '알림'],
searchDescription: '팀, 구역, 통로, 가격 조건으로 야구 티켓 알림 조건을 저장하고 테스트 푸시를 보냅니다.',
},
{
id: 'e-reader',
name: 'E-Reader',
@@ -34,6 +48,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '읽기',
isReady: true,
icon: <BookOutlined />,
usagePriority: 80,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['e-reader', 'reader', 'ebook', 'article', 'web article', '기사', '전자책', '리더'],
searchDescription: 'Apps 보관함에서 인터넷 기사와 웹 콘텐츠를 전자책처럼 넘겨 읽습니다.',
@@ -45,6 +60,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '연결',
isReady: true,
icon: <FileImageOutlined />,
usagePriority: 70,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photoprism', 'photo', 'album', 'gallery', '사진 앨범'],
searchDescription: 'Apps 보관함에서 PhotoPrism 앨범과 사진을 단독 앱 형태로 엽니다.',
@@ -56,6 +72,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '실행',
isReady: true,
icon: <PictureOutlined />,
usagePriority: 60,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['photo puzzle', '퍼즐', '사진', '슬라이드 퍼즐'],
searchDescription: 'Apps 보관함에서 사진 퍼즐 게임을 바로 실행합니다.',
@@ -67,6 +84,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '신규',
isReady: true,
icon: <ThunderboltOutlined />,
usagePriority: 50,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['the quest', 'rpg', 'mobile rpg', 'phaser', 'quest', 'rpg game', '모바일 rpg'],
searchDescription: 'Apps 보관함에서 한국형 모바일 RPG 데모 The Quest를 실행합니다.',
@@ -78,6 +96,7 @@ export const APP_LIBRARY_ENTRIES: PlayAppEntry[] = [
statusLabel: '실행',
isReady: true,
icon: <FundProjectionScreenOutlined />,
usagePriority: 40,
supportedEnvironments: ['preview', 'test'],
searchKeywords: ['테트리스', 'block', 'arcade', '블록 게임'],
searchDescription: 'Apps 보관함에서 테트리스 게임을 바로 실행합니다.',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,253 @@
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 15_000;
const BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS = 180_000;
export type BaseballTicketBayTimeWindow = {
id: string;
start: string;
end: string;
};
export type BaseballTicketBayAlertItem = {
id: string;
title: string;
eventDate: string;
team: string;
zone: string;
aisleSide: string;
seatDirections: string[];
maxPrice: number | null;
seatCount: number;
batchIntervalMinutes: number;
sameProductAlertEnabled: boolean;
sameProductNotifyOnce: boolean;
active: boolean;
timeWindows: BaseballTicketBayTimeWindow[];
createdAt: string;
updatedAt: string;
lastRunAt: string | null;
lastMatchAt: string | null;
};
export type BaseballTicketBayMatchResult = {
productId: number;
displayNumber: string;
saleUrl: string;
title: string;
eventDateTime: string;
categoryName: string;
teamName: string;
area: string;
rowLabel: string;
row: string;
opponentOrFloor: string;
grade: string;
addInfo: string;
seatCount: number | null;
together: boolean;
price: number | null;
totalPrice: number | null;
transactionType: string;
sellerPost: string;
productRemarks: string[];
seatRemarks: string[];
seatMapImageUrl: string | null;
photoUrls: string[];
sellerPhotoCount: number;
createdAt: string;
safeTrade: boolean;
pinTrade: boolean;
deliveryTrade: boolean;
fieldTrade: boolean;
etcTrade: boolean;
};
export type BaseballTicketBayRunPayload = {
keyword: string;
scannedCategoryCount: number;
scannedItemTotalCount?: number;
scannedCategories: Array<{
categoryId: number;
categoryName: string;
pageCount: number;
scannedItemCount: number;
}>;
rejectionSummary?: Array<{
reason: string;
label: string;
count: number;
samples: string[];
}>;
results: BaseballTicketBayMatchResult[];
};
export type BaseballTicketBayAlertLogItem = {
id: string;
alertId: string | null;
alertTitle: string;
action: 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
status: 'info' | 'success' | 'warning' | 'error';
message: string;
detail: string;
createdAt: string;
payload?: BaseballTicketBayRunPayload | null;
};
type BaseballTicketBayAlertMutation = Omit<
BaseballTicketBayAlertItem,
'id' | 'createdAt' | 'updatedAt' | 'lastRunAt' | 'lastMatchAt'
>;
type BaseballTicketBayAlertsResponse = {
ok: boolean;
items: BaseballTicketBayAlertItem[];
};
type BaseballTicketBayLogsResponse = {
ok: boolean;
items: BaseballTicketBayAlertLogItem[];
};
type BaseballTicketBayAlertResponse = {
ok: boolean;
item: BaseballTicketBayAlertItem;
};
type BaseballTicketBayRunResponse = {
ok: boolean;
alert: BaseballTicketBayAlertItem;
matches: BaseballTicketBayMatchResult[];
notifiedMatches: BaseballTicketBayMatchResult[];
log: BaseballTicketBayAlertLogItem;
};
function resolveApiBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const API_BASE_URL = resolveApiBaseUrl();
function buildHeaders(headersInit?: HeadersInit, hasJsonBody = false) {
const headers = appendClientIdHeader(headersInit);
const token = getRegisteredAccessToken();
if (hasJsonBody && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
return headers;
}
async function request<T>(path: string, init?: (RequestInit & { timeoutMs?: number })) {
const controller = new AbortController();
const timeoutMs =
typeof init?.timeoutMs === 'number' && Number.isFinite(init.timeoutMs)
? Math.max(1_000, init.timeoutMs)
: WORK_SERVER_TIMEOUT_MS;
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
const hasBody = init?.body != null;
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: buildHeaders(init?.headers, hasBody),
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const parsed = JSON.parse(text) as { message?: string };
throw new Error(parsed.message || '야구 티켓베이 요청에 실패했습니다.');
} catch (error) {
if (error instanceof Error && error.message !== text) {
throw error;
}
throw new Error(text || '야구 티켓베이 요청에 실패했습니다.');
}
}
return (await response.json()) as T;
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error('야구 티켓베이 응답이 지연됩니다.');
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
}
export async function fetchBaseballTicketBayAlerts() {
const response = await request<BaseballTicketBayAlertsResponse>('/baseball-ticket-bay/alerts');
return response.items;
}
export async function fetchBaseballTicketBayLogs(alertId?: string | null) {
const params = new URLSearchParams();
if (alertId) {
params.set('alertId', alertId);
}
const path = params.size > 0 ? `/baseball-ticket-bay/logs?${params.toString()}` : '/baseball-ticket-bay/logs';
const response = await request<BaseballTicketBayLogsResponse>(path);
return response.items;
}
export async function deleteBaseballTicketBayLog(logId: string) {
await request<{ ok: boolean; item: BaseballTicketBayAlertLogItem | null }>(
`/baseball-ticket-bay/logs/${encodeURIComponent(logId)}`,
{
method: 'DELETE',
},
);
}
export async function createBaseballTicketBayAlert(payload: BaseballTicketBayAlertMutation) {
const response = await request<BaseballTicketBayAlertResponse>('/baseball-ticket-bay/alerts', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.item;
}
export async function updateBaseballTicketBayAlert(alertId: string, payload: Partial<BaseballTicketBayAlertMutation>) {
const response = await request<BaseballTicketBayAlertResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
return response.item;
}
export async function deleteBaseballTicketBayAlert(alertId: string) {
await request<{ ok: boolean; item: BaseballTicketBayAlertItem | null }>(
`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}`,
{
method: 'DELETE',
},
);
}
export async function runBaseballTicketBayAlert(alertId: string) {
return request<BaseballTicketBayRunResponse>(`/baseball-ticket-bay/alerts/${encodeURIComponent(alertId)}/run`, {
method: 'POST',
timeoutMs: BASEBALL_TICKET_BAY_RUN_TIMEOUT_MS,
});
}

View File

@@ -24,6 +24,7 @@ import {
listReaderLibraryArticles,
searchReaderNews,
saveReaderLibraryArticle,
setEReaderShareTokenOverride,
type EReaderLibraryArticle,
type EReaderNewsArticle,
type EReaderNewsSearchParams,
@@ -33,6 +34,7 @@ import './EReaderAppView.css';
type EReaderAppViewProps = {
onBack: () => void;
launchContext?: 'direct' | 'embedded';
shareToken?: string | null;
};
type ReaderTheme = 'mist' | 'ocean' | 'night' | 'sepia' | 'forest' | 'graphite' | 'rose' | 'dawn';
@@ -994,6 +996,18 @@ function getReaderLaunchUrl() {
return new URL(getReaderLaunchPath(), window.location.origin).toString();
}
function normalizeShareToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
function readShareTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
}
function getInstallGuideMessage() {
if (typeof navigator !== 'undefined' && /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
return 'Safari 공유 메뉴에서 홈 화면에 추가를 선택하면 E-Reader 전용 아이콘으로 저장됩니다.';
@@ -1002,7 +1016,10 @@ function getInstallGuideMessage() {
return '브라우저 메뉴의 홈 화면에 추가 또는 앱 설치를 사용하면 E-Reader를 바로 열 수 있습니다.';
}
async function createEReaderManifestObjectUrl(registeredToken: string) {
async function createEReaderManifestObjectUrl(options?: {
registeredToken?: string | null;
shareToken?: string | null;
}) {
if (typeof window === 'undefined') {
return '';
}
@@ -1020,8 +1037,17 @@ async function createEReaderManifestObjectUrl(registeredToken: string) {
typeof manifest.start_url === 'string' && manifest.start_url.trim() ? manifest.start_url : getReaderLaunchPath(),
window.location.origin,
);
const registeredToken = options?.registeredToken?.trim() ?? '';
const shareToken = normalizeShareToken(options?.shareToken);
if (registeredToken) {
startUrl.searchParams.set('registeredAccessToken', registeredToken);
}
if (shareToken) {
startUrl.searchParams.set('shareToken', shareToken);
}
startUrl.searchParams.set('registeredAccessToken', registeredToken);
manifest.start_url = `${startUrl.pathname}${startUrl.search}${startUrl.hash}`;
return window.URL.createObjectURL(
@@ -1297,7 +1323,7 @@ function sleep(ms: number) {
});
}
export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppViewProps) {
export function EReaderAppView({ onBack, launchContext = 'direct', shareToken }: EReaderAppViewProps) {
const storedSettings = readStoredSettings();
const initialStoredArticles = readStoredArticles();
const initialReadHistory = readStoredReadHistory();
@@ -1442,6 +1468,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
const safeDisplayPageIndex = clampIndex(displayPageIndex, pageCount);
const hiddenPageSlot = visiblePageSlot === 'primary' ? 'secondary' : 'primary';
const isEmbeddedLaunch = launchContext === 'embedded';
const effectiveShareToken = normalizeShareToken(shareToken) || readShareTokenFromUrl();
const hasViewHistory = viewHistory.length > 0;
const canExitToParentApp = isEmbeddedLaunch && installState !== 'standalone';
const isStandaloneHome = installState === 'standalone' && currentView === 'home';
@@ -1610,6 +1637,14 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
};
}, [librarySyncRequestId]);
useEffect(() => {
setEReaderShareTokenOverride(effectiveShareToken);
return () => {
setEReaderShareTokenOverride('');
};
}, [effectiveShareToken]);
useEffect(() => {
if (typeof document === 'undefined') {
return undefined;
@@ -1621,10 +1656,13 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
const previewRuntimeToken = isPreviewRuntime() ? getRegisteredAccessToken() : '';
const restoreManifest = swapManifestForEReader();
if (previewRuntimeToken) {
if (previewRuntimeToken || effectiveShareToken) {
void (async () => {
try {
const manifestObjectUrl = await createEReaderManifestObjectUrl(previewRuntimeToken);
const manifestObjectUrl = await createEReaderManifestObjectUrl({
registeredToken: previewRuntimeToken,
shareToken: effectiveShareToken,
});
if (isDisposed) {
window.URL.revokeObjectURL(manifestObjectUrl);
@@ -1648,7 +1686,7 @@ export function EReaderAppView({ onBack, launchContext = 'direct' }: EReaderAppV
restoreManifest();
document.body.classList.remove(E_READER_IMMERSIVE_BODY_CLASS);
};
}, []);
}, [effectiveShareToken]);
useEffect(() => {
if (typeof window === 'undefined') {

View File

@@ -92,6 +92,7 @@ const WORK_SERVER_FALLBACK_BASE_URL =
!import.meta.env.VITE_WORK_SERVER_URL && WORK_SERVER_BASE_URL === '/api'
? resolveWorkServerFallbackBaseUrl()
: null;
let eReaderShareTokenOverride = '';
class EReaderApiError extends Error {
status: number;
@@ -103,6 +104,26 @@ class EReaderApiError extends Error {
}
}
function normalizeShareToken(value: string | null | undefined) {
return value?.trim() ?? '';
}
function readShareTokenFromUrl() {
if (typeof window === 'undefined') {
return '';
}
return normalizeShareToken(new URLSearchParams(window.location.search).get('shareToken'));
}
function resolveReaderShareToken() {
return normalizeShareToken(eReaderShareTokenOverride) || readShareTokenFromUrl();
}
export function setEReaderShareTokenOverride(shareToken: string | null | undefined) {
eReaderShareTokenOverride = normalizeShareToken(shareToken);
}
function parseJsonPayload<T>(text: string, fallbackMessage: string) {
if (!text.trim()) {
throw new EReaderApiError(fallbackMessage, 502);
@@ -206,11 +227,16 @@ export async function extractReaderArticle(url: string) {
function buildReaderHeaders() {
const headers = appendClientIdHeader();
const token = getRegisteredAccessToken();
const shareToken = resolveReaderShareToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
if (shareToken && !headers.has('X-Chat-Share-Token')) {
headers.set('X-Chat-Share-Token', shareToken);
}
return headers;
}