import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router-dom'; import { FullscreenPreviewModal } from '../../../components/previewer'; import { BaseballTicketBayPlayAppView } from '../../../views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView'; import { EReaderAppView } from '../../../views/play/apps/e-reader/EReaderAppView'; import { findReadyPlayAppEntryById, getReadyPlayAppEntries, getSupportedPlayAppEnvironments, isPlayAppSupportedInEnvironment, type PlayAppEntry, type PlayAppEnvironment, } from '../../../views/play/apps/apps/appsRegistry'; import { PhotoPuzzleAppView } from '../../../views/play/apps/photo-puzzle/PhotoPuzzleAppView'; import { PhotoPrismAppView } from '../../../views/play/apps/photoprism/PhotoPrismAppView'; import { TetrisAppView } from '../../../views/play/apps/tetris/TetrisAppView'; import { TheQuestAppView } from '../../../views/play/apps/the-quest/TheQuestAppView'; import { SharedResourceManagementPage } from '../SharedResourceManagementPage'; import { SharedAppSettingsPage } from '../SharedAppSettingsPage'; import { TokenSettingManagementPage } from '../TokenSettingManagementPage'; import { ServerCommandPage } from '../../../features/serverCommand'; import { fetchServerCommands } from '../../../features/serverCommand/api'; import type { ServerCommandItem } from '../../../features/serverCommand/types'; import { saveAppConfigToServer, setStoredAppConfig, useAppConfig } from '../appConfig'; import { useChatTypeRegistry } from '../chatTypeAccess'; import { resolveChatRoomContextSettings, resolveChatTypeDefaultContextIds, upsertChatRoomContextSettings, useChatContextSettingsRegistry, } from '../chatContextSettingsAccess'; import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from '../mainChatPanel/ChatPromptCard'; import { ChatPreviewBody, type ChatPreviewTarget } from '../mainChatPanel/ChatPreviewBody'; import { cancelChatShareRuntimeRequest, cancelChatShareRequest, ChatApiError, clearChatShareConversationRoom, completeChatShareManualBadge, createChatShareRoom, fetchChatConversationDetail, fetchChatConversations, deleteChatShareRoom, fetchChatShareRuntimeSnapshot, fetchChatShareSnapshot, getStoredChatShareAccessPin, resolveChatWebSocketUrl, retryChatShareRequest, saveChatShareRoomSettings, setStoredChatShareAccessPin, submitChatShareOriginReply, submitChatShareMessage, submitChatSharePrompt, uploadChatShareComposerFile, type ChatShareRoomSummary, type ChatShareSnapshot, } from '../mainChatPanel/chatUtils'; import { extractAttachmentPreviewUrls, extractChatMessageParts } from '../mainChatPanel/messageParts'; import { stripHiddenPreviewTags } from '../mainChatPanel/previewMarkers'; import { extractPreviewItems, type PreviewItem } from '../mainChatPanel/previewItems'; import { buildChatPath, buildPlayAppPath } from '../routes'; import type { PreviewKind } from '../mainChatPanel/previewKind'; import { normalizeChatResourceUrl } from '../mainChatPanel/chatResourceUrl'; import { forceReloadApp } from '../appUpdate'; import type { ChatComposerAttachment, ChatConversationSummary, ChatConversationRequest, ChatMessage, ChatMessagePart, ChatShareRoomLinkContext, ChatRuntimeJobItem, ChatRuntimeSnapshot, ChatRuntimeTerminalStatus, ChatServerEvent, } from '../mainChatPanel/types'; import { isPromptResolved } from '../mainChatPanel/promptState'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars'; import { isPreviewRuntime } from '../previewRuntime'; import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest'; import { getSavedNotificationDeviceId } from '../notificationIdentity'; import { ensureWebPushSubscriptionRegistered, syncExistingWebPushSubscriptionRegistration } from '../webPushRegistration'; import '../mainChatPanel/styles/MainChatPanel.conversation.css'; import '../mainChatPanel/styles/MainChatPanel.preview-runtime.css'; import './ChatSharePage.css'; const { Paragraph, Text, Title } = Typography; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources/'; const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/'; const CHAT_PUBLIC_DOT_CODEX_PREFIX = '/public/.codex_chat/'; const RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX = '/api/resource-manager/preview/'; const RESOURCE_MANAGER_ROOT_PREFIX = 'resource/'; const SHARE_COMPOSER_VIEWPORT_COMPACT_THRESHOLD_PX = 24; const SHARE_ACCESS_PIN_MAX_LENGTH = 4; const SHARE_PROCESSING_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS = 1000; const SHARE_EXPIRY_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token'; const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token'; const SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY = 'codex-live-share-room-snapshot-index:v1'; const SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1'; const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6; const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [ { value: 'always', label: '매번 묻기', minutes: 0 }, { value: '5', label: '5분 유지', minutes: 5 }, { value: '30', label: '30분 유지', minutes: 30 }, { value: '60', label: '1시간 유지', minutes: 60 }, { value: '180', label: '3시간 유지', minutes: 180 }, { value: '1440', label: '24시간 유지', minutes: 1440 }, ] as const; type SharePreviewFetchError = Error & { status?: number }; type ShareRequestCardMode = 'question-only' | 'answer-only' | 'full'; type ShareRenderedMessage = { visibleText: string; promptParts: Extract[]; diffBlocks: string[]; }; type ShareMessageRenderPayload = ShareRenderedMessage & { previewItems: PreviewItem[]; }; type ShareExpandMode = 'latest' | 'pending' | 'all'; type PendingSharePromptSelection = PromptDraftSelection & { status: 'draft' | 'submitted'; }; type ShareProgramRestoreSnapshot = { roomSessionId: string; latestRequestId: string; expandMode: ShareExpandMode; scrollTop: number; }; type ShareProgramTarget = { key: string; label: string; url: string; kind: PreviewKind; meta?: string; appId?: string; restoreSnapshot?: ShareProgramRestoreSnapshot | null; }; type ShareMinimizedProgramItem = { target: ShareProgramTarget; position: { x: number; y: number; }; }; type ShareAppEnvironment = PlayAppEnvironment; type ShareSearchResult = { key: string; title: string; description: string; category: 'request' | 'response' | 'resource' | 'activity'; icon?: ReactNode; usageBadge?: string | null; requestId?: string; resource?: ShareProgramTarget | null; appEntry?: PlayAppEntry | null; scrollTarget?: { type: 'request' | 'activity' | 'response' | 'prompt'; value: string; }; }; type ShareSearchPanelMode = 'all' | 'apps'; type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required'; type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied'; type ShareNotificationStatusTone = 'success' | 'warning' | 'default'; type ShareNotificationClientStatus = { roomLabel: string; appLabel: string; permissionLabel: string; registrationLabel: string; summaryLabel: string; tone: ShareNotificationStatusTone; }; type ShareProcessInspectorMode = 'default' | 'fullscreen' | 'minimized'; type ShareProcessInspectorExpandedSection = 'summary' | 'narratives' | null; type ShareProcessChecklistStep = { key: string; label: string; status: 'pending' | 'in_progress' | 'completed'; note: string; }; type ShareProcessInspectorPayload = { requestId: string; statusTag: { color: string; label: string }; summary: string; elapsedLabel: string; startedAtLabel: string; updatedAtLabel: string; activityLines: string[]; latestActivityLine: string; checklist: ShareProcessChecklistStep[]; narratives: string[]; }; type ShareRoomPendingCounts = { processingCount: number; unansweredCount: number; }; type ShareRoomSourceGroup = { key: string; title: string; requestPreview: string; chatTypeLabel: string; sourceSessionId: string | null; sourceRequestId: string | null; linkContext: ChatShareRoomLinkContext | null; rooms: ChatShareRoomSummary[]; }; const LazyTextMemoWidget = lazy(async () => { const module = await import('../../../widgets/text-memo-widget'); return { default: module.TextMemoWidget }; }); function normalizeAccessPinInput(value: string) { return value.replace(/\D+/gu, '').slice(0, SHARE_ACCESS_PIN_MAX_LENGTH); } function resolveShareConversationRequestSortKey(request: ChatConversationRequest) { const userMessageId = typeof request.userMessageId === 'number' && Number.isFinite(request.userMessageId) ? request.userMessageId : null; if (userMessageId != null) { return userMessageId; } const responseMessageId = typeof request.responseMessageId === 'number' && Number.isFinite(request.responseMessageId) ? request.responseMessageId : null; if (responseMessageId != null) { return responseMessageId; } const createdAt = Date.parse(request.createdAt?.trim() ?? ''); return Number.isFinite(createdAt) ? createdAt : null; } function compareShareConversationRequests(left: ChatConversationRequest, right: ChatConversationRequest) { const leftSortKey = resolveShareConversationRequestSortKey(left); const rightSortKey = resolveShareConversationRequestSortKey(right); if (leftSortKey != null && rightSortKey != null && leftSortKey !== rightSortKey) { return leftSortKey - rightSortKey; } const leftCreatedAt = Date.parse(left.createdAt?.trim() ?? ''); const rightCreatedAt = Date.parse(right.createdAt?.trim() ?? ''); if (Number.isFinite(leftCreatedAt) && Number.isFinite(rightCreatedAt) && leftCreatedAt !== rightCreatedAt) { return leftCreatedAt - rightCreatedAt; } return left.requestId.localeCompare(right.requestId, 'ko'); } function isSnapshotDeferrableFocusTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) { return false; } const tagName = target.tagName.toLowerCase(); if (tagName === 'textarea') { return true; } if (tagName === 'input') { const inputType = target.getAttribute('type')?.toLowerCase() ?? 'text'; return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes(inputType); } return target.isContentEditable; } function resolveAccessPinPromptTtlMinutes(value?: number | null) { if (value == null) { return 0; } return Number.isFinite(value) ? Math.max(0, Number(value)) : 0; } function resolveAccessPinPromptTtlOptionValue(value?: number | null) { const normalizedValue = resolveAccessPinPromptTtlMinutes(value); return SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.minutes === normalizedValue)?.value ?? 'always'; } function resolveAccessPinPromptTtlLabel(value?: number | null) { const option = SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.minutes === resolveAccessPinPromptTtlMinutes(value)); return option?.label ?? '매번 묻기'; } function parseAccessPinPromptTtlOptionValue(value: string) { const matchedOption = SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.value === value); return matchedOption?.minutes ?? null; } function canDeleteShareRoom(room: ChatShareRoomSummary, rooms: ChatShareRoomSummary[]) { if (room.isDefault) { return false; } return rooms.length > 1; } function readStoredShareImmediateSendPinnedByToken() { if (typeof window === 'undefined') { return {} as Record; } try { const raw = window.localStorage.getItem(SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY); if (!raw) { return {} as Record; } const parsed = JSON.parse(raw) as Record; return Object.entries(parsed).reduce>((result, [token, isPinned]) => { const normalizedToken = String(token ?? '').trim(); if (normalizedToken && typeof isPinned === 'boolean') { result[normalizedToken] = isPinned; } return result; }, {}); } catch { return {} as Record; } } function writeStoredShareImmediateSendPinnedByToken(nextMap: Record) { if (typeof window === 'undefined') { return; } const normalizedMap = Object.entries(nextMap).reduce>((result, [token, isPinned]) => { const normalizedToken = String(token ?? '').trim(); if (normalizedToken && typeof isPinned === 'boolean') { result[normalizedToken] = isPinned; } return result; }, {}); window.localStorage.setItem(SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY, JSON.stringify(normalizedMap)); } function readStoredShareLastRoomMapFromStorage(storage: Storage | null | undefined) { if (!storage) { return {} as Record; } try { const raw = storage.getItem(SHARE_LAST_ROOM_STORAGE_KEY); if (!raw) { return {} as Record; } const parsed = JSON.parse(raw) as Record; return Object.entries(parsed).reduce>((result, [token, sessionId]) => { const normalizedToken = String(token ?? '').trim(); const normalizedSessionId = String(sessionId ?? '').trim(); if (normalizedToken && normalizedSessionId) { result[normalizedToken] = normalizedSessionId; } return result; }, {}); } catch { return {} as Record; } } function readStoredShareLastRoomByToken() { if (typeof window === 'undefined') { return {} as Record; } return { ...readStoredShareLastRoomMapFromStorage(window.sessionStorage), ...readStoredShareLastRoomMapFromStorage(window.localStorage), }; } function readStoredShareLastRoomSessionId(token: string) { const normalizedToken = token.trim(); if (!normalizedToken) { return ''; } return readStoredShareLastRoomByToken()[normalizedToken] ?? ''; } function writeStoredShareLastRoomSessionId(token: string, sessionId: string | null) { if (typeof window === 'undefined') { return; } const normalizedToken = token.trim(); if (!normalizedToken) { return; } const normalizedSessionId = String(sessionId ?? '').trim(); const nextMap = readStoredShareLastRoomByToken(); if (normalizedSessionId) { nextMap[normalizedToken] = normalizedSessionId; } else { delete nextMap[normalizedToken]; } const serialized = JSON.stringify(nextMap); window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized); window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized); } function buildShareRoomSnapshotSessionStorageKey(token: string, sessionId: string) { return `${SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX}:${token}:${sessionId}`; } function canUseShareRoomSnapshotSessionStorage() { return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'; } function readStoredShareRoomSnapshotSessionIndex() { if (!canUseShareRoomSnapshotSessionStorage()) { return {} as Record>; } try { const raw = window.sessionStorage.getItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY); if (!raw) { return {} as Record>; } const parsed = JSON.parse(raw) as Record; return Object.entries(parsed).reduce>>((result, [token, entries]) => { const normalizedToken = String(token ?? '').trim(); if (!normalizedToken || !Array.isArray(entries)) { return result; } result[normalizedToken] = entries.flatMap((entry) => { if (!entry || typeof entry !== 'object') { return []; } const sessionId = String((entry as { sessionId?: unknown }).sessionId ?? '').trim(); const savedAt = Number((entry as { savedAt?: unknown }).savedAt); if (!sessionId || !Number.isFinite(savedAt) || savedAt <= 0) { return []; } return [{ sessionId, savedAt }]; }); return result; }, {}); } catch { return {} as Record>; } } function writeStoredShareRoomSnapshotSessionIndex(index: Record>) { if (!canUseShareRoomSnapshotSessionStorage()) { return; } try { const normalizedIndex = Object.entries(index).reduce>>((result, [token, entries]) => { const normalizedToken = String(token ?? '').trim(); if (!normalizedToken || !Array.isArray(entries) || entries.length === 0) { return result; } const normalizedEntries = entries .map((entry) => ({ sessionId: String(entry.sessionId ?? '').trim(), savedAt: Number(entry.savedAt), })) .filter((entry) => entry.sessionId && Number.isFinite(entry.savedAt) && entry.savedAt > 0); if (normalizedEntries.length > 0) { result[normalizedToken] = normalizedEntries; } return result; }, {}); if (Object.keys(normalizedIndex).length === 0) { window.sessionStorage.removeItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY); return; } window.sessionStorage.setItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY, JSON.stringify(normalizedIndex)); } catch { // Ignore sessionStorage failures and keep runtime fallback active. } } function resolveShareSnapshotCacheSessionId(snapshot: ChatShareSnapshot | null | undefined) { return snapshot?.activeSessionId?.trim() || snapshot?.conversation.sessionId?.trim() || snapshot?.share.sessionId?.trim() || ''; } function doesShareSnapshotMatchRequestedRoom( snapshot: ChatShareSnapshot | null | undefined, requestedSessionId: string | null | undefined, ) { const normalizedRequestedSessionId = requestedSessionId?.trim() || ''; if (!normalizedRequestedSessionId) { return true; } return resolveShareSnapshotCacheSessionId(snapshot) === normalizedRequestedSessionId; } function readStoredShareRoomSnapshot(token: string, sessionId: string) { const normalizedToken = token.trim(); const normalizedSessionId = sessionId.trim(); if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) { return null; } try { const raw = window.sessionStorage.getItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId)); if (!raw) { return null; } const parsed = JSON.parse(raw) as { snapshot?: ChatShareSnapshot | null }; const snapshot = parsed?.snapshot ?? null; if (!snapshot) { return null; } if (snapshot.share.hasAccessPin && !getStoredChatShareAccessPin(normalizedToken)) { return null; } const cachedSessionId = resolveShareSnapshotCacheSessionId(snapshot); return cachedSessionId === normalizedSessionId ? snapshot : null; } catch { return null; } } function removeStoredShareRoomSnapshot(token: string, sessionId: string) { const normalizedToken = token.trim(); const normalizedSessionId = sessionId.trim(); if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) { return; } try { window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId)); const nextIndex = readStoredShareRoomSnapshotSessionIndex(); const currentEntries = nextIndex[normalizedToken] ?? []; const remainingEntries = currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId); if (remainingEntries.length > 0) { nextIndex[normalizedToken] = remainingEntries; } else { delete nextIndex[normalizedToken]; } writeStoredShareRoomSnapshotSessionIndex(nextIndex); } catch { // Ignore sessionStorage failures and keep runtime fallback active. } } function clearStoredShareRoomSnapshotCache(token: string) { const normalizedToken = token.trim(); if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken) { return; } const nextIndex = readStoredShareRoomSnapshotSessionIndex(); const currentEntries = nextIndex[normalizedToken] ?? []; currentEntries.forEach((entry) => { window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId)); }); delete nextIndex[normalizedToken]; writeStoredShareRoomSnapshotSessionIndex(nextIndex); } function writeStoredShareRoomSnapshot(token: string, snapshot: ChatShareSnapshot | null | undefined) { const normalizedToken = token.trim(); const normalizedSessionId = resolveShareSnapshotCacheSessionId(snapshot); if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId || !snapshot) { return; } const nextSavedAt = Date.now(); try { window.sessionStorage.setItem( buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId), JSON.stringify({ savedAt: nextSavedAt, snapshot, }), ); const nextIndex = readStoredShareRoomSnapshotSessionIndex(); const currentEntries = nextIndex[normalizedToken] ?? []; const dedupedEntries = [ { sessionId: normalizedSessionId, savedAt: nextSavedAt }, ...currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId), ] .sort((left, right) => right.savedAt - left.savedAt) .slice(0, SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT); nextIndex[normalizedToken] = dedupedEntries; writeStoredShareRoomSnapshotSessionIndex(nextIndex); currentEntries .filter((entry) => !dedupedEntries.some((keptEntry) => keptEntry.sessionId === entry.sessionId)) .forEach((entry) => { window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId)); }); } catch { // Ignore sessionStorage quota failures and keep network refresh behavior. } } function readShareRoomSessionIdFromLocation() { if (typeof window === 'undefined') { return ''; } return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || ''; } function writeShareRoomSessionIdToLocation(roomSessionId: string | null, mode: 'push' | 'replace' = 'replace') { if (typeof window === 'undefined') { return; } const normalizedSessionId = String(roomSessionId ?? '').trim(); const nextUrl = new URL(window.location.href); const currentSessionId = nextUrl.searchParams.get('roomSessionId')?.trim() || ''; if (normalizedSessionId) { if (currentSessionId === normalizedSessionId) { return; } nextUrl.searchParams.set('roomSessionId', normalizedSessionId); } else { if (!currentSessionId) { return; } nextUrl.searchParams.delete('roomSessionId'); } const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`; if (mode === 'push') { window.history.pushState(window.history.state, '', nextPath); return; } window.history.replaceState(window.history.state, '', nextPath); } function getClientNotificationPermission(): ClientNotificationPermissionState { if ( typeof window === 'undefined' || typeof Notification === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window) ) { return 'unsupported'; } if (Notification.permission === 'granted') { return 'granted'; } if (Notification.permission === 'denied') { return 'denied'; } return 'default'; } function hasSecureOrigin() { if (typeof window === 'undefined') { return false; } return window.isSecureContext || window.location.hostname === 'localhost'; } function isStandaloneDisplayMode() { if (typeof window === 'undefined') { return false; } return ( window.matchMedia?.('(display-mode: standalone)').matches === true || (window.navigator as Navigator & { standalone?: boolean }).standalone === true ); } function isAppleMobileDevice() { if (typeof navigator === 'undefined') { return false; } return /iPhone|iPad|iPod/i.test(navigator.userAgent); } async function getPushServiceWorkerRegistration() { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return null; } const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => { if (!registration) { return null; } if (registration.active || registration.waiting) { return registration; } const installingWorker = registration.installing; if (!installingWorker) { return registration; } await new Promise((resolve) => { let completed = false; const finish = () => { if (completed) { return; } completed = true; window.clearTimeout(timeoutId); installingWorker.removeEventListener('statechange', onStateChange); resolve(); }; const onStateChange = () => { if ( installingWorker.state === 'activated' || installingWorker.state === 'installed' || Boolean(registration.active) || Boolean(registration.waiting) ) { finish(); } }; const timeoutId = window.setTimeout(finish, 5000); installingWorker.addEventListener('statechange', onStateChange); onStateChange(); }); return registration.active || registration.waiting ? registration : registration; }; const existing = await navigator.serviceWorker.getRegistration(); if (existing) { return (await resolveUsableRegistration(existing)) ?? existing; } const scoped = await navigator.serviceWorker.getRegistration('/'); if (scoped) { return (await resolveUsableRegistration(scoped)) ?? scoped; } const knownRegistrations = await navigator.serviceWorker.getRegistrations(); for (const registration of knownRegistrations) { const usableRegistration = await resolveUsableRegistration(registration); if (usableRegistration) { return usableRegistration; } } const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js'; const resolvedServiceWorkerUrl = typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl; try { const registration = import.meta.env.DEV ? await navigator.serviceWorker.register(resolvedServiceWorkerUrl, { type: 'module', scope: '/', updateViaCache: 'none' }) .catch(() => navigator.serviceWorker.register(resolvedServiceWorkerUrl, { scope: '/', updateViaCache: 'none' })) : await navigator.serviceWorker.register(resolvedServiceWorkerUrl); const usableRegistration = await resolveUsableRegistration(registration); if (usableRegistration) { return usableRegistration; } } catch { // Fall through to ready/getRegistrations checks below. } try { const readyRegistration = await Promise.race([ navigator.serviceWorker.ready, new Promise((resolve) => { window.setTimeout(() => resolve(null), 5000); }), ]); if (readyRegistration) { return (await resolveUsableRegistration(readyRegistration)) ?? readyRegistration; } } catch { // Ignore and try known registrations one more time. } const latestRegistrations = await navigator.serviceWorker.getRegistrations(); for (const registration of latestRegistrations) { const usableRegistration = await resolveUsableRegistration(registration); if (usableRegistration) { return usableRegistration; } } return null; } function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) { switch (permission) { case 'granted': return '권한 허용'; case 'denied': return '권한 차단'; case 'unsupported': return '브라우저 미지원'; default: return '권한 확인 필요'; } } function buildShareNotificationClientStatus(args: { roomEnabled: boolean; appEnabled: boolean; permission: ClientNotificationPermissionState; registrationReady: boolean; }) { const roomLabel = args.roomEnabled ? '방 On' : '방 Off'; const appLabel = args.appEnabled ? '앱 On' : '앱 Off'; const permissionLabel = getClientNotificationPermissionLabel(args.permission); const registrationLabel = args.registrationReady ? '기기 등록됨' : '기기 미등록'; if (!args.roomEnabled) { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: '이 채팅방 알림 꺼짐', tone: 'default', } satisfies ShareNotificationClientStatus; } if (!args.appEnabled) { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: '앱 전체 알림 차단', tone: 'warning', } satisfies ShareNotificationClientStatus; } if (!hasSecureOrigin()) { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: 'HTTPS 환경 필요', tone: 'warning', } satisfies ShareNotificationClientStatus; } if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: 'iPhone PWA 실행 필요', tone: 'warning', } satisfies ShareNotificationClientStatus; } if (args.permission !== 'granted') { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: args.permission === 'denied' ? '브라우저 권한 차단' : permissionLabel, tone: 'warning', } satisfies ShareNotificationClientStatus; } if (!args.registrationReady) { return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: '현재 기기 등록 필요', tone: 'warning', } satisfies ShareNotificationClientStatus; } return { roomLabel, appLabel, permissionLabel, registrationLabel, summaryLabel: '수신 가능', tone: 'success', } satisfies ShareNotificationClientStatus; } function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) { if (attachments.length === 0) { return ''; } return ['첨부 파일:', ...attachments.map((attachment) => `- ${attachment.name}: ${attachment.publicUrl || attachment.path}`)].join('\n'); } function buildOutgoingShareMessageText(text: string, attachments: ChatComposerAttachment[]) { const trimmedText = text.trim(); const attachmentBlock = buildAttachmentMessageBlock(attachments); if (trimmedText && attachmentBlock) { return `${trimmedText}\n\n${attachmentBlock}`; } return trimmedText || attachmentBlock; } function resolveShareComposerPasteFiles(clipboardData: DataTransfer) { 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 []; } const COLLAPSIBLE_TEXT_MAX_LENGTH = 720; const COLLAPSIBLE_TEXT_MAX_LINES = 8; const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; const SCROLL_JUMP_HIDE_THRESHOLD = 36; const SCROLL_JUMP_MIN_OVERFLOW = 96; const SCROLL_JUMP_DIRECTION_THRESHOLD = 2; const SCROLL_JUMP_IDLE_HIDE_DELAY_MS = 180; const PROGRAM_MINIMIZED_VIEWPORT_PADDING = 12; const PROGRAM_MINIMIZED_DEFAULT_WIDTH = 176; const PROGRAM_MINIMIZED_DEFAULT_HEIGHT = 58; const SHARE_PROGRAM_MODAL_Z_INDEX = 1750; const SHARE_PROGRAM_MINIMIZED_Z_INDEX = SHARE_PROGRAM_MODAL_Z_INDEX + 5; const SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING = 12; const SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH = 520; const SHARE_PROCESS_INSPECTOR_DEFAULT_HEIGHT = 540; const SHARE_PROCESS_INSPECTOR_FULLSCREEN_WIDTH = 1120; const SHARE_PROCESS_INSPECTOR_FULLSCREEN_HEIGHT = 820; const SHARE_PROCESS_INSPECTOR_MINIMIZED_WIDTH = 250; const SHARE_PROCESS_INSPECTOR_MINIMIZED_HEIGHT = 72; const SHARE_PROCESS_INSPECTOR_Z_INDEX = SHARE_PROGRAM_MINIMIZED_Z_INDEX + 5; const MOBILE_INPUT_VIEWPORT_TOP_PADDING_PX = 6; const MOBILE_INPUT_VIEWPORT_BOTTOM_PADDING_PX = 8; const MOBILE_INPUT_VIEWPORT_SYNC_RETRY_DELAYS_MS = [180, 360] as const; const renderAccessPinVisibilityIcon = (visible: boolean) => (visible ? : ); function areStringListsEqual(left: string[], right: string[]) { return left.length === right.length && left.every((value, index) => value === right[index]); } function resolveShareRoomDefaultContextIds( roomContextSettings: ReturnType, chatTypeDefaults: Parameters[0], chatTypeId: string | null | undefined, ) { const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, chatTypeId); const roomDefaultContextIds = roomContextSettings?.defaultContextIds ?? []; return roomDefaultContextIds.length > 0 ? roomDefaultContextIds : inheritedDefaultContextIds; } const SHARE_EXPAND_MODE_LABELS: Record = { latest: '마지막건', pending: '처리중·미확인', all: '전체', }; const TOKEN_USAGE_PERIODS = [ { key: '7d', label: '1주일', windowMs: 7 * 24 * 60 * 60 * 1000 }, { key: '5h', label: '5시간', windowMs: 5 * 60 * 60 * 1000 }, ] as const; const SHARE_APP_ENVIRONMENT_OPTIONS: Array<{ key: ShareAppEnvironment; label: string; origin: string; }> = [ { key: 'preview', label: 'preview', origin: 'https://preview.sm-home.cloud' }, { key: 'test', label: 'test', origin: 'https://test.sm-home.cloud' }, { key: 'prod', label: 'prod', origin: 'https://sm-home.cloud' }, ] as const; const SHARE_APP_LAUNCH_USAGE_STORAGE_KEY = 'chat-share-page:app-launch-usage:v1'; const CHAT_SHARE_INSTALL_NAME = '리소스 공유 채팅방'; const CHAT_SHARE_INSTALL_SHORT_NAME = '공유채팅'; const CHAT_SHARE_INSTALL_THEME_COLOR = '#165dff'; const SHARE_CURRENT_CHAT_APP_ID = 'shared-chat-current'; type TokenUsagePeriodKey = (typeof TOKEN_USAGE_PERIODS)[number]['key']; type TokenUsageWindowSummary = { totalUsedTokens: number; inputTokens: number; outputTokens: number; cachedTokens: number; reasoningTokens: number; requestCount: number; percentage: number | null; remainingTokens: number | null; windowStartedAt: string | null; nextUsageDropAt: string | null; fullResetAt: string | null; nextResetTokens: number; }; const APPS_LAUNCHER_SEARCH_TERMS = ['apps', 'app', '앱', '프로그램', '실행', '빠른 실행']; const SHARE_MANAGEMENT_APP_OPTIONS = [ { value: 'text-memo-widget', label: '메모', description: '공유채팅 안에서 메모 컴포넌트를 바로 실행', icon: , usagePriority: 70, }, { value: 'token-setting', label: '토큰관리 설정', description: '토큰 설정 목록과 상세 편집 접근', icon: , usagePriority: 55, }, { value: 'shared-resource', label: '공유 리소스 관리', description: '공유 링크와 권한, 활동 이력 관리', icon: , usagePriority: 50, }, { value: 'app-settings', label: '앱 설정', description: '헤더 설정, 알림, 업데이트 화면 접근', icon: , usagePriority: 75, }, { value: 'server-command', label: '서버관리', description: '여러 서버 상태 확인과 재기동 관리 화면 접근', icon: , usagePriority: 95, }, ] as const; type ShareAppLaunchUsageMap = Record; function readShareAppLaunchUsage(): ShareAppLaunchUsageMap { if (typeof window === 'undefined') { return {}; } try { const rawValue = window.localStorage.getItem(SHARE_APP_LAUNCH_USAGE_STORAGE_KEY); if (!rawValue) { return {}; } const parsedValue = JSON.parse(rawValue) as unknown; if (!parsedValue || typeof parsedValue !== 'object') { return {}; } return Object.entries(parsedValue as Record).reduce((accumulator, [key, value]) => { if (!value || typeof value !== 'object') { return accumulator; } const count = Number((value as { count?: number }).count); const lastOpenedAt = Number((value as { lastOpenedAt?: number }).lastOpenedAt); if (!Number.isFinite(count) || count < 0 || !Number.isFinite(lastOpenedAt) || lastOpenedAt < 0) { return accumulator; } accumulator[key] = { count, lastOpenedAt }; return accumulator; }, {}); } catch { return {}; } } function writeShareAppLaunchUsage(value: ShareAppLaunchUsageMap) { if (typeof window === 'undefined') { return; } try { window.localStorage.setItem(SHARE_APP_LAUNCH_USAGE_STORAGE_KEY, JSON.stringify(value)); } catch { // Ignore storage failures and keep the UI usable. } } function resolveShareAppUsageBadge(record?: { count: number; lastOpenedAt: number } | null) { if (!record || record.count < 2) { return null; } return record.count >= 5 ? '자주 사용' : '최근 사용'; } function compareShareAppLaunchOrder( leftId: string, rightId: string, leftPriority: number, rightPriority: number, usageMap: ShareAppLaunchUsageMap, ) { const leftUsage = usageMap[leftId]; const rightUsage = usageMap[rightId]; const leftCount = leftUsage?.count ?? 0; const rightCount = rightUsage?.count ?? 0; if (leftCount !== rightCount) { return rightCount - leftCount; } const leftLastOpenedAt = leftUsage?.lastOpenedAt ?? 0; const rightLastOpenedAt = rightUsage?.lastOpenedAt ?? 0; if (leftLastOpenedAt !== rightLastOpenedAt) { return rightLastOpenedAt - leftLastOpenedAt; } if (leftPriority !== rightPriority) { return rightPriority - leftPriority; } return leftId.localeCompare(rightId, 'ko'); } function buildShareAllowedAppIdSet(allowedAppIds: string[] | null | undefined, permissions: string[] | null | undefined) { const normalizedAllowedAppIds = new Set( (allowedAppIds ?? []) .map((item) => item.trim().toLowerCase()) .filter(Boolean), ); const normalizedPermissions = new Set( (permissions ?? []) .map((item) => item.trim().toLowerCase()) .filter(Boolean), ); return normalizedAllowedAppIds; } function buildSharePromptAnchorKey(messageId: number, promptIndex: number) { return `${messageId}:${promptIndex}`; } function scrollToShareAnchorElement(element: HTMLElement | null | undefined) { if (!element) { return false; } element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest', }); return true; } function buildPhotoPrismProgramTarget(): ShareProgramTarget { return { key: 'launcher:photoprism', label: 'PhotoPrism', url: '/play/apps?app=photoprism&launchContext=embedded', kind: 'document', meta: '임시 프로그램 실행', appId: 'photoprism', }; } function buildPlayAppProgramTarget(appId: string, label: string): ShareProgramTarget { return { key: `launcher:${appId}`, label, url: buildPlayAppPath(appId, 'embedded'), kind: 'document', meta: '허용된 Apps 실행', appId, }; } function buildShareManagementProgramTarget(appId: string, label: string): ShareProgramTarget { return { key: `management:${appId}`, label, url: buildChatPath('live'), kind: 'document', meta: '공유채팅 관리 앱 실행', appId, }; } function resolveShareWorkServerVersionStatus(item: ServerCommandItem | null): ShareWorkServerVersionStatus { if (!item) { return 'unknown'; } if (item.buildRequired) { return 'build-required'; } if (item.updateAvailable) { return 'update-available'; } return 'latest'; } function resolveShareWorkServerVersionLabel(item: ServerCommandItem | null) { const latestVersion = item?.latestVersion?.trim(); const runningVersion = item?.runningVersion?.trim(); if (latestVersion) { return latestVersion; } if (runningVersion) { return runningVersion; } return '확인 필요'; } function resolveShareWorkServerStatusLabel(item: ServerCommandItem | null) { const status = resolveShareWorkServerVersionStatus(item); if (status === 'build-required') { return '빌드 필요'; } if (status === 'update-available') { return '반영 대기'; } if (status === 'latest') { return '최신'; } return '확인 필요'; } function resolveShareWorkServerVersionDescription(item: ServerCommandItem | null) { if (!item) { return 'WORK 서버 상태를 아직 확인하지 못했습니다.'; } if (item.buildRequired) { return '소스 변경이 있어 최신 빌드가 더 필요합니다.'; } if (item.updateAvailable) { return '새 빌드는 준비됐고 현재 런타임 반영만 남아 있습니다.'; } return '무중단 배포 예약과 최신 런타임 상태를 확인합니다.'; } function buildPlayAppEnvironmentUrl(appId: string, environment: ShareAppEnvironment) { const origin = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.origin ?? SHARE_APP_ENVIRONMENT_OPTIONS[0].origin; return new URL(buildPlayAppPath(appId, 'embedded'), origin).toString(); } function buildShareChatEnvironmentUrl( sharePath: string | null | undefined, shareToken: string | null | undefined, environment: ShareAppEnvironment, ) { const origin = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.origin ?? SHARE_APP_ENVIRONMENT_OPTIONS[0].origin; const normalizedSharePath = sharePath?.trim() ?? ''; const normalizedShareToken = shareToken?.trim() ?? ''; const pathname = normalizedSharePath || (normalizedShareToken ? `/chat/share/${encodeURIComponent(normalizedShareToken)}` : '/chat/live'); return new URL(pathname, origin).toString(); } function buildSharePlayAppInstallPath(appId: string, shareToken?: string | null) { const installPath = new URL(buildPlayAppPath(appId), 'https://preview.sm-home.cloud'); const normalizedShareToken = shareToken?.trim() ?? ''; if (appId === 'e-reader' && normalizedShareToken) { installPath.searchParams.set('shareToken', normalizedShareToken); } return `${installPath.pathname}${installPath.search}${installPath.hash}`; } function resolveSharePlayAppInstallThemeColor(appId: string) { switch (appId) { case 'baseball-ticket-bay': return '#1b3f91'; case 'photoprism': return '#0f766e'; case 'photo-puzzle': return '#d97706'; case 'the-quest': return '#7c3aed'; case 'tetris': return '#0f172a'; default: return '#165dff'; } } function resolveShareAppEnvironmentFromOrigin(origin?: string | null): ShareAppEnvironment { const normalizedOrigin = origin?.trim().toLowerCase(); if (!normalizedOrigin) { return 'preview'; } return SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.origin.toLowerCase() === normalizedOrigin)?.key ?? 'preview'; } function buildPlayAppEnvironmentTarget( appId: string, label: string, environment: ShareAppEnvironment, ): ShareProgramTarget { const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment; return { key: `launcher:${environment}:${appId}`, label, url: buildPlayAppEnvironmentUrl(appId, environment), kind: 'document', meta: `허용된 Apps 실행 · ${environmentLabel}`, appId, }; } function buildShareChatEnvironmentTarget( sharePath: string | null | undefined, shareToken: string | null | undefined, environment: ShareAppEnvironment, ): ShareProgramTarget { const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment; return { key: `launcher:${environment}:${SHARE_CURRENT_CHAT_APP_ID}`, label: '채팅', url: buildShareChatEnvironmentUrl(sharePath, shareToken, environment), kind: 'document', meta: `현재 공유토큰 열기 · ${environmentLabel}`, appId: SHARE_CURRENT_CHAT_APP_ID, }; } function shouldRenderSharePlayAppInline(target: ShareProgramTarget | null | undefined) { if (!target?.appId || !findReadyPlayAppEntryById(target.appId)) { return false; } if (typeof window === 'undefined') { return true; } try { return new URL(target.url, window.location.origin).origin === window.location.origin; } catch { return true; } } function resolveSupportedEnvironmentSummary(entry: PlayAppEntry) { return getSupportedPlayAppEnvironments(entry).join(', '); } function resolveFirstSupportedShareAppEnvironment(entries: PlayAppEntry[]) { for (const option of SHARE_APP_ENVIRONMENT_OPTIONS) { if (entries.some((entry) => isPlayAppSupportedInEnvironment(entry, option.key))) { return option.key; } } return 'preview'; } function renderEmbeddedSharePlayApp(appId: string | undefined, onBack: () => void, shareToken?: string | null) { if (appId === 'photoprism') { return ; } if (appId === 'baseball-ticket-bay') { return ; } if (appId === 'e-reader') { return ; } if (appId === 'photo-puzzle') { return ; } if (appId === 'tetris') { return ; } if (appId === 'the-quest') { return ; } return null; } function formatTimeLabel(value?: string | null) { const normalizedValue = value?.trim(); if (!normalizedValue) { return ''; } const parsedDate = new Date(normalizedValue); if (Number.isNaN(parsedDate.getTime())) { return normalizedValue; } return parsedDate.toLocaleString('ko-KR', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } function formatRemainingTimeLabel(expiresAt: string, nowMs: number) { const expiresAtMs = new Date(expiresAt).getTime(); if (!Number.isFinite(expiresAtMs)) { return ''; } const diffMs = expiresAtMs - nowMs; if (diffMs <= 0) { return '남은시간 없음'; } const totalMinutes = Math.floor(diffMs / (60 * 1000)); const days = Math.floor(totalMinutes / (24 * 60)); const hours = Math.floor((totalMinutes % (24 * 60)) / 60); const minutes = totalMinutes % 60; const parts: string[] = []; if (days > 0) { parts.push(`${days}일`); } if (hours > 0) { parts.push(`${hours}시간`); } if (minutes > 0 || parts.length === 0) { parts.push(`${minutes}분`); } return `남은시간 ${parts.join(' ')}`; } function formatCountdownLabel(targetAt: string | null | undefined, nowMs: number) { const normalizedValue = targetAt?.trim(); if (!normalizedValue) { return '예정 없음'; } const targetMs = new Date(normalizedValue).getTime(); if (!Number.isFinite(targetMs)) { return '시간 미기록'; } const diffMs = Math.max(0, targetMs - nowMs); const totalSeconds = Math.floor(diffMs / 1000); const days = Math.floor(totalSeconds / (24 * 60 * 60)); const hours = Math.floor((totalSeconds % (24 * 60 * 60)) / (60 * 60)); const minutes = Math.floor((totalSeconds % (60 * 60)) / 60); const seconds = totalSeconds % 60; const timeLabel = [hours, minutes, seconds].map((value) => String(value).padStart(2, '0')).join(':'); if (days > 0) { return `${days}일 ${timeLabel}`; } return timeLabel; } function formatDurationMinutesLabel(totalMinutes: number | null | undefined) { const normalizedMinutes = Number(totalMinutes ?? 0); if (!Number.isFinite(normalizedMinutes)) { return ''; } if (normalizedMinutes <= 0) { return '무제한'; } const roundedMinutes = Math.max(1, Math.round(normalizedMinutes)); const days = Math.floor(roundedMinutes / (24 * 60)); const hours = Math.floor((roundedMinutes % (24 * 60)) / 60); const minutes = roundedMinutes % 60; const parts: string[] = []; if (days > 0) { parts.push(`${days}일`); } if (hours > 0) { parts.push(`${hours}시간`); } if (minutes > 0 || parts.length === 0) { parts.push(`${minutes}분`); } return parts.join(' '); } function formatShareExpirySummary(expiresAt: string | null | undefined, nowMs: number) { const normalizedValue = expiresAt?.trim(); if (!normalizedValue) { return '사용기간 제한 없음'; } const formattedTime = formatTimeLabel(normalizedValue); const remainingTime = formatRemainingTimeLabel(normalizedValue, nowMs); const baseLabel = formattedTime || normalizedValue; if (remainingTime) { return `만료 ${baseLabel} · ${remainingTime}`; } return `만료 ${baseLabel}`; } function resolveShareEffectiveExpiresAt(share: ChatShareSnapshot['share'] | null | undefined) { const explicitExpiresAt = share?.expiresAt?.trim(); if (explicitExpiresAt) { return explicitExpiresAt; } const defaultExpiresInMinutes = Number(share?.tokenSetting?.defaultExpiresInMinutes ?? 0); const createdAtMs = Date.parse(share?.createdAt?.trim() ?? ''); if (!Number.isFinite(defaultExpiresInMinutes) || defaultExpiresInMinutes <= 0 || !Number.isFinite(createdAtMs)) { return ''; } return new Date(createdAtMs + defaultExpiresInMinutes * 60 * 1000).toISOString(); } function buildAbsoluteShareUrl(path?: string | null) { const normalizedPath = path?.trim(); if (!normalizedPath) { return ''; } if (/^https?:\/\//i.test(normalizedPath)) { return normalizedPath; } if (typeof window === 'undefined') { return normalizedPath; } return new URL(normalizedPath, window.location.origin).toString(); } function createChatShareManifestHref(pathname: string, title?: string | null) { const normalizedTitle = title?.trim(); return createInstallManifestObjectUrl({ startPath: pathname, scope: pathname, name: normalizedTitle || CHAT_SHARE_INSTALL_NAME, shortName: CHAT_SHARE_INSTALL_SHORT_NAME, description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.', themeColor: CHAT_SHARE_INSTALL_THEME_COLOR, backgroundColor: '#f4f7fb', }); } function swapManifestForChatShare(manifestHref: string, title?: string | null) { return swapInstallDocumentMetadata({ manifestHref, title: title?.trim() || CHAT_SHARE_INSTALL_NAME, themeColor: CHAT_SHARE_INSTALL_THEME_COLOR, }); } function getShareExpandModeLabel(mode: ShareExpandMode) { return SHARE_EXPAND_MODE_LABELS[mode]; } function normalizeSearchKeyword(value: string) { return value.trim().toLocaleLowerCase('ko-KR'); } function isShareInteractivePointerTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) { return false; } return Boolean(target.closest('button, a, input, textarea, select, [role="button"], [data-no-drag="true"]')); } function formatTokenCount(value: number) { return Math.max(0, Math.round(Number(value) || 0)).toLocaleString('ko-KR'); } function resolveSmallestFiniteNumber(...values: Array) { const normalizedValues = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); if (normalizedValues.length === 0) { return null; } return Math.min(...normalizedValues); } function resolveRequestUsageAt(request: ChatConversationRequest) { const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt]; for (const candidate of candidates) { const normalized = candidate?.trim(); if (!normalized) { continue; } const parsed = new Date(normalized).getTime(); if (Number.isFinite(parsed)) { return parsed; } } return 0; } function resolveRequestMessageTimestamp(request: ChatConversationRequest) { const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt]; for (const candidate of candidates) { const normalized = candidate?.trim(); if (!normalized) { continue; } return normalized; } return ''; } function resolveRequestUsageTokens(request: ChatConversationRequest) { return Math.max( 0, Math.round( Number( request.usageSnapshot?.tokenTotals?.total ?? request.usageSnapshot?.totalTokens ?? request.totalTokens ?? 0, ) || 0, ), ); } function resolveTokenUsageWindowSummary( requests: ChatConversationRequest[], periodKey: TokenUsagePeriodKey, nowMs: number, limit: number, ): TokenUsageWindowSummary { const period = TOKEN_USAGE_PERIODS.find((item) => item.key === periodKey) ?? TOKEN_USAGE_PERIODS[0]; const threshold = nowMs - period.windowMs; const matchingRequests = requests.filter((request) => resolveRequestUsageAt(request) >= threshold); const tokenAccumulator = matchingRequests.reduce( (accumulator, request) => { accumulator.totalUsedTokens += resolveRequestUsageTokens(request); accumulator.inputTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.input ?? 0) || 0)); accumulator.outputTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.output ?? 0) || 0)); accumulator.cachedTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.cached ?? 0) || 0)); accumulator.reasoningTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.reasoning ?? 0) || 0)); return accumulator; }, { totalUsedTokens: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0, reasoningTokens: 0, }, ); const usageResetSchedule = matchingRequests .map((request) => resolveRequestUsageAt(request) + period.windowMs) .filter((value) => Number.isFinite(value)) .sort((left, right) => left - right); const nextUsageDropAtMs = usageResetSchedule.length > 0 ? usageResetSchedule[0] : null; const normalizedLimit = Math.max(0, Math.round(Number(limit) || 0)); const percentage = normalizedLimit > 0 ? Math.min(100, (tokenAccumulator.totalUsedTokens / normalizedLimit) * 100) : null; const remainingTokens = normalizedLimit > 0 ? Math.max(0, normalizedLimit - tokenAccumulator.totalUsedTokens) : null; const nextResetTokens = nextUsageDropAtMs == null ? 0 : matchingRequests.reduce((accumulator, request) => { if (resolveRequestUsageAt(request) + period.windowMs !== nextUsageDropAtMs) { return accumulator; } return accumulator + resolveRequestUsageTokens(request); }, 0); return { ...tokenAccumulator, requestCount: matchingRequests.length, percentage, remainingTokens, windowStartedAt: new Date(threshold).toISOString(), nextUsageDropAt: nextUsageDropAtMs != null ? new Date(nextUsageDropAtMs).toISOString() : null, fullResetAt: usageResetSchedule.length > 0 ? new Date(usageResetSchedule[usageResetSchedule.length - 1]).toISOString() : null, nextResetTokens, }; } function resolveTokenUsageLimitForPeriod( setting: | { maxTokensPer30Days: number; maxTokensPer7Days: number; maxTokensPer5Hours: number; } | null | undefined, periodKey: TokenUsagePeriodKey, ) { if (!setting) { return 0; } if (periodKey === '7d') { return setting.maxTokensPer7Days; } if (periodKey === '5h') { return setting.maxTokensPer5Hours; } return setting.maxTokensPer7Days; } function clampProgramMinimizedValue(value: number, min: number, max: number) { if (max < min) { return min; } return Math.min(Math.max(value, min), max); } function resetMobileDocumentScrollOffset() { if (typeof window === 'undefined' || typeof document === 'undefined') { return; } const documentElement = document.documentElement; const body = document.body; const visualViewport = window.visualViewport; const layoutScrollTop = Math.max(window.scrollY || 0, documentElement?.scrollTop || 0, body?.scrollTop || 0); const visualOffsetTop = Math.max(visualViewport?.offsetTop ?? 0, visualViewport?.pageTop ?? 0); if (layoutScrollTop <= 0 && visualOffsetTop <= 0) { return; } window.scrollTo(0, 0); if (documentElement) { documentElement.scrollTop = 0; } if (body) { body.scrollTop = 0; } } function isMobileShareInputTarget(target: EventTarget | null): target is HTMLElement { if (!(target instanceof HTMLElement)) { return false; } const inputTarget = target.closest('textarea, input, [contenteditable="true"]'); return Boolean(inputTarget) && isSnapshotDeferrableFocusTarget(inputTarget); } function getDefaultProgramMinimizedPosition() { if (typeof window === 'undefined') { return { x: PROGRAM_MINIMIZED_VIEWPORT_PADDING, y: PROGRAM_MINIMIZED_VIEWPORT_PADDING }; } return { x: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, window.innerWidth - PROGRAM_MINIMIZED_DEFAULT_WIDTH - PROGRAM_MINIMIZED_VIEWPORT_PADDING), y: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, window.innerHeight - PROGRAM_MINIMIZED_DEFAULT_HEIGHT - PROGRAM_MINIMIZED_VIEWPORT_PADDING), }; } function getStackedProgramMinimizedPosition(index: number) { const basePosition = getDefaultProgramMinimizedPosition(); const offsetX = 18 * Math.max(0, index); const offsetY = 14 * Math.max(0, index); return { x: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.x - offsetX), y: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.y - offsetY), }; } function getDefaultShareProcessInspectorPosition() { if (typeof window === 'undefined') { return { x: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, y: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING }; } const width = Math.min(SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH, window.innerWidth - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING * 2); const centeredX = Math.round((window.innerWidth - width) / 2); return { x: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, centeredX), y: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, 88), }; } function matchesSearchKeyword(keyword: string, ...values: Array) { if (!keyword) { return true; } return values.some((value) => normalizeSearchKeyword(value ?? '').includes(keyword)); } function buildSharePromptSelectionKey(parentRequestId: string, sourceMessageId: number, promptIndex: number, promptTitle: string, promptSignature: string) { return [parentRequestId.trim(), sourceMessageId, promptIndex, promptTitle.trim(), promptSignature.trim()].join('::'); } function extractShareMessageRenderPayload(message: ChatMessage): ShareMessageRenderPayload { const extracted = extractChatMessageParts(message.text); const promptParts = (message.parts ?? []).filter( (part): part is Extract => part.type === 'prompt', ); const strippedPromptText = extracted.strippedText || message.text; const diffBlocks = Array.from(strippedPromptText.matchAll(DIFF_CODE_BLOCK_PATTERN)) .map((match) => match[1]?.trim()) .filter((value): value is string => Boolean(value)); const visibleText = stripHiddenPreviewTags(strippedPromptText.replace(DIFF_CODE_BLOCK_PATTERN, '')).trim(); return { visibleText, promptParts: promptParts.length > 0 ? promptParts : extracted.parts.filter((part): part is Extract => part.type === 'prompt'), diffBlocks, previewItems: extractPreviewItems([message]), }; } function resolveRenderedMessage(message: ChatMessage): ShareRenderedMessage { return extractShareMessageRenderPayload(message); } function buildShareRequestMessagesById(messages: ChatMessage[]) { const nextMap = new Map(); messages.forEach((message) => { const requestId = message.clientRequestId?.trim() || ''; if (!requestId) { return; } const current = nextMap.get(requestId) ?? []; current.push(message); nextMap.set(requestId, current); }); return nextMap; } function resolveShareRoomPendingCounts(snapshot: Pick): ShareRoomPendingCounts { const sortedRequests = [...snapshot.requests].sort(compareShareConversationRequests); const sortedMessages = [...snapshot.messages].sort((left, right) => left.id - right.id); const messageRenderPayloadById = new Map( sortedMessages.map((message) => [message.id, extractShareMessageRenderPayload(message)] as const), ); const requestMessagesById = buildShareRequestMessagesById(sortedMessages); const childRequestCountByParentId = buildShareChildRequestCountMap(sortedRequests); const promptFollowupCountByParentId = buildSharePromptFollowupCountMap(sortedRequests); const pendingCompletionRequests = sortedRequests.filter((request) => isPendingCompletionShareRequest( request, requestMessagesById.get(request.requestId) ?? [], childRequestCountByParentId, promptFollowupCountByParentId, messageRenderPayloadById, )); const processingCount = pendingCompletionRequests.filter((request) => isRequestInFlight(request.status)).length; return { processingCount, unansweredCount: Math.max(0, pendingCompletionRequests.length - processingCount), }; } function buildVisibleMessageText(message: ChatMessage, payload?: ShareMessageRenderPayload) { return (payload ?? resolveRenderedMessage(message)).visibleText.trim(); } const SHARE_ATTACHMENT_ACCEPT = 'image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed'; function isCollapsibleText(value: string) { const normalized = value.trim(); if (!normalized) { return false; } if (normalized.length > COLLAPSIBLE_TEXT_MAX_LENGTH) { return true; } return normalized.split(/\r?\n/).length > COLLAPSIBLE_TEXT_MAX_LINES; } function isShareSendDelayError(error: unknown) { const message = error instanceof Error ? error.message : String(error ?? ''); return /지연|timeout|연결/i.test(message); } function buildRequestAnswerText( request: ChatConversationRequest, relatedMessages: ChatMessage[], messageRenderPayloadById?: Map, ) { const relatedSegments = relatedMessages .filter((message) => message.author !== 'user') .map((message) => buildVisibleMessageText(message, messageRenderPayloadById?.get(message.id))) .filter(Boolean); if (relatedSegments.length > 0) { return relatedSegments.join('\n\n').trim(); } return stripHiddenPreviewTags(String(request.responseText ?? '').replace(DIFF_CODE_BLOCK_PATTERN, '')).trim(); } function resolveShareRequestFallbackAnswerText(request: ChatConversationRequest) { if (request.status === 'queued') { return '요청 대기 등록 하였습니다.'; } if (request.status === 'accepted' || request.status === 'started') { return '요청 처리 중 입니다.'; } return request.statusMessage?.trim() || '아직 답변이 없습니다.'; } function replaceChatShareSnapshotRequest(snapshot: ChatShareSnapshot, nextRequest: ChatConversationRequest): ChatShareSnapshot { return { ...snapshot, targetRequest: snapshot.targetRequest.requestId === nextRequest.requestId ? nextRequest : snapshot.targetRequest, requests: snapshot.requests.map((request) => request.requestId === nextRequest.requestId ? nextRequest : request, ), }; } function buildShareVisibleText(text: string) { return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim(); } function resolvePromptParentQuestionText(request: ChatConversationRequest | null | undefined) { return request?.userText?.replace(/\s+/g, ' ').trim() || ''; } function resolveShareConversationParentRequest( request: ChatConversationRequest | null | undefined, requestById: ReadonlyMap, ) { const parentRequestId = request?.parentRequestId?.trim() || ''; return parentRequestId ? requestById.get(parentRequestId) ?? null : null; } function resolveShareRequestLineage( request: ChatConversationRequest | null | undefined, requestById: ReadonlyMap, ) { const directParentRequest = resolveShareConversationParentRequest(request, requestById); if (!directParentRequest) { return { directParentRequest: null, topParentRequest: null, }; } let currentRequest: ChatConversationRequest | null = directParentRequest; let topParentRequest: ChatConversationRequest | null = directParentRequest; const visitedRequestIds = new Set(); while (currentRequest) { const currentRequestId = currentRequest.requestId.trim(); if (!currentRequestId || visitedRequestIds.has(currentRequestId)) { break; } visitedRequestIds.add(currentRequestId); topParentRequest = currentRequest; currentRequest = resolveShareConversationParentRequest(currentRequest, requestById); } return { directParentRequest, topParentRequest, }; } function buildLinkedRoomDraftTitle(source: ChatConversationSummary) { const titleBase = source.title?.trim() || source.requestBadgeLabel?.trim() || source.contextLabel?.trim() || '연결 작업'; return `${titleBase} 작업방`; } function buildLinkedRoomDraftSeedMessage(source: ChatConversationSummary) { const preview = source.lastRequestPreview?.trim() || source.lastMessagePreview?.trim() || source.lastResponsePreview?.trim() || '원 세션 내용을 참고해 이어서 처리해 주세요.'; return `참조 세션을 기준으로 작업을 이어갑니다.\n\n원 요청 요약: ${preview}`; } function buildShareRoomSourceGroups( rooms: ChatShareRoomSummary[], conversationBySessionId: ReadonlyMap, ) { const groupMap = new Map(); rooms.forEach((room) => { const linkContext = room.linkContext?.kind === 'linked-session' ? room.linkContext : null; const linkedConversation = linkContext ? conversationBySessionId.get(linkContext.sourceSessionId) ?? null : null; const key = linkContext ? `linked:${linkContext.sourceSessionId}` : `room:${room.sessionId}`; const current = groupMap.get(key); const title = linkContext?.sourceTitle?.trim() || linkedConversation?.title?.trim() || room.title.trim() || '공유 채팅방'; const requestPreview = linkContext?.sourceRequestPreview?.trim() || linkedConversation?.lastRequestPreview?.trim() || linkedConversation?.lastMessagePreview?.trim() || ''; const chatTypeLabel = linkContext?.sourceChatTypeLabel?.trim() || linkedConversation?.contextLabel?.trim() || room.contextLabel?.trim() || ''; if (current) { current.rooms.push(room); return; } groupMap.set(key, { key, title, requestPreview, chatTypeLabel, sourceSessionId: linkContext?.sourceSessionId ?? null, sourceRequestId: linkContext?.sourceRequestId ?? null, linkContext, rooms: [room], }); }); return Array.from(groupMap.values()).map((group) => ({ ...group, rooms: [...group.rooms].sort((left, right) => { if (left.isDefault !== right.isDefault) { return left.isDefault ? -1 : 1; } if (left.sortOrder !== right.sortOrder) { return left.sortOrder - right.sortOrder; } return left.title.localeCompare(right.title, 'ko'); }), })); } function dedupeShareRooms(rooms: ChatShareRoomSummary[]) { const dedupedRooms: ChatShareRoomSummary[] = []; const knownSessionIds = new Set(); rooms.forEach((room) => { const normalizedSessionId = room.sessionId.trim(); if (!normalizedSessionId || knownSessionIds.has(normalizedSessionId)) { return; } knownSessionIds.add(normalizedSessionId); dedupedRooms.push(room); }); return dedupedRooms; } function buildSharePreviewItemsFromText(text: string, shareToken: string) { if (!shareToken) { return []; } return extractPreviewItems([ { id: -1, author: 'user', text, timestamp: '', }, ]).map((item) => ({ ...item, url: resolveShareScopedResourceUrl(item.url, shareToken), })); } function resolveShareScopedResourceUrl(url: string, shareToken: string) { const normalizedShareToken = shareToken.trim(); const normalizedUrl = normalizeChatResourceUrl(String(url ?? '').trim()); const storedSharePin = getStoredChatShareAccessPin(normalizedShareToken); if (!normalizedShareToken || !normalizedUrl || typeof window === 'undefined') { return normalizedUrl; } try { const resolvedUrl = new URL(normalizedUrl, window.location.origin); let relativePath = ''; const searchParams = new URLSearchParams(resolvedUrl.search); searchParams.delete('token'); searchParams.delete('clientId'); searchParams.delete('sharePin'); if (resolvedUrl.pathname.startsWith(CHAT_API_RESOURCE_ROUTE_PREFIX)) { relativePath = resolvedUrl.pathname.slice(CHAT_API_RESOURCE_ROUTE_PREFIX.length); } else if (resolvedUrl.pathname.startsWith(CHAT_PUBLIC_DOT_CODEX_PREFIX)) { relativePath = resolvedUrl.pathname.slice(CHAT_PUBLIC_DOT_CODEX_PREFIX.length); } else if (resolvedUrl.pathname.startsWith(CHAT_PUBLIC_ROUTE_PREFIX)) { relativePath = resolvedUrl.pathname.slice(CHAT_PUBLIC_ROUTE_PREFIX.length); } else if (resolvedUrl.pathname.startsWith(RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX)) { const resourceRelativePath = decodeURIComponent( resolvedUrl.pathname.slice(RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX.length), ).replace(/^\/+/, ''); if (!resourceRelativePath) { return normalizedUrl; } relativePath = `${RESOURCE_MANAGER_ROOT_PREFIX}${resourceRelativePath}`; } if (!relativePath) { return normalizedUrl; } const encodedPath = relativePath .split('/') .filter(Boolean) .map((segment) => encodeURIComponent(decodeURIComponent(segment))) .join('/'); if (storedSharePin) { searchParams.set('sharePin', storedSharePin); } const nextSearch = searchParams.toString(); return `${window.location.origin}/api/chat/shares/${encodeURIComponent(normalizedShareToken)}/resources/${encodedPath}${nextSearch ? `?${nextSearch}` : ''}${resolvedUrl.hash || ''}`; } catch { return normalizedUrl; } } function rewritePromptPartForShare( prompt: Extract, shareToken: string, ): Extract { const rewritePreview = (option: T): T => ({ ...option, preview: option.preview ? { ...option.preview, url: option.preview.url ? resolveShareScopedResourceUrl(option.preview.url, shareToken) : option.preview.url ?? null, } : option.preview ?? null, }); return { ...prompt, options: prompt.options.map((option) => rewritePreview(option)), steps: prompt.steps?.map((step) => ({ ...step, options: step.options.map((option) => rewritePreview(option)), })), }; } function isRequestProcessing(status: ChatConversationRequest['status']) { return status === 'accepted' || status === 'started'; } function isRequestInFlight(status: ChatConversationRequest['status']) { return status === 'accepted' || status === 'queued' || status === 'started'; } function isDisconnectedShareRequestNeedingAttention(request: ChatConversationRequest) { if (request.hasResponse) { return false; } if (request.status !== 'failed') { return false; } const statusMessage = request.statusMessage?.trim() ?? ''; return statusMessage === '중단된 오래된 요청'; } function resolveRequestStatusTag(status: ChatConversationRequest['status']) { switch (status) { case 'queued': return { label: '대기열', color: 'default' as const }; case 'accepted': case 'started': return { label: '처리중', color: 'processing' as const }; case 'completed': return { label: '완료', color: 'success' as const }; case 'failed': return { label: '실패', color: 'error' as const }; case 'cancelled': return { label: '취소', color: 'warning' as const }; case 'removed': return { label: '삭제', color: 'default' as const }; default: return { label: status, color: 'default' as const }; } } function isRetriedRequest(request: ChatConversationRequest | null | undefined) { return Math.max(0, Number(request?.retryCount ?? 0) || 0) > 0; } function hasUnresolvedPromptPart(message: ChatMessage) { const promptParts = (message.parts ?? []).filter( (part): part is Extract => part.type === 'prompt', ); return promptParts.some((part) => !isPromptResolved(part)); } function countUnresolvedPromptParts( promptParts: Extract[], ) { return promptParts.filter((part) => !isPromptResolved(part)).length; } function buildSharePromptSearchText(prompt: Extract) { return [ prompt.title, prompt.description, ...prompt.options.map((option) => `${option.label} ${option.description ?? ''}`.trim()), ...(prompt.steps?.flatMap((step) => [ step.title, step.description ?? '', ...step.options.map((option) => `${option.label} ${option.description ?? ''}`.trim()), ]) ?? []), ] .map((item) => item.trim()) .filter(Boolean) .join('\n'); } function hasPendingPromptRequest( request: ChatConversationRequest, relatedMessages: ChatMessage[], promptSubmittedCount = 0, messageRenderPayloadById?: Map, ) { if (request.manualPromptCompletedAt) { return false; } let unresolvedPromptCount = 0; relatedMessages.forEach((message) => { if (message.author !== 'codex' && message.author !== 'system') { return; } const promptParts = messageRenderPayloadById?.get(message.id)?.promptParts; const unresolvedCount = (promptParts ?? (message.parts ?? []).filter( (part): part is Extract => part.type === 'prompt', )).filter((part) => !isPromptResolved(part)).length; unresolvedPromptCount += unresolvedCount; }); if (unresolvedPromptCount === 0) { return false; } return unresolvedPromptCount > Math.max(0, promptSubmittedCount); } function hasCodexVisibleResponseMessage(message: ChatMessage, payload?: ShareMessageRenderPayload) { if (message.author !== 'codex' && message.author !== 'system') { return false; } const resolvedPayload = payload ?? resolveRenderedMessage(message); const { visibleText, promptParts, diffBlocks, previewItems } = resolvedPayload; return Boolean( promptParts.length > 0 || previewItems.length > 0 || diffBlocks.length > 0 || visibleText.trim(), ); } function hasAnsweredShareRequest( request: ChatConversationRequest, relatedMessages: ChatMessage[], messageRenderPayloadById?: Map, ) { if (request.hasResponse) { return true; } return relatedMessages.some((message) => hasCodexVisibleResponseMessage(message, messageRenderPayloadById?.get(message.id))); } function hasPendingAttentionVerificationTarget(text: string | null | undefined) { const normalized = String(text ?? '').trim(); if (!normalized) { return false; } if (normalized.length > 720) { return true; } return /```diff[\s\S]*?```|\[\[preview:|\[\[link-card:|\[\[prompt:/i.test(normalized); } function hasVerificationTargetShareRequest( request: ChatConversationRequest, relatedMessages: ChatMessage[], ) { if (request.responseMessageId != null) { return true; } if (String(request.responseText ?? '').trim().length > 0) { return true; } if (request.status === 'completed' && request.requestOrigin === 'composer') { return relatedMessages.some( (message) => (message.author === 'codex' || message.author === 'system') && hasPendingAttentionVerificationTarget(message.text), ); } return false; } function buildShareChildRequestCountMap(requests: ChatConversationRequest[]) { const nextMap = new Map(); requests.forEach((request) => { const parentRequestId = request.parentRequestId?.trim() || ''; const requestId = request.requestId.trim(); if (!parentRequestId || parentRequestId === requestId) { return; } nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1); }); return nextMap; } function buildSharePromptFollowupCountMap(requests: ChatConversationRequest[]) { const nextMap = new Map(); requests.forEach((request) => { if (request.requestOrigin !== 'prompt') { return; } const parentRequestId = request.parentRequestId?.trim() || ''; if (!parentRequestId) { return; } nextMap.set(parentRequestId, (nextMap.get(parentRequestId) ?? 0) + 1); }); return nextMap; } function isShareReplyFollowupRequest(request: ChatConversationRequest) { return request.requestOrigin === 'composer' && Boolean(request.parentRequestId?.trim()); } function isPromptFollowupShareRequest(request: ChatConversationRequest) { return request.requestOrigin === 'prompt'; } function hasShareChildFollowupRequest( request: ChatConversationRequest, childRequestCountByParentId?: Map, ) { return (childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0; } function hasShareManualCompletion(request: ChatConversationRequest) { return Boolean(request.manualPromptCompletedAt || request.manualVerificationCompletedAt); } function isCompletedHandledShareRequest( request: ChatConversationRequest, childRequestCountByParentId?: Map, ) { if (hasShareManualCompletion(request)) { return true; } return hasShareChildFollowupRequest(request, childRequestCountByParentId); } function resolveSharePrimaryRequestId( request: ChatConversationRequest | null | undefined, requestById: ReadonlyMap, ) { let currentRequest = request ?? null; const visitedRequestIds = new Set(); while (currentRequest) { const currentRequestId = currentRequest.requestId.trim(); if (!currentRequestId || visitedRequestIds.has(currentRequestId)) { break; } visitedRequestIds.add(currentRequestId); if (currentRequest.requestOrigin !== 'prompt') { return currentRequestId; } const parentRequestId = currentRequest.parentRequestId?.trim() || ''; if (!parentRequestId) { return currentRequestId; } currentRequest = requestById.get(parentRequestId) ?? null; } return request?.requestId?.trim() || ''; } function isUnreadAnsweredShareRequest( request: ChatConversationRequest, relatedMessages: ChatMessage[], childRequestCountByParentId?: Map, promptFollowupCountByParentId?: Map, messageRenderPayloadById?: Map, ) { if (isRequestInFlight(request.status)) { return false; } if ( hasPendingPromptRequest( request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0, messageRenderPayloadById, ) ) { return false; } if (request.manualPromptCompletedAt) { return false; } if (hasShareChildFollowupRequest(request, childRequestCountByParentId)) { return false; } if (request.manualVerificationCompletedAt) { return false; } if (!hasVerificationTargetShareRequest(request, relatedMessages)) { return false; } return hasAnsweredShareRequest(request, relatedMessages, messageRenderPayloadById); } function isPendingCompletionShareRequest( request: ChatConversationRequest, relatedMessages: ChatMessage[], childRequestCountByParentId?: Map, promptFollowupCountByParentId?: Map, messageRenderPayloadById?: Map, ) { if (isCompletedHandledShareRequest(request, childRequestCountByParentId)) { return false; } if (isRequestInFlight(request.status)) { return true; } if (request.status === 'failed') { return true; } if ( hasPendingPromptRequest( request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0, messageRenderPayloadById, ) ) { return true; } if (isDisconnectedShareRequestNeedingAttention(request)) { return true; } if (request.status === 'cancelled' || request.status === 'removed') { return false; } if (hasVerificationTargetShareRequest(request, relatedMessages)) { return hasAnsweredShareRequest(request, relatedMessages, messageRenderPayloadById); } return request.status === 'completed'; } function isShareRequestAwaitingManualAction( request: ChatConversationRequest, relatedMessages: ChatMessage[], childRequestCountByParentId?: Map, promptFollowupCountByParentId?: Map, messageRenderPayloadById?: Map, ) { if (isCompletedHandledShareRequest(request, childRequestCountByParentId)) { return false; } if (request.status === 'failed') { return true; } if ( hasPendingPromptRequest( request, relatedMessages, promptFollowupCountByParentId?.get(request.requestId.trim()) ?? 0, messageRenderPayloadById, ) ) { return true; } if (isDisconnectedShareRequestNeedingAttention(request)) { return true; } if (isRequestInFlight(request.status)) { return false; } if (request.status === 'cancelled' || request.status === 'removed') { return false; } if (hasVerificationTargetShareRequest(request, relatedMessages)) { return hasAnsweredShareRequest(request, relatedMessages, messageRenderPayloadById); } return request.status === 'completed'; } type SharePromptFollowupState = { hasInFlight: boolean; hasPendingCompletion: boolean; hasManualAction: boolean; latestChildRequest: ChatConversationRequest; }; function buildSharePromptFollowupStateByPrimaryRequestId( requests: ChatConversationRequest[], requestById: ReadonlyMap, requestMessagesById: Map, promptFollowupCountByParentId?: Map, messageRenderPayloadById?: Map, ) { const nextMap = new Map(); requests.forEach((request) => { if (!isPromptFollowupShareRequest(request)) { return; } const primaryRequestId = resolveSharePrimaryRequestId(request, requestById); if (!primaryRequestId || primaryRequestId === request.requestId.trim()) { return; } const relatedMessages = requestMessagesById.get(request.requestId) ?? []; const hasInFlight = isRequestInFlight(request.status); const hasPendingCompletion = isPendingCompletionShareRequest( request, relatedMessages, undefined, promptFollowupCountByParentId, messageRenderPayloadById, ); const hasManualAction = isShareRequestAwaitingManualAction( request, relatedMessages, undefined, promptFollowupCountByParentId, messageRenderPayloadById, ); if (!hasInFlight && !hasPendingCompletion && !hasManualAction) { return; } const previous = nextMap.get(primaryRequestId); const currentUpdatedAt = request.updatedAt?.trim() || request.createdAt; const previousUpdatedAt = previous?.latestChildRequest.updatedAt?.trim() || previous?.latestChildRequest.createdAt || ''; nextMap.set(primaryRequestId, { hasInFlight: hasInFlight || previous?.hasInFlight === true, hasPendingCompletion: hasPendingCompletion || previous?.hasPendingCompletion === true, hasManualAction: hasManualAction || previous?.hasManualAction === true, latestChildRequest: !previous || currentUpdatedAt.localeCompare(previousUpdatedAt) >= 0 ? request : previous.latestChildRequest, }); }); return nextMap; } function summarizeShareReplyReferenceText(text: string, maxLength = 96) { const normalized = stripHiddenPreviewTags(text).replace(/\s+/gu, ' ').trim(); if (normalized.length <= maxLength) { return normalized; } return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`; } function resolveShareRequestQuestionText(request: ChatConversationRequest | null | undefined) { if (!request) { return ''; } return buildShareVisibleText(request.userText); } function summarizeActivityLogLines(lines: string[]) { const normalizedLines = lines.map((line) => line.trim()).filter(Boolean); if (normalizedLines.length === 0) { return [] as string[]; } const summaries: string[] = []; const pushUnique = (label: string) => { if (!summaries.includes(label)) { summaries.push(label); } }; normalizedLines.forEach((line) => { const lower = line.toLowerCase(); if (/(read|search|inspect|context|analysis|analy|조사|확인|정리|검토|조회)/.test(lower)) { pushUnique('요청 내용을 확인하고 필요한 정보를 정리하고 있어요.'); } if (/(edit|patch|write|implement|fix|update|modify|반영|수정|작성)/.test(lower)) { pushUnique('답변에 반영할 내용을 만들고 있어요.'); } if (/(test|build|verify|check|validate|검증|점검|실행)/.test(lower)) { pushUnique('결과를 점검하고 있어요.'); } if (/(file|resource|upload|preview|첨부|파일)/.test(lower)) { pushUnique('참고 자료와 첨부 내용을 함께 살펴보고 있어요.'); } }); if (summaries.length > 0) { return summaries.slice(0, 3); } const latestLine = normalizedLines[normalizedLines.length - 1] ?.replace(/[`*_>#-]+/g, ' ') .replace(/\s+/g, ' ') .trim(); return latestLine ? [`현재 작업을 진행하고 있어요. ${latestLine}`] : ['현재 작업을 진행하고 있어요.']; } function formatElapsedDuration(startedAt: string | null | undefined, nowMs: number) { const normalized = startedAt?.trim(); if (!normalized) { return ''; } const startedMs = new Date(normalized).getTime(); if (Number.isNaN(startedMs)) { return ''; } const diffSeconds = Math.max(0, Math.floor((nowMs - startedMs) / 1000)); const hours = Math.floor(diffSeconds / 3600); const minutes = Math.floor((diffSeconds % 3600) / 60); const seconds = diffSeconds % 60; if (hours > 0) { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } function formatShareRuntimeTimestamp(value: string | null | undefined) { const normalized = value?.trim(); if (!normalized) { return '-'; } const parsed = new Date(normalized); if (Number.isNaN(parsed.getTime())) { return '-'; } return parsed.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }); } function resolveShareProcessRequestStatusTag(request: ChatConversationRequest) { switch (request.status) { case 'accepted': return { color: 'blue', label: '접수됨' } as const; case 'queued': return { color: 'default', label: '대기중' } as const; case 'started': return { color: 'processing', label: '처리중' } as const; case 'completed': return { color: 'green', label: '완료' } as const; case 'failed': return { color: 'red', label: '실패' } as const; case 'cancelled': return { color: 'gold', label: '취소됨' } as const; case 'removed': default: return { color: 'default', label: '대기취소' } as const; } } function resolveShareRuntimeTerminalTag(terminalStatus: ChatRuntimeTerminalStatus) { switch (terminalStatus) { case 'cancelled': return { color: 'gold', label: '취소됨' } as const; case 'removed': return { color: 'default', label: '대기취소' } as const; case 'failed': return { color: 'red', label: '실패' } as const; case 'completed': default: return { color: 'green', label: '완료' } as const; } } function resolveShareRuntimeStatusTag(item: ChatRuntimeJobItem) { if (item.status === 'running') { return { color: 'processing', label: '실행중' } as const; } return { color: 'default', label: '대기중' } as const; } function matchesShareProcessKeywords(lines: string[], pattern: RegExp) { return lines.some((line) => pattern.test(line.toLowerCase())); } function resolveShareProcessChecklistStepStatus( request: ChatConversationRequest, enabled: boolean, completed: boolean, ): ShareProcessChecklistStep['status'] { if (completed) { return 'completed'; } if (enabled || request.status === 'started') { return 'in_progress'; } return 'pending'; } function buildShareProcessInspectorPayload( request: ChatConversationRequest, activityLines: string[], nowMs: number, runtimeItem?: ChatRuntimeJobItem | (ChatRuntimeJobItem & { terminalStatus?: ChatRuntimeTerminalStatus; lastUpdatedAt?: string }) | null, ): ShareProcessInspectorPayload { const normalizedLines = activityLines.map((line) => line.trim()).filter(Boolean); const startedAt = runtimeItem?.startedAt ?? runtimeItem?.enqueuedAt ?? request.updatedAt?.trim() ?? request.createdAt; const updatedAt = 'lastUpdatedAt' in (runtimeItem ?? {}) ? runtimeItem?.lastUpdatedAt ?? request.updatedAt : request.updatedAt; const latestActivityLine = normalizedLines[normalizedLines.length - 1] ?? ''; const hasAnalysis = matchesShareProcessKeywords(normalizedLines, /(read|search|inspect|context|analysis|analy|조사|확인|정리|검토|조회)/); const hasImplementation = matchesShareProcessKeywords(normalizedLines, /(edit|patch|write|implement|fix|update|modify|반영|수정|작성)/); const hasVerification = matchesShareProcessKeywords(normalizedLines, /(test|build|verify|check|validate|검증|점검|실행)/); const isTerminal = ['completed', 'failed', 'cancelled', 'removed'].includes(request.status); const answerSummary = summarizeShareReplyReferenceText( request.responseText || request.statusMessage || latestActivityLine || '아직 기록된 응답이 없습니다.', 120, ); const summary = summarizeShareReplyReferenceText(request.userText || runtimeItem?.summary || '요약 정보 없음', 140); const analysisStatus = resolveShareProcessChecklistStepStatus(request, hasAnalysis, hasAnalysis && (hasImplementation || hasVerification || isTerminal)); const implementationStatus = resolveShareProcessChecklistStepStatus( request, hasImplementation, (hasImplementation && (hasVerification || isTerminal)) || request.hasResponse, ); const verificationStatus = resolveShareProcessChecklistStepStatus( request, hasVerification || request.hasResponse, request.status === 'completed' || request.hasResponse, ); const currentStepLabel = verificationStatus === 'in_progress' ? '검증/정리' : implementationStatus === 'in_progress' ? '응답 작성' : analysisStatus === 'in_progress' ? '요청 분석' : request.status === 'queued' ? '대기' : request.status === 'completed' ? '완료' : resolveShareProcessRequestStatusTag(request).label; return { requestId: request.requestId, statusTag: resolveShareProcessRequestStatusTag(request), summary, elapsedLabel: formatElapsedDuration(startedAt, nowMs) || '-', startedAtLabel: formatShareRuntimeTimestamp(startedAt), updatedAtLabel: formatShareRuntimeTimestamp(updatedAt), activityLines: normalizedLines, latestActivityLine, checklist: [ { key: 'accepted', label: '요청 접수', status: 'completed', note: `${formatShareRuntimeTimestamp(request.createdAt)} 접수`, }, { key: 'analysis', label: '계획·문맥 확인', status: analysisStatus, note: hasAnalysis ? '질문과 문맥, 관련 리소스를 확인 중입니다.' : '활동 로그 대기', }, { key: 'implementation', label: '실행·응답 작성', status: implementationStatus, note: hasImplementation ? '수정/실행/응답 초안을 진행 중입니다.' : '아직 실행 단계에 도달하지 않았습니다.', }, { key: 'verification', label: '검증·결과 정리', status: verificationStatus, note: request.status === 'completed' ? '최종 응답 또는 결과 정리가 끝났습니다.' : hasVerification ? '검증과 결과 정리를 진행 중입니다.' : '검증 단계 대기', }, ], narratives: [ `현재 단계는 ${currentStepLabel}입니다.`, latestActivityLine ? `최근 실행 설명: ${latestActivityLine}` : '최근 실행 설명이 아직 기록되지 않았습니다.', request.hasResponse ? `현재 응답 요약: ${answerSummary}` : '아직 최종 응답은 기록되지 않았습니다.', ], }; } async function createSharePreviewFetchError(response: Response): Promise { const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; let responseMessage = ''; try { if (contentType.includes('application/json')) { const payload = (await response.json()) as { message?: string }; responseMessage = String(payload.message ?? '').trim(); } else { responseMessage = (await response.text()).trim(); } } catch { responseMessage = ''; } const statusLabel = response.status === 403 ? '이 문서는 현재 권한으로 열 수 없습니다.' : response.status === 404 ? '이 문서를 찾을 수 없습니다.' : response.status === 401 ? '이 문서를 열기 위한 인증이 필요합니다.' : `preview 요청이 실패했습니다. (${response.status})`; const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim(); const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as SharePreviewFetchError; error.status = response.status; return error; } function ExpandableMessageText({ text, className, collapsedLabel = '더보기', expandedLabel = '접기', onExpand, }: { text: string; className?: string; collapsedLabel?: string; expandedLabel?: string; onExpand?: (() => void) | null; }) { const normalizedText = text.trim(); const [isExpanded, setIsExpanded] = useState(false); const canCollapse = useMemo(() => isCollapsibleText(normalizedText), [normalizedText]); useEffect(() => { setIsExpanded(false); }, [normalizedText]); if (!normalizedText) { return null; } return (
{normalizedText} {canCollapse ? ( ) : null}
); } function ShareMessageTextBlock({ tone, label, text, timestamp, actions, }: { tone: 'question' | 'answer'; label: string; text: string; timestamp?: string | null; actions?: ReactNode; }) { return (
{label} {timestamp ? {timestamp} : null}
{actions ?
{actions}
: null}
); } function ShareResponseBlock({ message, requestById, fallbackRequestId, emphasisLabel, onSubmitPrompt, promptSelections, onPromptSelectionChange, onPromptSubmitted, onCompletePrompt, onCompleteVerification, isPromptCompletionSaving, isPromptManualCompleted, isVerificationCompletionSaving, isVerificationCompleted, hasChildRequest, activeReplyRequestId, onReplyToResponse, shareToken, onOpenProgram, canUploadAttachments = false, onUploadAttachment, onSetResponseAnchor, onSetPromptAnchor, onCopyMessage, }: { message: ChatMessage; requestById: Map; fallbackRequestId?: string; emphasisLabel?: string; onSubmitPrompt: (payload: PromptSubmitPayload & { parentRequestId: string; promptIndex: number; sourceMessageId: number }) => Promise; promptSelections: Record; onPromptSelectionChange: (selectionKey: string, selection: PromptDraftSelection | null) => void; onPromptSubmitted: (selectionKey: string, selection: PromptDraftSelection) => void; onCompletePrompt?: ((parentRequestId: string) => Promise) | null; onCompleteVerification?: ((parentRequestId: string) => Promise) | null; isPromptCompletionSaving?: boolean; isPromptManualCompleted?: boolean; isVerificationCompletionSaving?: boolean; isVerificationCompleted?: boolean; hasChildRequest?: boolean; activeReplyRequestId?: string | null; onReplyToResponse?: ((parentRequestId: string) => void) | null; shareToken?: string; onOpenProgram?: ((target: ShareProgramTarget) => void) | null; canUploadAttachments?: boolean; onUploadAttachment?: ((file: File) => Promise) | null; onSetResponseAnchor?: ((messageId: number, element: HTMLDivElement | null) => void) | null; onSetPromptAnchor?: ((messageId: number, promptIndex: number, element: HTMLDivElement | null) => void) | null; onCopyMessage?: ((text: string, label: string) => void) | null; }) { const { visibleText, promptParts: rawPromptParts, diffBlocks, previewItems: rawPreviewItems } = useMemo( () => extractShareMessageRenderPayload(message), [message], ); const promptParts = useMemo( () => rawPromptParts.map((part) => (shareToken ? rewritePromptPartForShare(part, shareToken) : part)), [rawPromptParts, shareToken], ); const previewItems = useMemo( () => shareToken ? rawPreviewItems.map((item) => ({ ...item, url: resolveShareScopedResourceUrl(item.url, shareToken), })) : [], [rawPreviewItems, shareToken], ); const parentRequestId = message.clientRequestId?.trim() || fallbackRequestId?.trim() || ''; const parentRequest = parentRequestId ? requestById.get(parentRequestId) ?? null : null; const isParentRequestInFlight = parentRequest ? isRequestInFlight(parentRequest.status) : false; const parentQuestionText = useMemo( () => resolvePromptParentQuestionText(parentRequestId ? requestById.get(parentRequestId) ?? null : null), [parentRequestId, requestById], ); const canCompletePrompt = countUnresolvedPromptParts(promptParts) > 0 && !isPromptManualCompleted && !isParentRequestInFlight; const hasOpenPromptInResponse = countUnresolvedPromptParts(promptParts) > 0; const canCompleteVerification = !hasOpenPromptInResponse && Boolean(parentRequestId) && Boolean(onCompleteVerification) && !hasChildRequest && !isVerificationCompleted && !isParentRequestInFlight; const canReplyToResponse = !hasOpenPromptInResponse && Boolean(parentRequestId) && Boolean(onReplyToResponse) && !isParentRequestInFlight; const isReplyTargetActive = canReplyToResponse && activeReplyRequestId?.trim() === parentRequestId; const answerActions = ( <> {canCompletePrompt && parentRequestId && onCompletePrompt ? ( ) : (
{activeProcessInspectorPayload.statusTag.label} {`처리 시간 ${activeProcessInspectorPayload.elapsedLabel}`}
{isProcessInspectorSummaryCollapsed ? null : (
요청 {activeProcessInspectorPayload.summary}
ID {activeProcessInspectorPayload.requestId}
시작 {activeProcessInspectorPayload.startedAtLabel}
업데이트 {activeProcessInspectorPayload.updatedAtLabel}
최근 로그 {activeProcessInspectorPayload.latestActivityLine || '아직 활동 로그가 없습니다.'}
)}
계획 체크리스트
{activeProcessInspectorPayload.checklist.map((step) => (
{step.label} {step.status === 'completed' ? '완료' : step.status === 'in_progress' ? '진행중' : '대기'} {step.note}
))}
추가 실행 설명
{isProcessInspectorNarrativesCollapsed ? null : (
{activeProcessInspectorPayload.narratives.map((item, index) => (
{String(index + 1).padStart(2, '0')} {item}
))}
)}
활동 로그 {activeProcessInspectorPayload.activityLines.length}줄
{activeProcessInspectorPayload.activityLines.length > 0 ? ( activeProcessInspectorPayload.activityLines.map((line, index) => (
{String(index + 1).padStart(2, '0')} {line}
)) ) : ( 활동 로그가 아직 기록되지 않았습니다. )}
)} ) : null; const shareChatTypeLabel = useMemo(() => { const candidates = [ currentRequest?.chatTypeLabel, snapshot?.targetRequest?.chatTypeLabel, ...sortedRequests.map((request) => request.chatTypeLabel), ]; return candidates.find((value) => value?.trim())?.trim() || 'Codex Live'; }, [currentRequest?.chatTypeLabel, snapshot?.targetRequest?.chatTypeLabel, sortedRequests]); const shareMenuLabel = useMemo(() => { const candidates = [ snapshot?.conversation.requestBadgeLabel, snapshot?.conversation.title, headerInquiryRequest?.chatTypeLabel, currentRequest?.chatTypeLabel, ]; return candidates.find((value) => value?.trim())?.trim() || ''; }, [currentRequest?.chatTypeLabel, headerInquiryRequest?.chatTypeLabel, snapshot?.conversation.requestBadgeLabel, snapshot?.conversation.title]); const collapsedActivitySummary = useMemo(() => { if (expandMode !== 'latest' || !currentRequest || !isRequestProcessing(currentRequest.status)) { return [] as string[]; } const activityLog = activityLogByRequestId.get(currentRequest.requestId.trim()); return summarizeActivityLogLines(activityLog?.lines ?? []); }, [activityLogByRequestId, currentRequest, expandMode]); const headerSummaryLabel = useMemo( () => `처리 시간 ${aggregateStatusTag?.elapsedLabel || '-'} · 처리중 ${pendingProcessingCount}건 · 미확인 ${pendingUnansweredCount}건`, [aggregateStatusTag?.elapsedLabel, pendingProcessingCount, pendingUnansweredCount], ); useEffect(() => { const sessionId = selectedShareRoomSessionId.trim(); if (!sessionId) { return; } setShareRoomPendingCountsBySessionId((current) => { const previousCounts = current[sessionId]; if ( previousCounts?.processingCount === pendingProcessingCount && previousCounts?.unansweredCount === pendingUnansweredCount ) { return current; } return { ...current, [sessionId]: { processingCount: pendingProcessingCount, unansweredCount: pendingUnansweredCount, }, }; }); }, [pendingProcessingCount, pendingUnansweredCount, selectedShareRoomSessionId]); const refreshShareRoomPendingCount = useCallback(async (sessionId: string) => { const normalizedSessionId = sessionId.trim(); if (!normalizedToken || !normalizedSessionId) { return; } const inFlightTask = shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId]; if (inFlightTask) { shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = true; await inFlightTask; return; } const sharePin = getStoredChatShareAccessPin(normalizedToken) || undefined; const refreshTask = (async () => { try { const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, { sessionId: normalizedSessionId, sharePin, }); setShareRoomPendingCountsBySessionId((current) => { const nextCounts = resolveShareRoomPendingCounts(roomSnapshot); const previousCounts = current[normalizedSessionId]; if ( previousCounts?.processingCount === nextCounts.processingCount && previousCounts?.unansweredCount === nextCounts.unansweredCount ) { return current; } return { ...current, [normalizedSessionId]: nextCounts, }; }); } catch (error) { console.error('failed to refresh share room pending counts', normalizedSessionId, error); } finally { shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = null; if (shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId]) { shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = false; window.setTimeout(() => { void refreshShareRoomPendingCount(normalizedSessionId); }, 0); } } })(); shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = refreshTask; await refreshTask; }, [normalizedToken]); useEffect(() => { if (!normalizedToken || !shareRoomListFetchKey) { return; } const targetRoomSessionIds = visibleBackgroundShareRoomSessionIds; if (targetRoomSessionIds.length === 0) { setIsLoadingShareRoomPendingCounts(false); return; } const fetchSequence = shareRoomPendingCountFetchSequenceRef.current + 1; shareRoomPendingCountFetchSequenceRef.current = fetchSequence; let cancelled = false; setIsLoadingShareRoomPendingCounts(true); void Promise.allSettled( targetRoomSessionIds.map(async (sessionId) => { await refreshShareRoomPendingCount(sessionId); }), ) .finally(() => { if (cancelled || shareRoomPendingCountFetchSequenceRef.current !== fetchSequence) { return; } setIsLoadingShareRoomPendingCounts(false); }); return () => { cancelled = true; }; }, [normalizedToken, refreshShareRoomPendingCount, shareRoomListFetchKey, visibleBackgroundShareRoomSessionIds]); useEffect(() => { if (!normalizedToken || requiresAccessPin || typeof window === 'undefined' || visibleBackgroundShareRoomSessionIds.length === 0) { return undefined; } let isDisposed = false; const sockets = new Map(); const reconnectTimerIds = new Map(); const refreshTimerIds = new Map(); const clearRefreshTimer = (sessionId: string) => { const timerId = refreshTimerIds.get(sessionId); if (timerId != null) { window.clearTimeout(timerId); refreshTimerIds.delete(sessionId); } }; const scheduleRoomCountRefresh = (sessionId: string) => { if (isDisposed || refreshTimerIds.has(sessionId)) { return; } const timerId = window.setTimeout(() => { refreshTimerIds.delete(sessionId); void refreshShareRoomPendingCount(sessionId); }, 150); refreshTimerIds.set(sessionId, timerId); }; const connectRoomSocket = (sessionId: string) => { if (isDisposed) { return; } const websocketUrl = resolveChatWebSocketUrl(sessionId, undefined, undefined, normalizedToken); if (!websocketUrl) { return; } const socket = new WebSocket(websocketUrl); sockets.set(sessionId, socket); socket.addEventListener('message', (event) => { if (isDisposed || typeof event.data !== 'string') { return; } try { const payload = JSON.parse(event.data) as ChatServerEvent; if (payload.sessionId !== sessionId) { return; } if ( payload.type === 'chat:init' || payload.type === 'chat:status' || payload.type === 'chat:runtime' || payload.type === 'chat:runtime:detail' || payload.type === 'notification:messages-updated' ) { return; } } catch { // payload 해석 실패 시에도 해당 방 건수만 다시 확인한다. } scheduleRoomCountRefresh(sessionId); }); socket.addEventListener('error', () => { if (isDisposed) { return; } socket.close(); }); socket.addEventListener('close', () => { sockets.delete(sessionId); clearRefreshTimer(sessionId); if (isDisposed) { return; } const reconnectTimerId = window.setTimeout(() => { reconnectTimerIds.delete(sessionId); connectRoomSocket(sessionId); }, 1500); reconnectTimerIds.set(sessionId, reconnectTimerId); }); }; visibleBackgroundShareRoomSessionIds.forEach((sessionId) => { connectRoomSocket(sessionId); }); return () => { isDisposed = true; reconnectTimerIds.forEach((timerId) => { window.clearTimeout(timerId); }); refreshTimerIds.forEach((timerId) => { window.clearTimeout(timerId); }); sockets.forEach((socket) => { socket.close(); }); }; }, [normalizedToken, refreshShareRoomPendingCount, requiresAccessPin, visibleBackgroundShareRoomSessionIds]); useEffect(() => { if (!activeProcessInspectorRequestId.trim()) { return; } if (activeProcessInspectorRequest) { return; } setActiveProcessInspectorRequestId(''); }, [activeProcessInspectorRequest, activeProcessInspectorRequestId]); useEffect(() => { if (!replyReferenceRequestId.trim()) { return; } if (requestById.has(replyReferenceRequestId.trim())) { return; } setReplyReferenceRequestId(''); }, [replyReferenceRequestId, requestById]); useEffect(() => { if (sortedRequests.length === 0) { if (latestRequestId) { setLatestRequestId(''); } return; } const nextLatestRequest = sortedRequests[sortedRequests.length - 1]; if (!nextLatestRequest) { return; } if (latestRequestId && sortedRequests.some((request) => request.requestId === latestRequestId)) { return; } if (latestRequestId !== nextLatestRequest.requestId) { setLatestRequestId(nextLatestRequest.requestId); } }, [latestRequestId, sortedRequests]); const handleMoveToPreviousRequest = () => { if (!canMoveToPreviousRequest) { return; } const previousRequest = sortedRequests[latestRequestIndex - 1]; if (previousRequest) { setLatestRequestId(previousRequest.requestId); } }; const handleMoveToNextRequest = () => { if (!canMoveToNextRequest) { return; } const nextRequest = sortedRequests[latestRequestIndex + 1]; if (nextRequest) { setLatestRequestId(nextRequest.requestId); } }; const hasActiveProcessingRequest = useMemo( () => displayedRequests.some((request) => isRequestProcessing(request.status)), [displayedRequests], ); const contentLayoutClassName = canSendMessage ? 'chat-share-page__content-layout chat-share-page__content-layout--with-composer' : 'chat-share-page__content-layout'; const canToggleShareRoomList = shareRooms.length > 1 || canCreateSharedRooms; const captureProgramRestoreSnapshot = useCallback( (): ShareProgramRestoreSnapshot => ({ roomSessionId: selectedShareRoomSessionId.trim(), latestRequestId: latestRequestId.trim(), expandMode, scrollTop: Math.max(0, scrollContainerRef.current?.scrollTop ?? 0), }), [expandMode, latestRequestId, selectedShareRoomSessionId], ); const restoreProgramReturnSnapshot = useCallback((restoreSnapshot?: ShareProgramRestoreSnapshot | null) => { if (typeof window === 'undefined' || !restoreSnapshot) { return; } const normalizedRoomSessionId = restoreSnapshot.roomSessionId.trim(); if (normalizedRoomSessionId && normalizedRoomSessionId !== selectedShareRoomSessionId) { setIsRoomSwitching(true); requestedRoomSessionIdRef.current = normalizedRoomSessionId; writeStoredShareLastRoomSessionId(normalizedToken, normalizedRoomSessionId); writeShareRoomSessionIdToLocation(normalizedRoomSessionId, 'replace'); setRequestedRoomSessionId(normalizedRoomSessionId); } setExpandMode(restoreSnapshot.expandMode); setLatestRequestId(restoreSnapshot.latestRequestId.trim()); setIsSearchOpen(false); setIsShareRoomListVisible(false); const applyScrollPosition = () => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); const nextScrollTop = Math.min(Math.max(0, restoreSnapshot.scrollTop), maxScrollTop); scrollContainer.scrollTo({ top: nextScrollTop, behavior: 'auto', }); lastScrollTopRef.current = nextScrollTop; queueScrollJumpVisibilitySync(); }; window.requestAnimationFrame(() => { applyScrollPosition(); window.setTimeout(applyScrollPosition, 80); }); }, [normalizedToken, queueScrollJumpVisibilitySync, selectedShareRoomSessionId]); const handleCloseProgram = useCallback(() => { restoreProgramReturnSnapshot(programTarget?.restoreSnapshot); setProgramTarget(null); }, [programTarget?.restoreSnapshot, restoreProgramReturnSnapshot]); const canLaunchShareProgram = useCallback( (appId?: ShareProgramTarget['appId']) => { if (!appId) { return true; } if (appId === SHARE_CURRENT_CHAT_APP_ID) { return true; } return shareAllowedAppIdSet.has(appId); }, [shareAllowedAppIdSet], ); const recordShareAppLaunch = useCallback((appId?: string) => { if (!appId) { return; } setAppLaunchUsage((current) => { const nextRecord = { count: (current[appId]?.count ?? 0) + 1, lastOpenedAt: Date.now(), }; const nextValue = { ...current, [appId]: nextRecord, }; writeShareAppLaunchUsage(nextValue); return nextValue; }); }, []); const openProgramTarget = useCallback((target: ShareProgramTarget) => { if (!canLaunchShareProgram(target.appId)) { message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.'); return; } recordShareAppLaunch(target.appId); setProgramReloadKey(0); setMinimizedPrograms((current) => current.filter((item) => item.target.key !== target.key)); setProgramTarget({ ...target, restoreSnapshot: target.restoreSnapshot ?? captureProgramRestoreSnapshot(), }); }, [canLaunchShareProgram, captureProgramRestoreSnapshot, message, recordShareAppLaunch]); const openAllowedPlayAppEnvironment = useCallback((entry: PlayAppEntry, environment: ShareAppEnvironment) => { if (!shareAllowedAppIdSet.has(entry.id)) { message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.'); return; } if (!isPlayAppSupportedInEnvironment(entry, environment)) { const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment; const supportedLabel = resolveSupportedEnvironmentSummary(entry); message.warning(`${entry.name} 앱은 ${environmentLabel} 환경에서 아직 열 수 없습니다. 지원 환경: ${supportedLabel}`); return; } setIsSearchOpen(false); openProgramTarget(buildPlayAppEnvironmentTarget(entry.id, entry.name, environment)); }, [message, openProgramTarget, shareAllowedAppIdSet]); const handleMinimizeProgram = useCallback(() => { if (!programTarget) { return; } setMinimizedPrograms((current) => { const existingIndex = current.findIndex((item) => item.target.key === programTarget.key); const rememberedPosition = minimizedProgramPositionByKeyRef.current[programTarget.key]; const nextPosition = existingIndex >= 0 ? current[existingIndex].position : rememberedPosition ?? getStackedProgramMinimizedPosition(current.length); const nextItem: ShareMinimizedProgramItem = { target: programTarget, position: nextPosition, }; minimizedProgramPositionByKeyRef.current[programTarget.key] = nextPosition; if (existingIndex >= 0) { return current.map((item, index) => (index === existingIndex ? nextItem : item)); } return [...current, nextItem]; }); setProgramTarget(null); restoreProgramReturnSnapshot(programTarget.restoreSnapshot); }, [programTarget, restoreProgramReturnSnapshot]); const handleSearchResultSelect = useCallback((result: ShareSearchResult) => { if (result.appEntry) { openAllowedPlayAppEnvironment(result.appEntry, selectedAppEnvironment); return; } if (result.resource && !result.requestId && !result.scrollTarget) { setIsSearchOpen(false); openProgramTarget(result.resource); return; } setIsSearchOpen(false); if (result.requestId) { setLatestRequestId(result.requestId); setExpandMode('all'); } const scrollTarget = result.scrollTarget; if (!scrollTarget) { return; } window.requestAnimationFrame(() => { window.setTimeout(() => { if (scrollTarget.type === 'request') { if (scrollToShareAnchorElement(requestAnchorRefs.current.get(scrollTarget.value))) { return; } } else if (scrollTarget.type === 'response') { const messageId = Number(scrollTarget.value); if (Number.isFinite(messageId) && scrollToShareAnchorElement(responseAnchorRefs.current.get(messageId))) { return; } } else if (scrollTarget.type === 'prompt') { if (scrollToShareAnchorElement(promptAnchorRefs.current.get(scrollTarget.value))) { return; } } else if (scrollToShareAnchorElement(document.getElementById(scrollTarget.value))) { return; } const selector = scrollTarget.type === 'request' ? `#chat-share-request-${CSS.escape(scrollTarget.value)}` : `#${CSS.escape(scrollTarget.value)}`; document.querySelector(selector)?.scrollIntoView({ behavior: 'smooth', block: scrollTarget.type === 'activity' ? 'center' : 'start', }); }, 80); }); }, [openAllowedPlayAppEnvironment, openProgramTarget, selectedAppEnvironment]); const closeProgramTarget = useCallback(() => { setProgramTarget(null); }, []); const sharedServerCommandAccess = useMemo( () => ({ shareToken: normalizedToken, allowedKeys: ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const, }), [normalizedToken], ); const shouldInlineProgramTarget = shouldRenderSharePlayAppInline(programTarget); const embeddedPlayAppContent = shouldInlineProgramTarget && programTarget ? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken) : null; const isServerCommandDrawerOpen = programTarget?.appId === 'server-command'; const searchResults = useMemo(() => { const keyword = normalizeSearchKeyword(searchKeyword); const results: ShareSearchResult[] = []; if (searchPanelMode === 'apps') { const photoprismLauncher = buildPhotoPrismProgramTarget(); if ( matchesSearchKeyword( keyword, currentShareChatTarget.label, currentShareChatTarget.appId, '공유채팅', '현재 토큰', '현재 공유토큰 열기', ...APPS_LAUNCHER_SEARCH_TERMS, ) ) { results.push({ key: `management-app:${SHARE_CURRENT_CHAT_APP_ID}`, title: currentShareChatTarget.label, description: `현재 공유토큰을 ${selectedAppEnvironment} 환경에서 다시 엽니다.`, category: 'resource', icon: , usageBadge: resolveShareAppUsageBadge(appLaunchUsage[SHARE_CURRENT_CHAT_APP_ID]), resource: currentShareChatTarget, }); } sortedAllowedManagementApps.forEach((item) => { if (!matchesSearchKeyword(keyword, item.value, item.label, item.description, ...APPS_LAUNCHER_SEARCH_TERMS)) { return; } results.push({ key: `management-app:${item.value}`, title: item.label, description: item.description, category: 'resource', icon: item.icon, usageBadge: resolveShareAppUsageBadge(appLaunchUsage[item.value]), resource: buildShareManagementProgramTarget(item.value, item.label), }); }); sortedAllowedPlayAppEntries.forEach((entry) => { if ( !matchesSearchKeyword( keyword, entry.id, entry.name, entry.searchDescription, ...APPS_LAUNCHER_SEARCH_TERMS, ...(entry.searchKeywords ?? []), ) ) { return; } results.push({ key: `app:${entry.id}`, title: entry.name, description: resolveSupportedEnvironmentSummary(entry), category: 'resource', icon: entry.icon, usageBadge: resolveShareAppUsageBadge(appLaunchUsage[entry.id]), appEntry: entry, resource: entry.id === 'photoprism' ? photoprismLauncher : buildPlayAppProgramTarget(entry.id, entry.name), }); }); return results; } if (!keyword) { return results; } sortedRequests.forEach((request) => { const requestText = buildShareVisibleText(request.userText); if (matchesSearchKeyword(keyword, requestText, request.statusMessage, request.requestId)) { results.push({ key: `request:${request.requestId}`, title: requestText || '질문', description: `질문 · ${formatTimeLabel(request.createdAt)}`, category: 'request', requestId: request.requestId, scrollTarget: { type: 'request', value: request.requestId, }, }); } buildSharePreviewItemsFromText(request.userText, normalizedToken).forEach((item) => { if (!matchesSearchKeyword(keyword, item.label, item.url, item.kind)) { return; } const scopedUrl = resolveShareScopedResourceUrl(item.url, normalizedToken); results.push({ key: `request-resource:${request.requestId}:${item.id}`, title: item.label, description: `질문 리소스 · ${item.kind}`, category: 'resource', requestId: request.requestId, resource: { key: `request-resource:${request.requestId}:${item.id}`, label: item.label, url: scopedUrl, kind: item.kind, meta: `${item.kind} resource`, }, scrollTarget: { type: 'request', value: request.requestId, }, }); }); }); sortedMessages.forEach((entry) => { const payload = messageRenderPayloadById.get(entry.id); const visibleText = buildVisibleMessageText(entry, payload); const requestId = entry.clientRequestId?.trim() || snapshot?.rootRequestId?.trim() || ''; if (matchesSearchKeyword(keyword, visibleText, entry.author, requestId)) { results.push({ key: `response:${entry.id}`, title: visibleText || '응답', description: `${entry.author === 'user' ? '질문 메시지' : '응답'} · ${formatTimeLabel(entry.timestamp)}`, category: 'response', requestId: requestId || undefined, scrollTarget: { type: 'response', value: String(entry.id), }, }); } (payload?.promptParts ?? []).forEach((prompt, promptIndex) => { if (!matchesSearchKeyword(keyword, buildSharePromptSearchText(prompt), requestId, entry.id)) { return; } results.push({ key: `prompt:${entry.id}:${promptIndex}`, title: prompt.title || 'prompt', description: `prompt · ${formatTimeLabel(entry.timestamp)}`, category: 'response', requestId: requestId || undefined, scrollTarget: { type: 'prompt', value: buildSharePromptAnchorKey(entry.id, promptIndex), }, }); }); (payload?.previewItems ?? []).forEach((item) => { if (!requestId || !matchesSearchKeyword(keyword, item.label, item.url, item.kind)) { return; } const scopedUrl = resolveShareScopedResourceUrl(item.url, normalizedToken); results.push({ key: `response-resource:${entry.id}:${item.id}`, title: item.label, description: `응답 리소스 · ${item.kind}`, category: 'resource', requestId, resource: { key: `response-resource:${entry.id}:${item.id}`, label: item.label, url: scopedUrl, kind: item.kind, meta: `${item.kind} resource`, }, scrollTarget: { type: 'response', value: String(entry.id), }, }); }); }); (snapshot?.activityLogs ?? []).forEach((activity, index) => { const summary = summarizeActivityLogLines(activity.lines ?? []).join(' '); if (!matchesSearchKeyword(keyword, activity.requestId, summary)) { return; } results.push({ key: `activity:${activity.requestId}:${index}`, title: summary || `활동 로그 ${index + 1}`, description: `활동 로그 · ${activity.requestId}`, category: 'activity', requestId: activity.requestId, scrollTarget: { type: 'activity', value: 'chat-share-activity-panel', }, }); }); return results .filter((item) => !item.scrollTarget || item.scrollTarget.value.trim()) .slice(0, 40); }, [appLaunchUsage, currentShareChatTarget, messageRenderPayloadById, normalizedToken, searchKeyword, searchPanelMode, selectedAppEnvironment, snapshot?.activityLogs, snapshot?.rootRequestId, sortedAllowedManagementApps, sortedAllowedPlayAppEntries, sortedMessages, sortedRequests]); const selectedTokenUsageSetting = shareTokenSetting; const tokenUsageSummaryByPeriod = useMemo( () => Object.fromEntries( TOKEN_USAGE_PERIODS.map((period) => [ period.key, resolveTokenUsageWindowSummary( sortedRequests, period.key, nowMs, resolveTokenUsageLimitForPeriod(selectedTokenUsageSetting, period.key), ), ]), ) as Record, [nowMs, selectedTokenUsageSetting, sortedRequests], ); const tokenUsageFiveHourSummary = tokenUsageSummaryByPeriod['5h']; const tokenUsageSevenDaySummary = tokenUsageSummaryByPeriod['7d']; const tokenUsageOverview = useMemo(() => { const sevenDayLimit = Math.max(0, Math.round(Number(resolveTokenUsageLimitForPeriod(selectedTokenUsageSetting, '7d')) || 0)); const fiveHourLimit = Math.max(0, Math.round(Number(resolveTokenUsageLimitForPeriod(selectedTokenUsageSetting, '5h')) || 0)); const sevenDayRemaining = tokenUsageSevenDaySummary.remainingTokens; const fiveHourRemaining = tokenUsageFiveHourSummary.remainingTokens; const currentAvailableTokens = resolveSmallestFiniteNumber(sevenDayRemaining, fiveHourRemaining); const axisLimit = sevenDayLimit > 0 ? sevenDayLimit : Math.max(fiveHourLimit, currentAvailableTokens ?? 0); const overallLabel = sevenDayLimit > 0 ? `${formatTokenCount(sevenDayLimit)} 토큰` : '무제한'; const sevenDayRemainingLabel = sevenDayRemaining == null ? '무제한' : `${formatTokenCount(sevenDayRemaining)} 토큰`; const fiveHourRemainingLabel = fiveHourRemaining == null ? '무제한' : `${formatTokenCount(fiveHourRemaining)} 토큰`; const currentAvailableLabel = currentAvailableTokens == null ? '무제한' : `${formatTokenCount(currentAvailableTokens)} 토큰`; const resolveAxisWidthPercent = (value: number | null, fallback: number) => { if (axisLimit <= 0) { return fallback; } if (value == null) { return fallback; } return Math.max(4, Math.min(100, (value / axisLimit) * 100)); }; return { currentAvailableLabel, overallLabel, sevenDayRemainingLabel, fiveHourRemainingLabel, fiveHourBucketLabel: `5시간 버킷 ${fiveHourRemainingLabel}`, oneLineSummary: sevenDayLimit > 0 ? `전체는 1주일 총량 ${overallLabel}, 1주일은 현재 남은 ${sevenDayRemainingLabel}, 5시간은 주간 잔여 상한이 적용된 실제 사용 가능 ${currentAvailableLabel}` : `현재 사용 가능 ${currentAvailableLabel}`, fiveHourResetAmountLabel: tokenUsageFiveHourSummary.nextUsageDropAt && tokenUsageFiveHourSummary.nextResetTokens > 0 ? `${formatTokenCount(tokenUsageFiveHourSummary.nextResetTokens)} 토큰` : tokenUsageFiveHourSummary.nextUsageDropAt ? '일부 초기화' : '예정 없음', fiveHourResetTimeLabel: tokenUsageFiveHourSummary.nextUsageDropAt ? formatTimeLabel(tokenUsageFiveHourSummary.nextUsageDropAt) || '시간 미기록' : '예정 없음', fiveHourCountdownLabel: formatCountdownLabel(tokenUsageFiveHourSummary.nextUsageDropAt, nowMs), sevenDayResetTimeLabel: tokenUsageSevenDaySummary.nextUsageDropAt ? formatTimeLabel(tokenUsageSevenDaySummary.nextUsageDropAt) || '시간 미기록' : '예정 없음', meterLegendItems: [ { key: 'overall', label: '전체', colorClassName: 'chat-share-page__token-usage-meter-fill--overall', caption: overallLabel, }, { key: '7d', label: '1주일', colorClassName: 'chat-share-page__token-usage-meter-fill--7d', caption: sevenDayRemainingLabel, }, { key: '5h', label: '5시간', colorClassName: 'chat-share-page__token-usage-meter-fill--5h', caption: currentAvailableLabel, }, ], meterLayers: [ { key: 'overall', colorClassName: 'chat-share-page__token-usage-meter-fill--overall', widthPercent: 100, }, { key: '7d', colorClassName: 'chat-share-page__token-usage-meter-fill--7d', widthPercent: resolveAxisWidthPercent(sevenDayRemaining, 100), }, { key: '5h', colorClassName: 'chat-share-page__token-usage-meter-fill--5h', widthPercent: resolveAxisWidthPercent(currentAvailableTokens, 100), }, ], }; }, [nowMs, selectedTokenUsageSetting, tokenUsageFiveHourSummary, tokenUsageSevenDaySummary]); const currentShareUrl = useMemo(() => buildAbsoluteShareUrl(snapshot?.share.sharePath), [snapshot?.share.sharePath]); const currentShareEffectiveExpiresAt = useMemo(() => resolveShareEffectiveExpiresAt(snapshot?.share ?? null), [snapshot?.share]); const currentShareDefaultExpiryLabel = useMemo(() => { const label = formatDurationMinutesLabel(snapshot?.share.tokenSetting?.defaultExpiresInMinutes); return label ? `기본 유효시간 ${label}` : ''; }, [snapshot?.share.tokenSetting?.defaultExpiresInMinutes]); const currentShareDefaultPolicyLabel = useMemo( () => currentShareDefaultExpiryLabel || '사용기간 제한 없음', [currentShareDefaultExpiryLabel], ); const currentShareExpiresAtLabel = useMemo( () => currentShareEffectiveExpiresAt ? formatShareExpirySummary(currentShareEffectiveExpiresAt, nowMs) : currentShareDefaultPolicyLabel, [currentShareDefaultPolicyLabel, currentShareEffectiveExpiresAt, nowMs], ); const currentShareRemainingTimeLabel = useMemo( () => (currentShareEffectiveExpiresAt ? formatRemainingTimeLabel(currentShareEffectiveExpiresAt, nowMs) : ''), [currentShareEffectiveExpiresAt, nowMs], ); const currentShareTokenUsageStatusLabel = useMemo( () => currentShareRemainingTimeLabel || currentShareDefaultPolicyLabel, [currentShareDefaultPolicyLabel, currentShareRemainingTimeLabel], ); const handleCopyCurrentShareUrl = useCallback(async () => { if (!currentShareUrl) { return; } try { await copyTextToClipboard(currentShareUrl); message.success('토큰 URL을 복사했습니다.'); } catch (error) { console.error('failed to copy share url', error); message.error('토큰 URL 복사에 실패했습니다.'); } }, [currentShareUrl, message]); const shareHeaderSettingsItems = useMemo( () => [ { key: 'conversation-title', label: ( {snapshot?.conversation.title?.trim() || '현재 채팅방'} 현재 공유 채팅방 ), disabled: true, }, { key: 'conversation-search', label: ( 통합검색 질문, 답변, 리소스, 활동 로그를 함께 찾습니다. ), icon: , }, { key: 'conversation-refresh', label: ( 강력 새로고침 서비스워커와 캐시를 정리한 뒤 현재 공유채팅방 화면을 다시 불러옵니다. ), icon: , }, ...(normalizedToken || allowedManagementApps.length > 0 || allowedPlayAppEntries.length > 0 ? [ { key: 'conversation-apps', label: ( Apps 허용된 앱을 빠르게 확인하고 실행합니다. ), icon: , }, ] : []), { key: 'conversation-token-usage', label: ( 토큰 관리 {selectedTokenUsageSetting ? selectedTokenUsageSetting.name : '설정 필요'} {selectedTokenUsageSetting ? `지금 사용 가능 ${tokenUsageOverview.currentAvailableLabel}` : '공유 링크 생성 시 선택된 토큰 설정이 없습니다.'} 5시간 초기화 {tokenUsageOverview.fiveHourCountdownLabel} ), icon: , }, ...(canOpenSharedRoomSettings ? [ { key: 'conversation-room-settings', label: ( 채팅방 설정 Codex Live와 동일한 Context 기준으로 유형과 문맥을 조정합니다. ), icon: , }, ] : []), ...(canCreateSharedRooms ? [ { key: 'conversation-room-create', label: ( 채팅방 추가 같은 공유 토큰 안에 새 채팅방을 만들고 바로 전환합니다. ), icon: , }, ] : []), ...(hasWorkServerCommandApp ? [ { key: 'conversation-work-server-command', label: ( {resolveShareWorkServerStatusLabel(shareWorkServerCommand)} {resolveShareWorkServerVersionDescription(shareWorkServerCommand)} {resolveShareWorkServerVersionLabel(shareWorkServerCommand)} ), icon: , }, ] : []), { key: 'conversation-clear', label: ( 채팅방 비우기 메시지, 요청, 활동 로그를 초기화합니다. ), icon: , danger: true, disabled: !canSendMessage || isClearingConversation || !selectedShareRoomSessionId, }, ], [ allowedManagementApps.length, allowedPlayAppEntries.length, canSendMessage, canOpenSharedRoomSettings, canCreateSharedRooms, hasWorkServerCommandApp, isClearingConversation, normalizedToken, selectedTokenUsageSetting, shareWorkServerCommand, snapshot?.conversation.title, selectedShareRoomSessionId, tokenUsageFiveHourSummary.percentage, tokenUsageOverview.currentAvailableLabel, tokenUsageOverview.fiveHourCountdownLabel, ], ); const handleShareHeaderSettingsClick = useCallback>( ({ key }) => { if (key === 'conversation-search') { setSearchPanelMode('all'); setSearchKeyword(''); setIsSearchOpen(true); return; } if (key === 'conversation-refresh') { handleReloadPage(); return; } if (key === 'conversation-apps') { setSearchPanelMode('apps'); setSearchKeyword(''); setIsSearchOpen(true); return; } if (key === 'conversation-token-usage') { setIsTokenUsageOpen(true); return; } if (key === 'conversation-room-settings') { openSharedRoomSettings(); return; } if (key === 'conversation-room-create') { openCreateRoomDialog(); return; } if (key === 'conversation-work-server-command') { openProgramTarget(buildShareManagementProgramTarget('server-command', '서버관리')); return; } if (key === 'conversation-clear') { void handleClearConversation(); } }, [handleClearConversation, handleReloadPage, openCreateRoomDialog, openProgramTarget, openSharedRoomSettings], ); const shareExpandModeMenuItems = useMemo( () => [ { key: 'latest', label: `마지막건${sortedRequests.length > 0 ? ` (${latestRequestIndex + 1}/${sortedRequests.length})` : ''}`, }, { key: 'pending', label: `처리중·미확인 (${pendingCompletionRequests.length})`, }, { key: 'all', label: `전체 (${sortedRequests.length})`, }, ], [latestRequestIndex, pendingCompletionRequests.length, sortedRequests.length], ); const handleSelectExpandMode: MenuProps['onClick'] = ({ key }) => { if (key === 'latest' || key === 'pending' || key === 'all') { if (key === 'latest') { const nextLatestRequest = sortedRequests[sortedRequests.length - 1]; if (nextLatestRequest) { setLatestRequestId(nextLatestRequest.requestId); } } setExpandMode(key); } }; useEffect(() => { const activePromptSelectionKeys = new Set(); sortedMessages.forEach((message) => { const promptParts = (messageRenderPayloadById.get(message.id)?.promptParts ?? []).map((part) => normalizedToken ? rewritePromptPartForShare(part, normalizedToken) : part, ); const parentRequestId = message.clientRequestId?.trim() || ''; promptParts.forEach((target, promptIndex) => { activePromptSelectionKeys.add( buildSharePromptSelectionKey( parentRequestId, message.id, promptIndex, target.title, buildPromptTargetSignature(target), ), ); }); }); if (promptTarget && promptTargetRequestId) { const rewrittenPromptTarget = normalizedToken ? rewritePromptPartForShare(promptTarget.prompt, normalizedToken) : promptTarget.prompt; activePromptSelectionKeys.add( buildSharePromptSelectionKey( promptTargetRequestId, promptTarget.sourceMessageId, promptTarget.promptIndex, rewrittenPromptTarget.title, buildPromptTargetSignature(rewrittenPromptTarget), ), ); } setPendingPromptSelections((current) => { const nextEntries = Object.entries(current).filter(([key]) => activePromptSelectionKeys.has(key)); if (nextEntries.length === Object.keys(current).length) { return current; } return Object.fromEntries(nextEntries); }); }, [messageRenderPayloadById, normalizedToken, promptTarget, promptTargetRequestId, sortedMessages]); useEffect(() => { if (!hasActiveProcessingRequest) { return undefined; } setNowMs(Date.now()); const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, SHARE_PROCESSING_CLOCK_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; }, [hasActiveProcessingRequest]); useEffect(() => { if (hasActiveProcessingRequest) { return undefined; } if (isTokenUsageOpen && selectedTokenUsageSetting) { setNowMs(Date.now()); const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; } if (!currentShareEffectiveExpiresAt) { return undefined; } setNowMs(Date.now()); const intervalId = window.setInterval(() => { setNowMs(Date.now()); }, SHARE_EXPIRY_CLOCK_INTERVAL_MS); return () => { window.clearInterval(intervalId); }; }, [currentShareEffectiveExpiresAt, hasActiveProcessingRequest, isTokenUsageOpen, selectedTokenUsageSetting]); useEffect(() => { return () => { if (liveRefreshTimerRef.current != null) { window.clearTimeout(liveRefreshTimerRef.current); } }; }, []); useEffect(() => { queueScrollJumpVisibilitySync(); return () => { if (scrollSyncFrameRef.current) { window.cancelAnimationFrame(scrollSyncFrameRef.current); scrollSyncFrameRef.current = null; } if (scrollIdleTimerRef.current) { window.clearTimeout(scrollIdleTimerRef.current); scrollIdleTimerRef.current = null; } }; }, [displayedRequests, isPromptShare, queueScrollJumpVisibilitySync, sortedMessages.length]); if (isLoading) { return (
); } if (requiresAccessPin) { return (
공유 채팅방 비밀번호 이 공유 채팅방은 접근 전에 숫자 4자리 비밀번호 입력이 필요합니다.
{ const nextValue = normalizeAccessPinInput(event.target.value); setAccessPinInput(nextValue); if (accessPinSubmitError) { setAccessPinSubmitError(''); } if (nextValue.length === SHARE_ACCESS_PIN_MAX_LENGTH) { void handleUnlockShare(nextValue); } }} onPressEnter={() => { void handleUnlockShare(); }} /> {accessPinSubmitError ? {accessPinSubmitError} : null}
); } if (errorMessage || !snapshot) { return (
공유 화면을 열 수 없습니다. {errorMessage || '공유 데이터가 없습니다.'}
); } return ( <>
{isPromptShare ? (
공유된 prompt
{promptTarget && promptTargetRequestId && !isPromptResolved(promptTarget.prompt) && !snapshot.targetRequest.manualPromptCompletedAt && !isRequestInFlight(snapshot.targetRequest.status) ? (
) : null}
{promptTarget && promptTargetRequestId ? ( (() => { const target = rewritePromptPartForShare(promptTarget.prompt, normalizedToken); const promptSignature = buildPromptTargetSignature(target); const selectionKey = buildSharePromptSelectionKey( promptTargetRequestId, promptTarget.sourceMessageId, promptTarget.promptIndex, target.title, promptSignature, ); const draftSelection = pendingPromptSelections[selectionKey]?.status === 'draft' ? pendingPromptSelections[selectionKey] : null; const submittedSelection = pendingPromptSelections[selectionKey]?.status === 'submitted' ? pendingPromptSelections[selectionKey] : null; return ( updatePendingPromptSelection(selectionKey, selection)} onSubmitted={(selection) => markPendingPromptSelectionSubmitted(selectionKey, selection)} onSubmit={(payload) => handleSubmitPrompt({ ...payload, parentRequestId: promptTargetRequestId, promptIndex: promptTarget.promptIndex, sourceMessageId: promptTarget.sourceMessageId, }) } allowAttachments={Boolean(selectedShareRoomSessionId)} attachmentAccept={SHARE_ATTACHMENT_ACCEPT} onUploadAttachment={handleUploadPromptAttachment} /> ); })() ) : ( 표시할 prompt가 없습니다. )}
{lastResponseMessage ? (
결과 이 prompt에 대해 받은 최신 응답입니다.
) : null} {promptShareFollowupRequests.length > 0 ? (
전달 내역 선택 없이 보낸 기타 요청도 여기에서 계속 확인할 수 있습니다.
{promptShareFollowupRequests.map((request) => ( 0} activeReplyRequestId={null} onCancelDisconnectedRequest={handleCancelDisconnectedRequest} isRequestCancellationSaving={pendingRequestCancellationIds.includes(request.requestId)} onRetryDisconnectedRequest={handleRetryDisconnectedRequest} isRequestRetrySaving={pendingRequestRetryIds.includes(request.requestId)} onReplyToResponse={null} shareToken={normalizedToken} onOpenProgram={openProgramTarget} canUploadAttachments={Boolean(selectedShareRoomSessionId)} onUploadAttachment={handleUploadPromptAttachment} onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} onSetPromptAnchor={setPromptAnchorRef} onOpenProcessInspector={openProcessInspector} /> ))}
) : null}
) : (
{headerTitleText.trim() || '채팅'} {canOpenSharedRoomSettings ? (
{headerSummaryLabel}
{canToggleShareRoomList ? (
{showRoomSwitchingSkeleton ? (
채팅방 내용을 불러오는 중입니다.
) : (
{showRoomSwitchingOverlay ? (
{`${roomSwitchingStatusLabel} 불러오는 중`}
) : null} {expandMode === 'latest' && hiddenBeforeCount > 0 ? (
) : null} {displayedRequests.map((request) => ( 0} activeReplyRequestId={replyReferenceRequestId} onCancelDisconnectedRequest={handleCancelDisconnectedRequest} isRequestCancellationSaving={pendingRequestCancellationIds.includes(request.requestId)} onRetryDisconnectedRequest={handleRetryDisconnectedRequest} isRequestRetrySaving={pendingRequestRetryIds.includes(request.requestId)} onReplyToResponse={ isPromptShare ? null : (parentRequestId) => { setReplyReferenceRequestId(parentRequestId.trim()); window.setTimeout(() => { composerRef.current?.focus({ cursor: 'end' }); }, 0); } } shareToken={normalizedToken} onOpenProgram={openProgramTarget} canUploadAttachments={Boolean(selectedShareRoomSessionId)} onUploadAttachment={handleUploadPromptAttachment} onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} onSetPromptAnchor={setPromptAnchorRef} onOpenPreviousQuestion={(requestId) => { setPreviousQuestionModalRequestId(requestId.trim()); }} onCopyMessage={handleCopyShareMessageText} onCancelActiveRequest={handleCancelActiveShareRequest} isActiveRequestCancellationSaving={pendingShareRuntimeRequestIds.includes(request.requestId)} onResubmitRequestDirect={handleResubmitQueuedRequestDirect} isDirectResubmitSaving={pendingShareRuntimeRequestIds.includes(request.requestId) || isSending} onOpenProcessInspector={openProcessInspector} /> ))} {expandMode === 'latest' && hiddenAfterCount > 0 ? (
) : null} {expandMode === 'pending' && displayedRequests.length === 0 ? (
) : null}
)}
{canSendMessage ? ( <> {expandMode === 'latest' && collapsedActivitySummary.length > 0 ? (
{showRoomSwitchingOverlay ? (
새 방 상태 반영 중
) : null}
현재 진행 상황
{collapsedActivitySummary.map((item) => ( {item} ))}
) : null}
{showRoomSwitchingSkeleton ? (
) : (
{showRoomSwitchingOverlay ? (
전환이 끝나면 입력할 수 있습니다.
) : null} { const files = Array.from(event.target.files ?? []); event.target.value = ''; void handleUploadComposerAttachments(files); }} />
{ setEditingRoomTitle(event.target.value); }} />
기본 채팅유형 공유채팅이 기본으로 사용할 유형을 먼저 고릅니다.
{ setEditingRoomCustomContextTitle(event.target.value); }} /> { setEditingRoomCustomContextContent(event.target.value); }} />
), }, { key: 'notifications', label: '채팅 알림', children: (
이 채팅방 알림 현재 기기에서 이 공유채팅방 새 답변 알림을 받을지 설정합니다.
{roomNotificationClientStatus.summaryLabel}
{roomNotificationClientStatus.roomLabel} {roomNotificationClientStatus.appLabel} {roomNotificationClientStatus.permissionLabel} {roomNotificationClientStatus.registrationLabel}
방 설정, 앱 전체 허용, 브라우저 권한, 현재 기기 등록 상태만 간단히 합쳐서 표시합니다.
), }, { key: 'security', label: '보안', children: (
공유 비밀번호 공유 URL 자체에 4자리 비밀번호를 걸어 접근을 제한합니다.
{ setEditingRoomAccessPin(event.target.value); }} /> ({ value: item.sessionId, label: `${item.title || item.contextLabel || item.sessionId} · ${item.lastRequestPreview || item.lastMessagePreview || '미리보기 없음'}`, }))} onChange={(value) => { setCreatingRoomLinkedSessionId(value ?? ''); }} /> {creatingRoomLinkedConversation ? ( {creatingRoomLinkedConversation.lastRequestPreview || creatingRoomLinkedConversation.lastMessagePreview || '원 세션 미리보기 없음'} ) : ( 선택하지 않으면 독립 공유채팅방으로 생성합니다. )}
setSourceGroupDetailKey('')} > {sourceGroupDetail ? (
{sourceGroupDetail.title} {sourceGroupDetail.chatTypeLabel || '연결된 원 세션'} {sourceGroupDetail.requestPreview || '등록된 원 요청 미리보기가 없습니다.'}
{sourceGroupDetail.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? ( ) : null}
{sourceGroupDetail.rooms.map((room) => ( ))}
) : null}
{ void handleSubmitOriginReply(); }} onCancel={() => { if (isSubmittingOriginReply) { return; } setIsOriginReplyModalOpen(false); setOriginReplyTargetGroupKey(''); }} >
{originReplyTargetGroup?.title || '선택된 원 세션'} 기준으로 현재 작업 답변을 전달합니다. setOriginReplyDraftText(event.target.value)} />
setIsSearchOpen(false)} >
} placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
{searchKeyword.trim() ? `검색 결과 ${searchResults.length}건` : searchPanelMode === 'apps' ? '최근 자주 연 앱을 먼저 보여주며, 아이콘 중심으로 빠르게 실행할 수 있습니다.' : '질문, 답변, 리소스, 활동 로그를 함께 찾습니다.'}
{searchPanelMode === 'apps' ? (
실행 환경 { setShareRoomFilterKeyword(event.target.value); }} className="chat-share-page__room-filter-input" placeholder="채팅방 필터" prefix={} aria-label="공유채팅 채팅방 필터" />
채팅방 {activeShareRoom ? `${activeShareRoom.title} 사용 중` : '공유 토큰에 연결된 방 목록'}
{canCreateSharedRooms ? ( ) : null}
{filteredShareRoomGroups.map((group) => (
{group.linkContext ? (
{group.title} {group.chatTypeLabel || '연결된 원 세션'} {group.requestPreview ? ( {group.requestPreview} ) : null}
{group.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? ( ) : null}
) : null}
{group.rooms.map((room) => { const isActive = room.sessionId === selectedShareRoomSessionId; const canDeleteRoom = canDeleteShareRoom(room, shareRooms); const isDeletingTarget = isDeletingRoom && pendingDeleteRoomSessionId === room.sessionId; const pendingCounts = shareRoomPendingCountsBySessionId[room.sessionId] ?? null; return (
{canDeleteRoom ? ( ) : null}
); })}
))} {filteredShareRoomGroups.length === 0 ? (
조건에 맞는 채팅방이 없습니다.
) : null}
, document.body, ) : null} ); }