import { AppstoreOutlined, CheckOutlined, CloseOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, 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 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 { cancelChatShareRequest, ChatApiError, clearChatShareConversationRoom, completeChatShareManualBadge, fetchChatShareSnapshot, getStoredChatShareAccessPin, resolveChatWebSocketUrl, retryChatShareRequest, saveChatShareRoomSettings, setStoredChatShareAccessPin, submitChatShareMessage, submitChatSharePrompt, uploadChatShareComposerFile, 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 type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatMessagePart, ChatServerEvent } from '../mainChatPanel/types'; import { isPromptResolved } from '../mainChatPanel/promptState'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; import { applyViewportCssVars } from '../viewportCssVars'; 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_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 ShareProgramTarget = { key: string; label: string; url: string; kind: PreviewKind; meta?: string; appId?: string; }; 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; }; 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 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 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 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'; 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 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 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 createChatShareManifestObjectUrl(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 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 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 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 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 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 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')}`; } 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 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, }: { 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; }) { 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; return (
{ onSetResponseAnchor?.(message.id, element); }} > {formatTimeLabel(message.timestamp)}
{emphasisLabel ? {emphasisLabel} : null} {canCompletePrompt && parentRequestId && onCompletePrompt ? ( ) : null} {!canCompletePrompt && isPromptManualCompleted ? prompt 완료 처리됨 : null} {canCompleteVerification && parentRequestId && onCompleteVerification ? ( ) : null} {!canCompleteVerification && !hasOpenPromptInResponse && !hasChildRequest && isVerificationCompleted ? 응답 확인 완료 : null} {canReplyToResponse && parentRequestId && onReplyToResponse ? ( ) : null}
답변
{promptParts.length > 0 ? promptParts.map((target, promptIndex) => { const promptSignature = buildPromptTargetSignature(target); const selectionKey = buildSharePromptSelectionKey(parentRequestId, message.id, promptIndex, target.title, promptSignature); const draftSelection = promptSelections[selectionKey]?.status === 'draft' ? promptSelections[selectionKey] : null; const submittedSelection = promptSelections[selectionKey]?.status === 'submitted' ? promptSelections[selectionKey] : null; return (
{ onSetPromptAnchor?.(message.id, promptIndex, element); }} > onPromptSelectionChange(selectionKey, selection)} onSubmitted={(selection) => onPromptSubmitted(selectionKey, selection)} onSubmit={(payload) => onSubmitPrompt({ ...payload, parentRequestId, promptIndex, sourceMessageId: message.id })} allowAttachments={canUploadAttachments} attachmentAccept={SHARE_ATTACHMENT_ACCEPT} onUploadAttachment={onUploadAttachment} />
); }) : null} {shareToken && previewItems.length > 0 ? (
{previewItems.map((item) => ( ))}
) : null} {diffBlocks.length > 0 ? (
{diffBlocks.map((diffText, index) => ( 1 ? `diff preview ${index + 1}` : 'diff preview', kind: 'diff', previewText: diffText, }} /> ))}
) : null}
); } function ShareInlinePreviewCard({ item, onPreviewViewed, }: { item: { id: string; label: string; kind: PreviewKind; previewText: string; }; onPreviewViewed?: (() => void) | null; }) { const [isExpanded, setIsExpanded] = useState(false); const target = useMemo( () => ({ label: item.label, url: item.label, kind: item.kind, }), [item.kind, item.label], ); return (
{item.label} {item.kind} preview
{isExpanded ? (
) : null}
); } function ShareResourcePreviewCard({ item, shareToken, onPreviewViewed, onOpenProgram, }: { item: PreviewItem; shareToken: string; onPreviewViewed?: (() => void) | null; onOpenProgram?: ((target: ShareProgramTarget) => void) | null; }) { const [isExpanded, setIsExpanded] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [previewText, setPreviewText] = useState(''); const [previewError, setPreviewError] = useState(''); const [previewContentType, setPreviewContentType] = useState(''); const [isLoading, setIsLoading] = useState(false); const scopedUrl = useMemo(() => resolveShareScopedResourceUrl(item.url, shareToken), [item.url, shareToken]); const target = useMemo( () => ({ label: item.label, url: scopedUrl, kind: item.kind, }), [item.kind, item.label, scopedUrl], ); const handleOpenProgram = useCallback(() => { onOpenProgram?.({ key: `${item.id}-program`, label: item.label, url: scopedUrl, kind: item.kind, meta: `${item.kind} resource`, }); }, [item.id, item.kind, item.label, onOpenProgram, scopedUrl]); useEffect(() => { if (!isPreviewOpen) { return undefined; } if (item.kind === 'image' || item.kind === 'video' || item.kind === 'pdf' || item.kind === 'file') { setPreviewText(''); setPreviewError(''); setPreviewContentType(''); setIsLoading(false); return undefined; } const controller = new AbortController(); setIsLoading(true); setPreviewError(''); setPreviewContentType(''); fetch(scopedUrl, { cache: 'no-store', credentials: 'include', signal: controller.signal, }) .then(async (response) => { if (!response.ok) { throw await createSharePreviewFetchError(response); } setPreviewContentType(response.headers.get('content-type') ?? ''); const text = await response.text(); setPreviewText(text); }) .catch((error: unknown) => { if (controller.signal.aborted) { return; } setPreviewText(''); setPreviewContentType(''); setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.'); }) .finally(() => { if (!controller.signal.aborted) { setIsLoading(false); } }); return () => { controller.abort(); }; }, [isPreviewOpen, item.kind, scopedUrl]); return (
{item.label} {item.kind} preview
{isPreviewOpen ? (
) : null} { handleOpenProgram(); setIsExpanded(false); } : null} onClose={() => setIsExpanded(false)} >
); } function ShareRequestCard({ request, requestById, answerText, relatedMessages, mode, onSubmitPrompt, promptSelections, onPromptSelectionChange, onPromptSubmitted, onCompletePrompt, onCompleteVerification, isPromptCompletionSaving, isVerificationCompletionSaving, isVerificationCompleted, hasChildRequest = false, activeReplyRequestId, onReplyToResponse, onCancelDisconnectedRequest, onRetryDisconnectedRequest, isRequestCancellationSaving = false, isRequestRetrySaving = false, shareToken, onOpenProgram, canUploadAttachments = false, onUploadAttachment, onSetRequestAnchor, onSetResponseAnchor, onSetPromptAnchor, onOpenPreviousQuestion, }: { request: ChatConversationRequest; requestById: Map; answerText: string; relatedMessages: ChatMessage[]; mode: ShareRequestCardMode; 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; isVerificationCompletionSaving?: boolean; isVerificationCompleted?: boolean; hasChildRequest?: boolean; activeReplyRequestId?: string | null; onReplyToResponse?: ((parentRequestId: string) => void) | null; onCancelDisconnectedRequest?: ((parentRequestId: string) => Promise) | null; isRequestCancellationSaving?: boolean; onRetryDisconnectedRequest?: ((parentRequestId: string) => Promise) | null; isRequestRetrySaving?: boolean; shareToken: string; onOpenProgram?: ((target: ShareProgramTarget) => void) | null; canUploadAttachments?: boolean; onUploadAttachment?: ((file: File) => Promise) | null; onSetRequestAnchor?: ((requestId: string, element: HTMLElement | null) => void) | null; onSetResponseAnchor?: ((messageId: number, element: HTMLDivElement | null) => void) | null; onSetPromptAnchor?: ((messageId: number, promptIndex: number, element: HTMLDivElement | null) => void) | null; onOpenPreviousQuestion?: ((requestId: string) => void) | null; }) { const questionText = useMemo(() => buildShareVisibleText(request.userText), [request.userText]); const questionPreviewItems = useMemo( () => buildSharePreviewItemsFromText(request.userText, shareToken), [request.userText, shareToken], ); const resolvedAnswerText = answerText.trim() || request.statusMessage?.trim() || '아직 답변이 없습니다.'; const shouldRenderQuestion = mode !== 'answer-only'; const shouldRenderFullAnswer = mode === 'full' || mode === 'answer-only'; const isRequestStillRunning = isRequestInFlight(request.status); const responseMessages = useMemo( () => relatedMessages.filter((message) => message.author !== 'user'), [relatedMessages], ); const responseRequestById = useMemo(() => new Map([[request.requestId, request]]), [request]); const shouldRenderAnswerSummary = shouldRenderFullAnswer && responseMessages.length === 0; const canReplyFromSummary = Boolean(onReplyToResponse) && mode !== 'question-only' && !isRequestStillRunning; const isSummaryReplyActive = canReplyFromSummary && activeReplyRequestId?.trim() === request.requestId; const canCompleteSummaryVerification = shouldRenderAnswerSummary && Boolean(onCompleteVerification) && !isVerificationCompleted && !isRequestStillRunning; const canCancelDisconnectedRequest = Boolean(onCancelDisconnectedRequest) && !request.hasResponse && request.status === 'failed' && (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청'; const canRetryDisconnectedRequest = Boolean(onRetryDisconnectedRequest) && !request.hasResponse && request.status === 'failed' && (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청'; const retryCount = Math.max(0, Number(request.retryCount ?? 0) || 0); return (
{ onSetRequestAnchor?.(request.requestId, element); }} >
{formatTimeLabel(request.createdAt)} {isRetriedRequest(request) ? 재처리 {retryCount}회 : null} {canCancelDisconnectedRequest ? ( ) : null} {canRetryDisconnectedRequest ? ( ) : null}
{shouldRenderQuestion ? ( <>
질문
{questionPreviewItems.length > 0 ? (
{questionPreviewItems.map((item) => ( ))}
) : null} ) : null} {shouldRenderFullAnswer ? ( <> {shouldRenderAnswerSummary ? ( <>
); } export function ChatSharePage() { const { message, modal } = App.useApp(); const appConfig = useAppConfig(); const { token = '' } = useParams(); const normalizedToken = token.trim(); const scrollContainerRef = useRef(null); const pageRef = useRef(null); const requestAnchorRefs = useRef(new Map()); const responseAnchorRefs = useRef(new Map()); const promptAnchorRefs = useRef(new Map()); const isInteractingRef = useRef(false); const deferredSnapshotRef = useRef(null); const liveRefreshTimerRef = useRef(null); const snapshotRefreshPromiseRef = useRef | null>(null); const pendingSilentRefreshRef = useRef(false); const scrollSyncFrameRef = useRef(null); const scrollIdleTimerRef = useRef(null); const programmaticScrollTargetRef = useRef<'top' | 'bottom' | null>(null); const lastScrollTopRef = useRef(0); const [snapshot, setSnapshot] = useState(null); const [isLoading, setIsLoading] = useState(true); const [, setIsRefreshing] = useState(false); const [isLiveConnected, setIsLiveConnected] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [requiresAccessPin, setRequiresAccessPin] = useState(false); const hasSnapshotRef = useRef(false); const requiresAccessPinRef = useRef(false); const [accessPinInput, setAccessPinInput] = useState(''); const [accessPinSubmitError, setAccessPinSubmitError] = useState(''); const [draftText, setDraftText] = useState(''); const [composerAttachments, setComposerAttachments] = useState([]); const [isSending, setIsSending] = useState(false); const [isUploadingComposerAttachment, setIsUploadingComposerAttachment] = useState(false); const [immediateSendPinnedByToken, setImmediateSendPinnedByToken] = useState>(() => readStoredShareImmediateSendPinnedByToken(), ); const [nowMs, setNowMs] = useState(() => Date.now()); const [expandMode, setExpandMode] = useState('pending'); const [latestRequestId, setLatestRequestId] = useState(''); const [pendingPromptSelections, setPendingPromptSelections] = useState>({}); const [pendingPromptCompletionRequestIds, setPendingPromptCompletionRequestIds] = useState([]); const [pendingVerificationCompletionRequestIds, setPendingVerificationCompletionRequestIds] = useState([]); const [pendingRequestCancellationIds, setPendingRequestCancellationIds] = useState([]); const [pendingRequestRetryIds, setPendingRequestRetryIds] = useState([]); const [replyReferenceRequestId, setReplyReferenceRequestId] = useState(''); const [previousQuestionModalRequestId, setPreviousQuestionModalRequestId] = useState(''); const [showScrollToTop, setShowScrollToTop] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [isClearingConversation, setIsClearingConversation] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isTokenUsageOpen, setIsTokenUsageOpen] = useState(false); const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false); const [isSavingRoomSettings, setIsSavingRoomSettings] = useState(false); const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security'>('chat-type'); const [editingRoomTitle, setEditingRoomTitle] = useState(''); const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState(null); const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState([]); const [isEditingRoomDefaultContextsDirty, setIsEditingRoomDefaultContextsDirty] = useState(false); const [editingRoomCustomContextTitle, setEditingRoomCustomContextTitle] = useState(''); const [editingRoomCustomContextContent, setEditingRoomCustomContextContent] = useState(''); const [editingRoomUseAccessPin, setEditingRoomUseAccessPin] = useState(false); const [editingRoomAccessPin, setEditingRoomAccessPin] = useState(''); const [editingRoomAccessPinPromptTtlMinutes, setEditingRoomAccessPinPromptTtlMinutes] = useState(null); const [editingRoomNotifyOffline, setEditingRoomNotifyOffline] = useState(false); const [roomNotificationClientStatus, setRoomNotificationClientStatus] = useState(() => buildShareNotificationClientStatus({ roomEnabled: false, appEnabled: appConfig.chat.receiveRoomNotifications, permission: getClientNotificationPermission(), registrationReady: false, }), ); const [isRefreshingRoomNotificationStatus, setIsRefreshingRoomNotificationStatus] = useState(false); const [isSendingRoomNotificationTest, setIsSendingRoomNotificationTest] = useState(false); const [searchKeyword, setSearchKeyword] = useState(''); const [searchPanelMode, setSearchPanelMode] = useState('all'); const [selectedAppEnvironment, setSelectedAppEnvironment] = useState(() => typeof window === 'undefined' ? 'preview' : resolveShareAppEnvironmentFromOrigin(window.location.origin), ); const [programTarget, setProgramTarget] = useState(null); const [programMinimizedTarget, setProgramMinimizedTarget] = useState(null); const [isProgramMinimized, setIsProgramMinimized] = useState(false); const programMinimizedCardRef = useRef(null); const programMinimizedDragStateRef = useRef<{ pointerId: number; lastX: number; lastY: number; captureTarget: HTMLDivElement; } | null>(null); const programMinimizedMovedRef = useRef(false); const programMinimizedPositionRef = useRef(getDefaultProgramMinimizedPosition()); const [programMinimizedPosition, setProgramMinimizedPosition] = useState(() => programMinimizedPositionRef.current); const composerRef = useRef(null); const composerAttachmentInputRef = useRef(null); const composerInputShellRef = useRef(null); const immediateSendHoldTimerRef = useRef(null); const suppressImmediateSendClickRef = useRef(false); const focusedMobileInputRef = useRef(null); const composerFocusScrollTimerIdsRef = useRef([]); const [isComposerViewportCompacted, setIsComposerViewportCompacted] = useState(false); const [appLaunchUsage, setAppLaunchUsage] = useState(() => readShareAppLaunchUsage()); hasSnapshotRef.current = snapshot != null; requiresAccessPinRef.current = requiresAccessPin; const shareTokenSetting = snapshot?.share.tokenSetting ?? null; const shareAllowedAppIdSet = useMemo( () => buildShareAllowedAppIdSet(shareTokenSetting?.allowedAppIds, snapshot?.share.permissions), [shareTokenSetting?.allowedAppIds, snapshot?.share.permissions], ); const sharePermissionSet = useMemo( () => new Set((snapshot?.share.permissions ?? []).map((item) => item.trim().toLowerCase()).filter(Boolean)), [snapshot?.share.permissions], ); const allowedPlayAppEntries = useMemo( () => getReadyPlayAppEntries().filter((entry) => shareAllowedAppIdSet.has(entry.id)), [shareAllowedAppIdSet], ); const sortedAllowedPlayAppEntries = useMemo( () => [...allowedPlayAppEntries].sort((left, right) => ( compareShareAppLaunchOrder( left.id, right.id, left.usagePriority ?? 0, right.usagePriority ?? 0, appLaunchUsage, ) )), [allowedPlayAppEntries, appLaunchUsage], ); useEffect(() => { if (sortedAllowedPlayAppEntries.length === 0) { return; } if (sortedAllowedPlayAppEntries.some((entry) => isPlayAppSupportedInEnvironment(entry, selectedAppEnvironment))) { return; } const nextEnvironment = resolveFirstSupportedShareAppEnvironment(sortedAllowedPlayAppEntries); if (nextEnvironment !== selectedAppEnvironment) { setSelectedAppEnvironment(nextEnvironment); } }, [selectedAppEnvironment, sortedAllowedPlayAppEntries]); const allowedManagementApps = useMemo( () => SHARE_MANAGEMENT_APP_OPTIONS.filter((option) => shareAllowedAppIdSet.has(option.value)), [shareAllowedAppIdSet], ); const sortedAllowedManagementApps = useMemo( () => [...allowedManagementApps].sort((left, right) => ( compareShareAppLaunchOrder(left.value, right.value, left.usagePriority, right.usagePriority, appLaunchUsage) )), [allowedManagementApps, appLaunchUsage], ); const hasSharedRoomSettingsApp = shareAllowedAppIdSet.has('chat-room-settings'); const hasWorkServerCommandApp = shareAllowedAppIdSet.has('server-command'); const canManageSharedTokenSetting = sharePermissionSet.has('manage') && shareAllowedAppIdSet.has('token-setting'); const canManageSharedRoomSettings = sharePermissionSet.has('manage') && hasSharedRoomSettingsApp; const canEditSharedRoomAccessPin = hasSharedRoomSettingsApp; const canOpenSharedRoomSettings = hasSharedRoomSettingsApp; const isImmediateSendPinned = normalizedToken ? immediateSendPinnedByToken[normalizedToken] === true : false; const [shareWorkServerCommand, setShareWorkServerCommand] = useState(null); const refreshRoomNotificationStatus = useCallback(async (roomEnabled: boolean) => { const permission = getClientNotificationPermission(); const appEnabled = appConfig.chat.receiveRoomNotifications; const fallbackStatus = buildShareNotificationClientStatus({ roomEnabled, appEnabled, permission, registrationReady: false, }); setRoomNotificationClientStatus(fallbackStatus); if (isPreviewRuntime() || !hasSecureOrigin()) { return fallbackStatus; } setIsRefreshingRoomNotificationStatus(true); try { if (permission !== 'granted') { const nextStatus = buildShareNotificationClientStatus({ roomEnabled, appEnabled, permission, registrationReady: false, }); setRoomNotificationClientStatus(nextStatus); return nextStatus; } const registration = await getPushServiceWorkerRegistration(); const deviceId = getSavedNotificationDeviceId().trim(); if (!registration || !deviceId) { setRoomNotificationClientStatus(fallbackStatus); return fallbackStatus; } const subscription = await registration.pushManager.getSubscription(); if (subscription) { await syncExistingWebPushSubscriptionRegistration(registration, { deviceId }).catch(() => null); } const nextStatus = buildShareNotificationClientStatus({ roomEnabled, appEnabled, permission, registrationReady: Boolean(subscription), }); setRoomNotificationClientStatus(nextStatus); return nextStatus; } catch { setRoomNotificationClientStatus(fallbackStatus); return fallbackStatus; } finally { setIsRefreshingRoomNotificationStatus(false); } }, [appConfig.chat.receiveRoomNotifications]); const ensureRoomNotificationRegistration = useCallback(async () => { if (isPreviewRuntime()) { throw new Error('미리보기 런타임에서는 알림 등록을 지원하지 않습니다.'); } if (!hasSecureOrigin()) { throw new Error('알림은 HTTPS 또는 localhost 환경에서만 사용할 수 있습니다.'); } if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { throw new Error('아이폰에서는 홈 화면에 추가한 PWA에서만 웹 푸시를 사용할 수 있습니다.'); } if ( typeof window === 'undefined' || typeof Notification === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window) ) { throw new Error('현재 브라우저에서는 웹 푸시를 지원하지 않습니다.'); } let permission = Notification.permission; if (permission !== 'granted') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { throw new Error('브라우저 알림 권한을 허용한 뒤 다시 시도해 주세요.'); } if (!appConfig.chat.receiveRoomNotifications) { const savedAppConfig = await saveAppConfigToServer({ ...appConfig, chat: { ...appConfig.chat, receiveRoomNotifications: true, }, }, { shareToken: normalizedToken, skipAutomationNotifications: true, }); setStoredAppConfig(savedAppConfig); } let registration = await getPushServiceWorkerRegistration(); if (!registration) { await waitForDuration(1500); registration = await getPushServiceWorkerRegistration(); } if (!registration) { throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.'); } const deviceId = getSavedNotificationDeviceId().trim(); if (!deviceId) { throw new Error('현재 기기 알림 식별자를 확인하지 못했습니다.'); } const subscription = await ensureWebPushSubscriptionRegistered(registration, { deviceId }); const nextStatus = buildShareNotificationClientStatus({ roomEnabled: true, appEnabled: true, permission: 'granted', registrationReady: Boolean(subscription), }); setRoomNotificationClientStatus(nextStatus); return { registration, deviceId, subscription }; }, [appConfig, normalizedToken]); const handleSendRoomNotificationTest = useCallback(async () => { if (!snapshot?.conversation.sessionId) { message.warning('공유 채팅방 정보를 다시 불러온 뒤 시도해 주세요.'); return; } if (isPreviewRuntime()) { message.warning('미리보기 런타임에서는 테스트 알림 전송을 지원하지 않습니다.'); return; } if (!hasSecureOrigin()) { message.warning('테스트 알림은 HTTPS 또는 localhost 환경에서만 확인할 수 있습니다.'); return; } if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { message.warning('아이폰에서는 홈 화면에 추가한 PWA에서만 웹 푸시 테스트를 확인할 수 있습니다.'); return; } if ( typeof window === 'undefined' || typeof Notification === 'undefined' || typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !('PushManager' in window) ) { message.warning('현재 브라우저에서는 웹 푸시 테스트를 지원하지 않습니다.'); return; } setIsSendingRoomNotificationTest(true); try { let permission = Notification.permission; if (permission !== 'granted') { permission = await Notification.requestPermission(); } if (permission !== 'granted') { throw new Error('브라우저 알림 권한을 허용한 뒤 다시 시도해 주세요.'); } const registration = await getPushServiceWorkerRegistration(); if (!registration) { throw new Error('알림 서비스워커 준비가 아직 끝나지 않았습니다. 잠시 후 다시 시도해 주세요.'); } const deviceId = getSavedNotificationDeviceId().trim(); if (!deviceId) { throw new Error('현재 기기 알림 식별자를 확인하지 못했습니다.'); } await ensureWebPushSubscriptionRegistered(registration, { deviceId }); const result = await sendClientNotification({ title: '공유채팅방 테스트 알림', body: `${snapshot.conversation.title?.trim() || '공유채팅방'} 알림 테스트입니다.`, threadId: `chat:${snapshot.conversation.sessionId}:share-notification-test`, targetDeviceIds: [deviceId], data: { category: 'chat', type: 'chat-share-room-test', sessionId: snapshot.conversation.sessionId, title: snapshot.conversation.title?.trim() || '공유채팅방', }, }); if (result.web.sentCount > 0) { message.success('현재 기기로 테스트 알림 전송을 요청했습니다.'); } else if (shouldFallbackToLocalNotification(result)) { const shown = await showLocalClientNotification({ title: '공유채팅방 테스트 알림', body: `${snapshot.conversation.title?.trim() || '공유채팅방'} 알림 테스트입니다.`, threadId: `chat:${snapshot.conversation.sessionId}:share-notification-test-local`, data: { category: 'chat', type: 'chat-share-room-test-local', sessionId: snapshot.conversation.sessionId, }, }); if (!shown) { throw new Error(result.web.reason || '테스트 알림 전송 대상을 찾지 못했습니다.'); } message.warning('서버 푸시 전송은 확인되지 않아 현재 브라우저 로컬 알림으로 대체했습니다.'); } else { throw new Error(result.web.reason || '테스트 알림 전송 결과를 확인하지 못했습니다.'); } await refreshRoomNotificationStatus(editingRoomNotifyOffline); } catch (error) { message.error(error instanceof Error ? error.message : '테스트 알림 전송에 실패했습니다.'); await refreshRoomNotificationStatus(editingRoomNotifyOffline); } finally { setIsSendingRoomNotificationTest(false); } }, [editingRoomNotifyOffline, message, refreshRoomNotificationStatus, snapshot?.conversation.sessionId, snapshot?.conversation.title]); useLayoutEffect(() => { if (typeof window === 'undefined') { return undefined; } const sharePathname = `${window.location.pathname}${window.location.search}${window.location.hash}`; const activeInstallAppEntry = findReadyPlayAppEntryById(programTarget?.appId); const activeInstallAppId = activeInstallAppEntry?.id ?? ''; const manifestObjectUrl = activeInstallAppEntry ? createInstallManifestObjectUrl({ startPath: buildSharePlayAppInstallPath(activeInstallAppId, normalizedToken), scope: '/play/apps', name: activeInstallAppEntry.name, shortName: activeInstallAppEntry.name, description: `${activeInstallAppEntry.name} 앱을 홈 화면에서 바로 엽니다.`, themeColor: resolveSharePlayAppInstallThemeColor(activeInstallAppId), backgroundColor: '#eff5ff', }) : createChatShareManifestObjectUrl(sharePathname, snapshot?.conversation.title); const restoreManifest = activeInstallAppEntry ? swapInstallDocumentMetadata({ manifestHref: manifestObjectUrl, title: activeInstallAppEntry.name, themeColor: resolveSharePlayAppInstallThemeColor(activeInstallAppId), }) : swapManifestForChatShare(manifestObjectUrl, snapshot?.conversation.title); return () => { restoreManifest(); if (manifestObjectUrl) { window.URL.revokeObjectURL(manifestObjectUrl); } }; }, [normalizedToken, programTarget?.appId, snapshot?.conversation.title]); useEffect(() => { if (!hasWorkServerCommandApp) { setShareWorkServerCommand(null); return; } let cancelled = false; void fetchServerCommands({ shareToken: normalizedToken }) .then((items) => { if (cancelled) { return; } setShareWorkServerCommand(items.find((item) => item.key === 'work-server') ?? null); }) .catch((error) => { console.error('failed to load shared work-server command status', error); if (!cancelled) { setShareWorkServerCommand(null); } }); return () => { cancelled = true; }; }, [hasWorkServerCommandApp, normalizedToken]); const setRequestAnchorRef = useCallback((requestId: string, element: HTMLElement | null) => { const normalizedRequestId = requestId.trim(); if (!normalizedRequestId) { return; } if (!element) { requestAnchorRefs.current.delete(normalizedRequestId); return; } requestAnchorRefs.current.set(normalizedRequestId, element); }, []); const setResponseAnchorRef = useCallback((messageId: number, element: HTMLDivElement | null) => { if (!Number.isFinite(messageId)) { return; } if (!element) { responseAnchorRefs.current.delete(messageId); return; } responseAnchorRefs.current.set(messageId, element); }, []); const setPromptAnchorRef = useCallback((messageId: number, promptIndex: number, element: HTMLDivElement | null) => { if (!Number.isFinite(messageId) || !Number.isFinite(promptIndex)) { return; } const anchorKey = buildSharePromptAnchorKey(messageId, promptIndex); if (!element) { promptAnchorRefs.current.delete(anchorKey); return; } promptAnchorRefs.current.set(anchorKey, element); }, []); const { chatTypes, isLoading: isChatTypesLoading, errorMessage: chatTypesErrorMessage } = useChatTypeRegistry(); const { defaultContexts, chatTypeDefaults, roomContexts, setStore: setChatContextSettingsStore, isLoading: isChatContextSettingsLoading, errorMessage: chatContextSettingsErrorMessage, } = useChatContextSettingsRegistry(); const enabledChatTypes = useMemo(() => chatTypes.filter((item) => item.enabled), [chatTypes]); const enabledDefaultContexts = useMemo(() => defaultContexts.filter((item) => item.enabled), [defaultContexts]); const currentSharedChatTypeId = useMemo(() => { const requestChatTypeIds = [ snapshot?.conversation.chatTypeId, snapshot?.conversation.lastChatTypeId, snapshot?.targetRequest.chatTypeId, ...(snapshot?.requests ?? []).map((item) => item.chatTypeId), ]; return requestChatTypeIds.find((item) => String(item ?? '').trim())?.trim() ?? null; }, [snapshot?.conversation.chatTypeId, snapshot?.conversation.lastChatTypeId, snapshot?.requests, snapshot?.targetRequest.chatTypeId]); const activeRoomContextSettings = useMemo( () => resolveChatRoomContextSettings(roomContexts, snapshot?.conversation.sessionId ?? null), [roomContexts, snapshot?.conversation.sessionId], ); const minimizedProgramTarget = programMinimizedTarget ?? programTarget; const openSharedRoomSettings = useCallback(() => { if (!snapshot?.conversation.sessionId) { return; } const nextChatTypeId = currentSharedChatTypeId ?? enabledChatTypes[0]?.id ?? null; const fallbackRoomTitle = snapshot.conversation.title?.trim() || snapshot.targetRequest.userText?.trim() || snapshot.requests?.[0]?.userText?.trim() || '-'; setEditingRoomTitle(fallbackRoomTitle); setEditingRoomChatTypeId(nextChatTypeId); setEditingRoomDefaultContextIds(resolveShareRoomDefaultContextIds(activeRoomContextSettings, chatTypeDefaults, nextChatTypeId)); setIsEditingRoomDefaultContextsDirty(false); setEditingRoomCustomContextTitle(activeRoomContextSettings?.customContextTitle ?? ''); setEditingRoomCustomContextContent(activeRoomContextSettings?.customContextContent ?? ''); setEditingRoomNotifyOffline(snapshot.conversation.notifyOffline === true); setEditingRoomUseAccessPin(snapshot.share.hasAccessPin === true); setEditingRoomAccessPin(''); setEditingRoomAccessPinPromptTtlMinutes(resolveAccessPinPromptTtlMinutes(snapshot.share.accessPinPromptTtlMinutes)); setRoomSettingsTabKey('chat-type'); setIsRoomSettingsOpen(true); }, [ activeRoomContextSettings?.customContextContent, activeRoomContextSettings?.customContextTitle, activeRoomContextSettings?.defaultContextIds, chatTypeDefaults, currentSharedChatTypeId, enabledChatTypes, snapshot?.conversation.notifyOffline, snapshot?.conversation.title, snapshot?.requests, snapshot?.share.accessPinPromptTtlMinutes, snapshot?.share.hasAccessPin, snapshot?.conversation.sessionId, snapshot?.targetRequest.userText, ]); useEffect(() => { if (!isRoomSettingsOpen) { return; } void refreshRoomNotificationStatus(editingRoomNotifyOffline); }, [appConfig.chat.receiveRoomNotifications, editingRoomNotifyOffline, isRoomSettingsOpen, refreshRoomNotificationStatus]); const handleSaveSharedRoomSettings = useCallback(async () => { if (!snapshot?.conversation.sessionId) { setIsRoomSettingsOpen(false); return; } const nextChatType = enabledChatTypes.find((item) => item.id === editingRoomChatTypeId) ?? null; if (canManageSharedRoomSettings && !nextChatType) { message.warning('채팅유형을 먼저 선택하세요.'); return; } if (!canManageSharedRoomSettings && !canEditSharedRoomAccessPin) { setIsRoomSettingsOpen(false); return; } const normalizedDefaultContextIds = Array.from( new Set( editingRoomDefaultContextIds .map((item) => item.trim()) .filter((item) => enabledDefaultContexts.some((context) => context.id === item)), ), ); const inheritedDefaultContextIds = resolveChatTypeDefaultContextIds(chatTypeDefaults, nextChatType?.id ?? null); const nextCustomContextTitle = editingRoomCustomContextTitle.trim(); const nextCustomContextContent = editingRoomCustomContextContent.trim(); const shouldPersistRoomDefaultContextIds = !areStringListsEqual(normalizedDefaultContextIds, inheritedDefaultContextIds); const shouldPersistRoomCustomContext = Boolean(nextCustomContextTitle || nextCustomContextContent); const currentRoomDefaultContextIds = activeRoomContextSettings?.defaultContextIds ?? []; const currentRoomCustomContextTitle = activeRoomContextSettings?.customContextTitle?.trim() ?? ''; const currentRoomCustomContextContent = activeRoomContextSettings?.customContextContent?.trim() ?? ''; const currentRoomCodexParticipants = activeRoomContextSettings?.codexParticipants ?? []; const shouldSaveRoomContextSettings = canManageSharedRoomSettings && ( !areStringListsEqual(normalizedDefaultContextIds, currentRoomDefaultContextIds) || nextCustomContextTitle !== currentRoomCustomContextTitle || nextCustomContextContent !== currentRoomCustomContextContent ); const normalizedRoomTitle = editingRoomTitle.trim(); const normalizedAccessPin = editingRoomAccessPin.trim(); const currentHasAccessPin = snapshot?.share.hasAccessPin === true; const currentAccessPinPromptTtlMinutes = resolveAccessPinPromptTtlMinutes(snapshot?.share.accessPinPromptTtlMinutes); const shouldSaveConversationSettings = canManageSharedRoomSettings && Boolean(nextChatType) && ( normalizedRoomTitle !== (snapshot?.conversation.title?.trim() || '') || !(snapshot?.conversation.title?.trim()) || currentSharedChatTypeId !== nextChatType.id || snapshot.conversation.notifyOffline !== editingRoomNotifyOffline ); if (canManageSharedRoomSettings && !normalizedRoomTitle) { message.warning('채팅방 이름을 입력하세요.'); return; } if (canEditSharedRoomAccessPin && editingRoomUseAccessPin && !currentHasAccessPin && !normalizedAccessPin) { message.warning('비밀번호를 새로 켜려면 숫자 4자리를 입력하세요.'); return; } if (canEditSharedRoomAccessPin && normalizedAccessPin && !/^\d{4}$/u.test(normalizedAccessPin)) { message.warning('비밀번호는 숫자 4자리로 입력하세요.'); return; } setIsSavingRoomSettings(true); try { if ( shouldSaveConversationSettings && editingRoomNotifyOffline && ( snapshot.conversation.notifyOffline !== editingRoomNotifyOffline || !appConfig.chat.receiveRoomNotifications || roomNotificationClientStatus.tone !== 'success' ) ) { await ensureRoomNotificationRegistration(); } if (shouldSaveRoomContextSettings) { const shouldKeepRoomContextRecord = shouldPersistRoomDefaultContextIds || shouldPersistRoomCustomContext || currentRoomCodexParticipants.length > 0; const nextRoomContexts = shouldKeepRoomContextRecord ? upsertChatRoomContextSettings(roomContexts, { sessionId: snapshot.conversation.sessionId, defaultContextIds: normalizedDefaultContextIds, customContextTitle: nextCustomContextTitle, customContextContent: nextCustomContextContent, codexParticipants: currentRoomCodexParticipants, }) : roomContexts.filter((item) => item.sessionId !== snapshot.conversation.sessionId); await setChatContextSettingsStore({ defaultContexts, chatTypeDefaults, roomContexts: nextRoomContexts, }); } const shouldSaveAccessPinSettings = canEditSharedRoomAccessPin && ( (!editingRoomUseAccessPin && currentHasAccessPin) || (editingRoomUseAccessPin && !currentHasAccessPin) || Boolean(normalizedAccessPin) || (editingRoomUseAccessPin && currentHasAccessPin && editingRoomAccessPinPromptTtlMinutes !== currentAccessPinPromptTtlMinutes) ); const nextAccessPinUpdate = canEditSharedRoomAccessPin ? !editingRoomUseAccessPin ? currentHasAccessPin ? null : undefined : normalizedAccessPin ? normalizedAccessPin : undefined : undefined; if (shouldSaveAccessPinSettings || shouldSaveConversationSettings) { const roomSecurity = await saveChatShareRoomSettings(normalizedToken, { accessPin: nextAccessPinUpdate, accessPinPromptTtlMinutes: editingRoomUseAccessPin ? editingRoomAccessPinPromptTtlMinutes : null, chatTypeId: shouldSaveConversationSettings ? nextChatType?.id ?? null : undefined, chatTypeLabel: shouldSaveConversationSettings ? nextChatType?.name ?? null : undefined, title: shouldSaveConversationSettings ? normalizedRoomTitle : undefined, notifyOffline: shouldSaveConversationSettings ? editingRoomNotifyOffline : undefined, }); if (nextAccessPinUpdate === null) { setAccessPinInput(''); } if (!roomSecurity.hasAccessPin) { setAccessPinSubmitError(''); } setSnapshot((previous) => previous ? { ...previous, share: { ...previous.share, hasAccessPin: roomSecurity.hasAccessPin, accessPinPromptTtlMinutes: roomSecurity.accessPinPromptTtlMinutes, }, conversation: roomSecurity.conversation ? { ...previous.conversation, ...roomSecurity.conversation, title: roomSecurity.conversation.title ?? previous.conversation.title, } : previous.conversation, } : previous, ); } message.success(canManageSharedRoomSettings ? '공유 채팅방 설정을 저장했습니다.' : '공유 비밀번호를 저장했습니다.'); setIsEditingRoomDefaultContextsDirty(false); setIsRoomSettingsOpen(false); } catch (error) { message.error(error instanceof Error ? error.message : '공유 채팅방 설정 저장에 실패했습니다.'); } finally { setIsSavingRoomSettings(false); } }, [ chatTypeDefaults, canEditSharedRoomAccessPin, canManageSharedRoomSettings, defaultContexts, editingRoomChatTypeId, editingRoomTitle, editingRoomAccessPin, editingRoomAccessPinPromptTtlMinutes, editingRoomCustomContextContent, editingRoomCustomContextTitle, editingRoomDefaultContextIds, editingRoomNotifyOffline, editingRoomUseAccessPin, enabledChatTypes, enabledDefaultContexts, ensureRoomNotificationRegistration, currentSharedChatTypeId, message, normalizedToken, roomNotificationClientStatus.tone, activeRoomContextSettings?.codexParticipants, activeRoomContextSettings?.customContextContent, activeRoomContextSettings?.customContextTitle, activeRoomContextSettings?.defaultContextIds, roomContexts, setChatContextSettingsStore, snapshot?.conversation.notifyOffline, snapshot?.conversation.sessionId, snapshot?.share.accessPinPromptTtlMinutes, snapshot?.share.hasAccessPin, ]); useEffect(() => { if (!minimizedProgramTarget || !isProgramMinimized) { programMinimizedDragStateRef.current = null; programMinimizedMovedRef.current = false; return; } const clampPosition = (position: { x: number; y: number }) => { const cardWidth = programMinimizedCardRef.current?.offsetWidth ?? PROGRAM_MINIMIZED_DEFAULT_WIDTH; const cardHeight = programMinimizedCardRef.current?.offsetHeight ?? PROGRAM_MINIMIZED_DEFAULT_HEIGHT; const maxX = Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, window.innerWidth - cardWidth - PROGRAM_MINIMIZED_VIEWPORT_PADDING); const maxY = Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, window.innerHeight - cardHeight - PROGRAM_MINIMIZED_VIEWPORT_PADDING); return { x: clampProgramMinimizedValue(position.x, PROGRAM_MINIMIZED_VIEWPORT_PADDING, maxX), y: clampProgramMinimizedValue(position.y, PROGRAM_MINIMIZED_VIEWPORT_PADDING, maxY), }; }; const syncPosition = (position: { x: number; y: number }) => { const nextPosition = clampPosition(position); programMinimizedPositionRef.current = nextPosition; setProgramMinimizedPosition(nextPosition); }; const handleResize = () => { syncPosition(programMinimizedPositionRef.current); }; const handlePointerMove = (event: PointerEvent) => { const dragState = programMinimizedDragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } const deltaX = event.clientX - dragState.lastX; const deltaY = event.clientY - dragState.lastY; if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { programMinimizedMovedRef.current = true; } dragState.lastX = event.clientX; dragState.lastY = event.clientY; syncPosition({ x: programMinimizedPositionRef.current.x + deltaX, y: programMinimizedPositionRef.current.y + deltaY, }); }; const finishPointerDrag = (event: PointerEvent) => { const dragState = programMinimizedDragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { dragState.captureTarget.releasePointerCapture(event.pointerId); } programMinimizedDragStateRef.current = null; }; window.addEventListener('resize', handleResize); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', finishPointerDrag); window.addEventListener('pointercancel', finishPointerDrag); handleResize(); return () => { window.removeEventListener('resize', handleResize); window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', finishPointerDrag); window.removeEventListener('pointercancel', finishPointerDrag); }; }, [isProgramMinimized, minimizedProgramTarget]); const handleProgramMinimizedPointerDown = useCallback((event: ReactPointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) { return; } const captureTarget = event.currentTarget; programMinimizedDragStateRef.current = { pointerId: event.pointerId, lastX: event.clientX, lastY: event.clientY, captureTarget, }; programMinimizedMovedRef.current = false; captureTarget.setPointerCapture(event.pointerId); }, []); const handleRestoreProgram = useCallback(() => { if (programMinimizedTarget) { setProgramTarget(programMinimizedTarget); } setIsProgramMinimized(false); }, [programMinimizedTarget]); const handleReloadPage = useCallback(() => { if (typeof window === 'undefined') { return; } window.location.reload(); }, []); useEffect(() => { setAccessPinInput(''); setAccessPinSubmitError(''); setRequiresAccessPin(false); }, [normalizedToken]); const handleCloseProgram = useCallback(() => { setProgramTarget(null); setProgramMinimizedTarget(null); setIsProgramMinimized(false); }, []); const minimizedProgramCard = minimizedProgramTarget && isProgramMinimized ? (
) : null; const syncScrollJumpVisibility = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { setShowScrollToTop(false); setShowScrollToBottom(false); return; } const topDistance = scrollContainer.scrollTop; const bottomDistance = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight; const maxScrollDistance = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); const isAtTop = topDistance <= SCROLL_JUMP_HIDE_THRESHOLD; const isAtBottom = bottomDistance <= SCROLL_JUMP_HIDE_THRESHOLD; const scrollDelta = topDistance - lastScrollTopRef.current; lastScrollTopRef.current = topDistance; if (maxScrollDistance < SCROLL_JUMP_MIN_OVERFLOW) { programmaticScrollTargetRef.current = null; setShowScrollToTop(false); setShowScrollToBottom(false); return; } if (programmaticScrollTargetRef.current === 'top') { if (isAtTop) { programmaticScrollTargetRef.current = null; } setShowScrollToTop(false); setShowScrollToBottom(false); return; } if (programmaticScrollTargetRef.current === 'bottom') { if (isAtBottom) { programmaticScrollTargetRef.current = null; } setShowScrollToTop(false); setShowScrollToBottom(false); return; } if (Math.abs(scrollDelta) < SCROLL_JUMP_DIRECTION_THRESHOLD) { setShowScrollToTop(false); setShowScrollToBottom(false); return; } if (scrollIdleTimerRef.current) { window.clearTimeout(scrollIdleTimerRef.current); } scrollIdleTimerRef.current = window.setTimeout(() => { scrollIdleTimerRef.current = null; setShowScrollToTop(false); setShowScrollToBottom(false); }, SCROLL_JUMP_IDLE_HIDE_DELAY_MS); if (scrollDelta < 0) { setShowScrollToTop(!isAtTop); setShowScrollToBottom(false); return; } setShowScrollToTop(false); setShowScrollToBottom(!isAtBottom); }, []); const queueScrollJumpVisibilitySync = useCallback(() => { if (scrollSyncFrameRef.current) { window.cancelAnimationFrame(scrollSyncFrameRef.current); } scrollSyncFrameRef.current = window.requestAnimationFrame(() => { scrollSyncFrameRef.current = null; syncScrollJumpVisibility(); }); }, [syncScrollJumpVisibility]); const handleScrollContainerScroll = useCallback(() => { queueScrollJumpVisibilitySync(); }, [queueScrollJumpVisibilitySync]); const handleScrollToTop = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } programmaticScrollTargetRef.current = 'top'; setShowScrollToTop(false); setShowScrollToBottom(false); scrollContainer.scrollTo({ top: 0, behavior: 'smooth', }); }, []); const handleScrollToBottom = useCallback(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return; } programmaticScrollTargetRef.current = 'bottom'; setShowScrollToTop(false); setShowScrollToBottom(false); scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }); }, []); useEffect(() => { const pageElement = pageRef.current; if (!pageElement) { return undefined; } const flushDeferredSnapshot = () => { if (!deferredSnapshotRef.current) { return; } setSnapshot(deferredSnapshotRef.current); deferredSnapshotRef.current = null; }; const handleFocusIn = (event: FocusEvent) => { isInteractingRef.current = isSnapshotDeferrableFocusTarget(event.target); }; const handleFocusOut = () => { window.setTimeout(() => { const activeElement = document.activeElement; const stillInside = activeElement instanceof Node && pageElement.contains(activeElement); if (stillInside && isSnapshotDeferrableFocusTarget(activeElement)) { return; } isInteractingRef.current = false; flushDeferredSnapshot(); }, 0); }; pageElement.addEventListener('focusin', handleFocusIn); pageElement.addEventListener('focusout', handleFocusOut); return () => { pageElement.removeEventListener('focusin', handleFocusIn); pageElement.removeEventListener('focusout', handleFocusOut); }; }, []); useEffect(() => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { return undefined; } const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => { queueScrollJumpVisibilitySync(); }); lastScrollTopRef.current = scrollContainer.scrollTop; syncScrollJumpVisibility(); window.addEventListener('resize', queueScrollJumpVisibilitySync); resizeObserver?.observe(scrollContainer); resizeObserver?.observe(scrollContainer.firstElementChild ?? scrollContainer); return () => { if (scrollSyncFrameRef.current) { window.cancelAnimationFrame(scrollSyncFrameRef.current); scrollSyncFrameRef.current = null; } if (scrollIdleTimerRef.current) { window.clearTimeout(scrollIdleTimerRef.current); scrollIdleTimerRef.current = null; } window.removeEventListener('resize', queueScrollJumpVisibilitySync); resizeObserver?.disconnect(); }; }, [queueScrollJumpVisibilitySync, syncScrollJumpVisibility]); const refreshSnapshot = useCallback(async (options?: { initialLoad?: boolean; silent?: boolean; sharePin?: string | null }) => { if (!normalizedToken) { return false; } const initialLoad = options?.initialLoad === true; const silent = options?.silent === true; if (snapshotRefreshPromiseRef.current) { if (!initialLoad) { pendingSilentRefreshRef.current = pendingSilentRefreshRef.current || silent; } return snapshotRefreshPromiseRef.current; } if (options?.initialLoad) { setIsLoading(true); } else { setIsRefreshing(true); } const refreshTask = (async () => { try { const nextSnapshot = await fetchChatShareSnapshot(normalizedToken, { sharePin: options?.sharePin, }); const shouldApplyImmediately = !isInteractingRef.current || initialLoad || requiresAccessPinRef.current || !hasSnapshotRef.current; if (!shouldApplyImmediately) { deferredSnapshotRef.current = nextSnapshot; } else { setSnapshot(nextSnapshot); deferredSnapshotRef.current = null; } if (nextSnapshot.share.hasAccessPin) { const resolvedPin = normalizeAccessPinInput(options?.sharePin ?? ''); const persistedPin = resolvedPin || getStoredChatShareAccessPin(normalizedToken); if (persistedPin) { setStoredChatShareAccessPin(normalizedToken, persistedPin, { expiresAt: nextSnapshot.share.accessPinSessionExpiresAt, ttlMinutes: nextSnapshot.share.accessPinPromptTtlMinutes, }); } } else { setStoredChatShareAccessPin(normalizedToken, null); } setErrorMessage(''); setRequiresAccessPin(false); setAccessPinSubmitError(''); return true; } catch (error) { if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { setStoredChatShareAccessPin(normalizedToken, null); setRequiresAccessPin(true); if (error.code === 'share_pin_invalid') { setAccessPinInput(''); setAccessPinSubmitError(error.message); } else { setAccessPinSubmitError(''); } } if (!silent) { setErrorMessage(error instanceof Error ? error.message : '공유 화면을 불러오지 못했습니다.'); } return false; } finally { snapshotRefreshPromiseRef.current = null; setIsLoading(false); setIsRefreshing(false); if (pendingSilentRefreshRef.current) { pendingSilentRefreshRef.current = false; window.setTimeout(() => { void refreshSnapshot({ silent: true }); }, 0); } } })(); snapshotRefreshPromiseRef.current = refreshTask; return refreshTask; }, [normalizedToken]); const scheduleSnapshotRefresh = useCallback( (delayMs = 120) => { if (!normalizedToken) { return; } if (liveRefreshTimerRef.current != null) { window.clearTimeout(liveRefreshTimerRef.current); } liveRefreshTimerRef.current = window.setTimeout(() => { liveRefreshTimerRef.current = null; void refreshSnapshot({ silent: true }); }, delayMs); }, [normalizedToken, refreshSnapshot], ); useEffect(() => { if (!normalizedToken) { setErrorMessage('공유 링크가 없습니다.'); setIsLoading(false); return undefined; } void refreshSnapshot({ initialLoad: true }); return undefined; }, [normalizedToken, refreshSnapshot]); const handleUnlockShare = useCallback(async (inputPin?: string) => { const normalizedPin = normalizeAccessPinInput(inputPin ?? accessPinInput.trim()); if (!/^\d{4}$/.test(normalizedPin)) { setAccessPinSubmitError('비밀번호는 숫자 4자리로 입력하세요.'); return; } setAccessPinInput(normalizedPin); setAccessPinSubmitError(''); await refreshSnapshot({ initialLoad: true, sharePin: normalizedPin }); }, [accessPinInput, refreshSnapshot]); useEffect(() => { const sessionExpiresAt = snapshot?.share.accessPinSessionExpiresAt?.trim() ?? ''; if (!normalizedToken || !snapshot?.share.hasAccessPin || !sessionExpiresAt) { return undefined; } const expiresAtMs = Date.parse(sessionExpiresAt); if (!Number.isFinite(expiresAtMs)) { return undefined; } const remainingMs = expiresAtMs - Date.now(); if (remainingMs <= 0) { setStoredChatShareAccessPin(normalizedToken, null); setAccessPinInput(''); setAccessPinSubmitError('유지시간이 지나 비밀번호를 다시 입력해 주세요.'); setRequiresAccessPin(true); return undefined; } const timeoutId = window.setTimeout(() => { setStoredChatShareAccessPin(normalizedToken, null); setAccessPinInput(''); setAccessPinSubmitError('유지시간이 지나 비밀번호를 다시 입력해 주세요.'); setRequiresAccessPin(true); }, remainingMs); return () => { window.clearTimeout(timeoutId); }; }, [normalizedToken, snapshot?.share.accessPinSessionExpiresAt, snapshot?.share.hasAccessPin]); useEffect(() => { const sessionId = snapshot?.share.sessionId?.trim() ?? ''; if (!normalizedToken || !sessionId || typeof window === 'undefined' || requiresAccessPin) { return undefined; } let isDisposed = false; let reconnectTimerId: number | null = null; let disconnectTimerId: number | null = null; let socket: WebSocket | null = null; const clearDisconnectTimer = () => { if (disconnectTimerId != null) { window.clearTimeout(disconnectTimerId); disconnectTimerId = null; } }; const markDisconnected = () => { clearDisconnectTimer(); disconnectTimerId = window.setTimeout(() => { disconnectTimerId = null; if (!isDisposed) { setIsLiveConnected(false); } }, 1500); }; const connect = () => { if (isDisposed) { return; } const websocketUrl = resolveChatWebSocketUrl(sessionId, undefined, undefined, normalizedToken); if (!websocketUrl) { setIsLiveConnected(false); return; } socket = new WebSocket(websocketUrl); socket.addEventListener('open', () => { if (isDisposed) { return; } clearDisconnectTimer(); setIsLiveConnected(true); scheduleSnapshotRefresh(0); }); 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:status' || payload.type === 'chat:runtime' || payload.type === 'chat:runtime:detail' || payload.type === 'notification:messages-updated' ) { return; } } catch { // 공유 화면은 세부 이벤트를 직접 반영하지 않고 스냅샷만 다시 조회한다. } scheduleSnapshotRefresh(); }); socket.addEventListener('error', () => { if (isDisposed) { return; } markDisconnected(); }); socket.addEventListener('close', () => { if (isDisposed) { return; } markDisconnected(); reconnectTimerId = window.setTimeout(() => { reconnectTimerId = null; connect(); }, 1500); }); }; connect(); return () => { isDisposed = true; if (reconnectTimerId != null) { window.clearTimeout(reconnectTimerId); } clearDisconnectTimer(); socket?.close(); }; }, [normalizedToken, requiresAccessPin, scheduleSnapshotRefresh, snapshot?.share.sessionId]); useEffect(() => { return () => { if (immediateSendHoldTimerRef.current !== null) { window.clearTimeout(immediateSendHoldTimerRef.current); } }; }, []); useEffect(() => { const handleResumeSync = () => { if (document.visibilityState === 'hidden') { return; } setNowMs(Date.now()); void refreshSnapshot({ silent: true }); }; window.addEventListener('focus', handleResumeSync); window.addEventListener('pageshow', handleResumeSync); window.addEventListener('online', handleResumeSync); document.addEventListener('visibilitychange', handleResumeSync); return () => { window.removeEventListener('focus', handleResumeSync); window.removeEventListener('pageshow', handleResumeSync); window.removeEventListener('online', handleResumeSync); document.removeEventListener('visibilitychange', handleResumeSync); }; }, [refreshSnapshot]); const handleSubmitPrompt = async ( payload: PromptSubmitPayload & { parentRequestId: string; promptIndex: number; sourceMessageId: number }, ) => { try { await submitChatSharePrompt(normalizedToken, { parentRequestId: payload.parentRequestId, promptIndex: payload.promptIndex, promptTitle: payload.promptTitle, promptSignature: payload.promptSignature, sourceMessageId: payload.sourceMessageId, selectedValues: payload.selection.selectedValues, freeText: payload.selection.freeText, stepSelections: payload.selection.stepSelections, summaryText: payload.selection.summaryText ?? null, attachments: payload.selection.attachments ?? payload.attachments ?? [], followupText: payload.text, mode: payload.mode, contextRef: payload.contextRef ?? null, }); message.success('답변을 전송했습니다. 최신 내용을 다시 불러옵니다.'); void refreshSnapshot({ silent: true }); return true; } catch (error) { if (isShareSendDelayError(error)) { message.warning('전송 후 응답 확인이 지연되고 있습니다. 연결 복구 시 최신 내용을 다시 불러옵니다.'); return false; } message.error(error instanceof Error ? error.message : 'prompt 답변을 전송하지 못했습니다.'); return false; } }; const updatePendingPromptSelection = useCallback((selectionKey: string, selection: PromptDraftSelection | null) => { setPendingPromptSelections((current) => { if (!selection) { if (!(selectionKey in current)) { return current; } const next = { ...current }; delete next[selectionKey]; return next; } return { ...current, [selectionKey]: { ...selection, status: 'draft', }, }; }); }, []); const markPendingPromptSelectionSubmitted = useCallback((selectionKey: string, selection: PromptDraftSelection) => { setPendingPromptSelections((current) => ({ ...current, [selectionKey]: { ...selection, status: 'submitted', }, })); }, []); const handleCompletePrompt = async (requestId: string) => { const normalizedRequestId = requestId.trim(); if (!normalizedToken || !normalizedRequestId) { return; } const confirmed = await new Promise((resolve) => { modal.confirm({ title: 'prompt 완료 처리', content: '이 요청의 prompt 선택대기를 수동 완료 처리할까요?', okText: '완료 처리', cancelText: '취소', centered: true, onOk: async () => { resolve(true); }, onCancel: () => { resolve(false); }, }); }); if (!confirmed) { return; } setPendingPromptCompletionRequestIds((current) => Array.from(new Set([...current, normalizedRequestId]))); try { await completeChatShareManualBadge(normalizedToken, { parentRequestId: normalizedRequestId, type: 'prompt', }); message.success('prompt 완료 상태를 저장했습니다.'); void refreshSnapshot({ silent: true }); } catch (error) { message.error(error instanceof Error ? error.message : 'prompt 완료 상태를 저장하지 못했습니다.'); } finally { setPendingPromptCompletionRequestIds((current) => current.filter((item) => item !== normalizedRequestId)); } }; const handleCompleteVerification = async (requestId: string) => { const normalizedRequestId = requestId.trim(); if ( !normalizedToken || !normalizedRequestId || pendingVerificationCompletionRequestIds.includes(normalizedRequestId) || sortedRequests.some( (request) => request.requestId === normalizedRequestId && Boolean(request.manualVerificationCompletedAt), ) ) { return; } const confirmed = await new Promise((resolve) => { modal.confirm({ title: '응답 확인 처리', content: '이 요청의 일반 답변을 응답 확인 완료로 처리할까요?', okText: '완료 처리', cancelText: '취소', centered: true, onOk: async () => { resolve(true); }, onCancel: () => { resolve(false); }, }); }); if (!confirmed) { return; } setPendingVerificationCompletionRequestIds((current) => Array.from(new Set([...current, normalizedRequestId]))); try { const updatedRequest = await completeChatShareManualBadge(normalizedToken, { parentRequestId: normalizedRequestId, type: 'verification', }); setSnapshot((current) => (current ? replaceChatShareSnapshotRequest(current, updatedRequest) : current)); message.success('응답 확인 상태를 저장했습니다.'); } catch (error) { message.error(error instanceof Error ? error.message : '응답 확인 상태를 저장하지 못했습니다.'); } finally { setPendingVerificationCompletionRequestIds((current) => current.filter((item) => item !== normalizedRequestId)); } }; const handleCancelDisconnectedRequest = async (requestId: string) => { const normalizedRequestId = requestId.trim(); if (!normalizedToken || !normalizedRequestId || pendingRequestCancellationIds.includes(normalizedRequestId)) { return; } const targetRequest = sortedRequests.find((request) => request.requestId === normalizedRequestId) ?? null; if ( !targetRequest || targetRequest.hasResponse || targetRequest.status !== 'failed' || (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청' ) { return; } const confirmed = await new Promise((resolve) => { modal.confirm({ title: '중단 요청 취소 처리', content: '연결이 끊겨 멈춘 이 요청을 취소 상태로 정리할까요?', okText: '취소 처리', cancelText: '닫기', okButtonProps: { danger: true }, centered: true, onOk: async () => { resolve(true); }, onCancel: () => { resolve(false); }, }); }); if (!confirmed) { return; } setPendingRequestCancellationIds((current) => Array.from(new Set([...current, normalizedRequestId]))); try { const updatedRequest = await cancelChatShareRequest(normalizedToken, { parentRequestId: normalizedRequestId, }); setSnapshot((current) => (current ? replaceChatShareSnapshotRequest(current, updatedRequest) : current)); message.success('중단된 요청을 취소 처리했습니다.'); void refreshSnapshot({ silent: true }); } catch (error) { message.error(error instanceof Error ? error.message : '중단 요청 취소 처리에 실패했습니다.'); } finally { setPendingRequestCancellationIds((current) => current.filter((item) => item !== normalizedRequestId)); } }; const handleRetryDisconnectedRequest = async (requestId: string) => { const normalizedRequestId = requestId.trim(); if (!normalizedToken || !normalizedRequestId || pendingRequestRetryIds.includes(normalizedRequestId)) { return; } const targetRequest = sortedRequests.find((request) => request.requestId === normalizedRequestId) ?? null; if ( !targetRequest || targetRequest.hasResponse || targetRequest.status !== 'failed' || (targetRequest.statusMessage?.trim() ?? '') !== '중단된 오래된 요청' ) { return; } const confirmed = await new Promise((resolve) => { modal.confirm({ title: '중단 요청 재처리', content: '연결이 끊겨 멈춘 이 요청을 같은 내용으로 다시 실행할까요?', okText: '재처리', cancelText: '닫기', centered: true, onOk: async () => { resolve(true); }, onCancel: () => { resolve(false); }, }); }); if (!confirmed) { return; } setPendingRequestRetryIds((current) => Array.from(new Set([...current, normalizedRequestId]))); try { await retryChatShareRequest(normalizedToken, { parentRequestId: normalizedRequestId, }); message.success('중단된 요청 재처리를 시작했습니다. 최신 상태를 다시 불러옵니다.'); void refreshSnapshot({ silent: true }); } catch (error) { message.error(error instanceof Error ? error.message : '중단 요청 재처리를 시작하지 못했습니다.'); } finally { setPendingRequestRetryIds((current) => current.filter((item) => item !== normalizedRequestId)); } }; const shareKind = snapshot?.share.kind ?? 'request-bundle'; const isPromptShare = shareKind === 'prompt'; const handleSendMessageByMode = useCallback(async (mode: 'queue' | 'direct') => { const outgoingText = buildOutgoingShareMessageText(draftText, composerAttachments).trim(); const resolvedParentRequestId = replyReferenceRequestId.trim() || (shareKind === 'inquiry-message' ? snapshot?.targetRequest.requestId?.trim() || '' : '') || ''; if (!outgoingText || isSending || isUploadingComposerAttachment) { return; } setIsSending(true); try { await submitChatShareMessage(normalizedToken, outgoingText, { mode, parentRequestId: resolvedParentRequestId, }); setDraftText(''); setComposerAttachments([]); setReplyReferenceRequestId(''); message.success( mode === 'direct' ? '즉시 전송했습니다. 최신 응답을 다시 불러옵니다.' : '대기열에 등록했습니다. 최신 응답을 다시 불러옵니다.', ); void refreshSnapshot({ silent: true }); } catch (error) { if (isShareSendDelayError(error)) { setDraftText(''); setComposerAttachments([]); setReplyReferenceRequestId(''); message.warning( mode === 'direct' ? '즉시 전송 후 응답 확인이 지연되고 있습니다. 연결 복구 시 최신 내용을 다시 불러옵니다.' : '대기열 등록 후 응답 확인이 지연되고 있습니다. 연결 복구 시 최신 내용을 다시 불러옵니다.', ); } else { message.error( error instanceof Error ? error.message : mode === 'direct' ? '즉시 전송하지 못했습니다.' : '대기열에 등록하지 못했습니다.', ); } } finally { setIsSending(false); } }, [ composerAttachments, draftText, isSending, isUploadingComposerAttachment, message, normalizedToken, refreshSnapshot, replyReferenceRequestId, shareKind, snapshot?.targetRequest.requestId, ]); const handleSendMessage = useCallback(async () => { await handleSendMessageByMode(isImmediateSendPinned ? 'direct' : 'queue'); }, [handleSendMessageByMode, isImmediateSendPinned]); const clearImmediateSendHoldTimer = useCallback(() => { if (immediateSendHoldTimerRef.current === null) { return; } window.clearTimeout(immediateSendHoldTimerRef.current); immediateSendHoldTimerRef.current = null; }, []); const handleToggleImmediateSendPinned = useCallback(() => { if (!normalizedToken) { return; } setImmediateSendPinnedByToken((previous) => { const nextState = { ...previous, [normalizedToken]: previous[normalizedToken] !== true, }; writeStoredShareImmediateSendPinnedByToken(nextState); return nextState; }); }, [normalizedToken]); const startImmediateSendHoldTimer = useCallback(() => { if (isSending || isUploadingComposerAttachment || !normalizedToken) { return; } clearImmediateSendHoldTimer(); immediateSendHoldTimerRef.current = window.setTimeout(() => { immediateSendHoldTimerRef.current = null; suppressImmediateSendClickRef.current = true; handleToggleImmediateSendPinned(); }, SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS); }, [clearImmediateSendHoldTimer, handleToggleImmediateSendPinned, isSending, isUploadingComposerAttachment, normalizedToken]); const handleImmediateSendButtonClick = useCallback(() => { if (suppressImmediateSendClickRef.current) { suppressImmediateSendClickRef.current = false; return; } void handleSendMessageByMode('direct'); }, [handleSendMessageByMode]); const handleComposerKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Enter' || event.nativeEvent.isComposing) { return; } if (!event.ctrlKey && !event.metaKey) { return; } event.preventDefault(); if ((!draftText.trim() && composerAttachments.length === 0) || isSending || isUploadingComposerAttachment) { return; } void handleSendMessageByMode(isImmediateSendPinned ? 'direct' : 'queue'); }; const syncFocusedInputIntoView = useCallback((inputTarget?: HTMLElement | null, behavior: ScrollBehavior = 'auto') => { if (typeof window === 'undefined' || window.innerWidth > 768) { return; } const scrollContainer = scrollContainerRef.current; const composerShell = composerInputShellRef.current; const targetInput = inputTarget ?? focusedMobileInputRef.current; const target = composerShell && targetInput && composerShell.contains(targetInput) ? composerShell : targetInput; if (!scrollContainer || !target) { return; } applyViewportCssVars(); resetMobileDocumentScrollOffset(); const containerRect = scrollContainer.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); const visualViewport = window.visualViewport; const viewportTop = Math.max(visualViewport?.offsetTop ?? 0, visualViewport?.pageTop ?? 0, 0); const viewportBottom = viewportTop + (visualViewport?.height ?? window.innerHeight); const visibleTop = Math.max(containerRect.top, viewportTop) + MOBILE_INPUT_VIEWPORT_TOP_PADDING_PX; const visibleBottom = Math.min(containerRect.bottom, viewportBottom) - MOBILE_INPUT_VIEWPORT_BOTTOM_PADDING_PX; if (visibleBottom <= visibleTop) { return; } if (targetRect.bottom > visibleBottom) { const nextScrollTop = scrollContainer.scrollTop + (targetRect.bottom - visibleBottom); scrollContainer.scrollTo({ top: Math.max(0, nextScrollTop), behavior }); return; } if (targetRect.top < visibleTop) { const nextScrollTop = scrollContainer.scrollTop - (visibleTop - targetRect.top); scrollContainer.scrollTo({ top: Math.max(0, nextScrollTop), behavior }); } }, []); const clearComposerViewportSyncTimers = useCallback(() => { if (typeof window === 'undefined') { composerFocusScrollTimerIdsRef.current = []; return; } composerFocusScrollTimerIdsRef.current.forEach((timerId) => { window.clearTimeout(timerId); }); composerFocusScrollTimerIdsRef.current = []; }, []); const scheduleFocusedInputViewportSync = useCallback(( inputTarget?: HTMLElement | null, behavior: ScrollBehavior = 'auto', options?: { includeRetryTimers?: boolean }, ) => { if (typeof window === 'undefined' || window.innerWidth > 768) { return; } focusedMobileInputRef.current = inputTarget ?? focusedMobileInputRef.current; clearComposerViewportSyncTimers(); window.requestAnimationFrame(() => { syncFocusedInputIntoView(inputTarget, behavior); }); if (options?.includeRetryTimers === false) { return; } MOBILE_INPUT_VIEWPORT_SYNC_RETRY_DELAYS_MS.forEach((delay) => { const timerId = window.setTimeout(() => { syncFocusedInputIntoView(inputTarget, behavior); composerFocusScrollTimerIdsRef.current = composerFocusScrollTimerIdsRef.current.filter((value) => value !== timerId); }, delay); composerFocusScrollTimerIdsRef.current.push(timerId); }); }, [clearComposerViewportSyncTimers, syncFocusedInputIntoView]); const syncComposerViewportCompactedState = useCallback(() => { if (typeof window === 'undefined') { setIsComposerViewportCompacted(false); return; } if (window.innerWidth > 768) { setIsComposerViewportCompacted(false); return; } const visualViewport = window.visualViewport; const activeElement = document.activeElement; const isFocusedShareInput = isMobileShareInputTarget(activeElement) && Boolean(scrollContainerRef.current?.contains(activeElement)); if (!isFocusedShareInput || !visualViewport) { setIsComposerViewportCompacted(false); return; } const layoutViewportHeight = Math.max(window.innerHeight || 0, document.documentElement?.clientHeight || 0); const visualHeightDelta = Math.max(0, layoutViewportHeight - visualViewport.height); const visualOffsetTop = Math.max(visualViewport.offsetTop ?? 0, visualViewport.pageTop ?? 0); setIsComposerViewportCompacted( visualHeightDelta >= SHARE_COMPOSER_VIEWPORT_COMPACT_THRESHOLD_PX || visualOffsetTop > 0, ); }, []); useEffect(() => { return () => { clearComposerViewportSyncTimers(); }; }, [clearComposerViewportSyncTimers]); useEffect(() => { if (typeof window === 'undefined') { return undefined; } const visualViewport = window.visualViewport; if (!visualViewport) { return undefined; } const handleViewportChange = () => { applyViewportCssVars(); syncComposerViewportCompactedState(); const activeElement = document.activeElement; const nextFocusTarget = isMobileShareInputTarget(activeElement) ? activeElement : null; if (!nextFocusTarget || !scrollContainerRef.current?.contains(nextFocusTarget)) { clearComposerViewportSyncTimers(); return; } focusedMobileInputRef.current = nextFocusTarget; scheduleFocusedInputViewportSync(nextFocusTarget, 'auto', { includeRetryTimers: false }); }; visualViewport.addEventListener('resize', handleViewportChange); visualViewport.addEventListener('scroll', handleViewportChange); return () => { visualViewport.removeEventListener('resize', handleViewportChange); visualViewport.removeEventListener('scroll', handleViewportChange); }; }, [clearComposerViewportSyncTimers, scheduleFocusedInputViewportSync, syncComposerViewportCompactedState]); const handleComposerFocus = () => { focusedMobileInputRef.current = composerRef.current?.resizableTextArea?.textArea ?? null; syncComposerViewportCompactedState(); scheduleFocusedInputViewportSync(focusedMobileInputRef.current, 'auto', { includeRetryTimers: true }); }; const handleComposerBlur = () => { clearComposerViewportSyncTimers(); setIsComposerViewportCompacted(false); }; const handleScrollContainerFocusCapture = (event: FocusEvent) => { const nextFocusTarget = isMobileShareInputTarget(event.target) ? event.target : null; if (!nextFocusTarget) { return; } focusedMobileInputRef.current = nextFocusTarget; syncComposerViewportCompactedState(); const isComposerFocusTarget = Boolean(composerInputShellRef.current?.contains(nextFocusTarget)); scheduleFocusedInputViewportSync(nextFocusTarget, 'auto', { includeRetryTimers: isComposerFocusTarget }); }; const handleScrollContainerBlurCapture = (event: FocusEvent) => { const blurTarget = isMobileShareInputTarget(event.target) ? event.target : null; if (!blurTarget) { return; } clearComposerViewportSyncTimers(); window.setTimeout(() => { const activeElement = document.activeElement; if (isMobileShareInputTarget(activeElement) && scrollContainerRef.current?.contains(activeElement)) { focusedMobileInputRef.current = activeElement; syncComposerViewportCompactedState(); return; } if (focusedMobileInputRef.current === blurTarget) { focusedMobileInputRef.current = null; } setIsComposerViewportCompacted(false); }, 0); }; const handleUploadPromptAttachment = useCallback( async (file: File) => { const sessionId = snapshot?.share.sessionId?.trim() ?? ''; if (!sessionId) { throw new Error('공유 채팅 세션이 준비되지 않았습니다.'); } return uploadChatShareComposerFile(normalizedToken, sessionId, file); }, [normalizedToken, snapshot?.share.sessionId], ); const handleUploadComposerAttachments = useCallback( async (files: File[]) => { if (!files.length || isUploadingComposerAttachment) { return; } setIsUploadingComposerAttachment(true); try { const uploadResults = await Promise.allSettled(files.map((file) => handleUploadPromptAttachment(file))); const succeeded = uploadResults .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') .map((result) => result.value); const failed = uploadResults.filter((result): result is PromiseRejectedResult => result.status === 'rejected'); if (succeeded.length > 0) { setComposerAttachments((current) => { const seenPaths = new Set(current.map((attachment) => attachment.path)); return [ ...current, ...succeeded.filter((attachment) => { if (seenPaths.has(attachment.path)) { return false; } seenPaths.add(attachment.path); return true; }), ]; }); } if (failed.length > 0) { const firstError = failed[0]?.reason; message.error(firstError instanceof Error ? firstError.message : '일부 첨부 파일 업로드에 실패했습니다.'); } } finally { setIsUploadingComposerAttachment(false); } }, [handleUploadPromptAttachment, isUploadingComposerAttachment, message], ); const handleComposerPaste = useCallback( (event: ClipboardEvent) => { const files = resolveShareComposerPasteFiles(event.clipboardData); if (files.length === 0 || isSending || isUploadingComposerAttachment) { return; } event.preventDefault(); event.stopPropagation(); void handleUploadComposerAttachments(files); }, [handleUploadComposerAttachments, isSending, isUploadingComposerAttachment], ); const handleClearConversation = useCallback(async () => { const sessionId = snapshot?.share.sessionId?.trim() ?? ''; const conversationTitle = snapshot?.conversation.title?.trim() || '현재 채팅방'; if (!normalizedToken || !sessionId || isClearingConversation) { return; } const confirmed = await new Promise((resolve) => { modal.confirm({ title: '채팅방 데이터를 초기화할까요?', content: `"${conversationTitle}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.`, okText: '초기화', cancelText: '취소', okButtonProps: { danger: true }, centered: true, onOk: async () => { resolve(true); }, onCancel: () => { resolve(false); }, }); }); if (!confirmed) { return; } setIsClearingConversation(true); try { await clearChatShareConversationRoom(normalizedToken); setDraftText(''); setComposerAttachments([]); setLatestRequestId(''); setExpandMode('pending'); setSnapshot((current) => current ? { ...current, requests: [], messages: [], activityLogs: [], roomRequestCounts: { processingCount: 0, unansweredCount: 0, }, refreshedAt: new Date().toISOString(), } : current, ); message.success('채팅방 데이터를 초기화했습니다.'); } catch (error) { message.error(error instanceof Error ? error.message : '채팅방 데이터 초기화 중 오류가 발생했습니다.'); } finally { setIsClearingConversation(false); } }, [isClearingConversation, message, modal, normalizedToken, snapshot]); const shareBlockedReason = snapshot?.share.blockedReason?.trim() ?? ''; const canSendMessage = snapshot != null && !isPromptShare && (snapshot.share.canSendMessage ?? true); const sortedMessages = useMemo(() => [...(snapshot?.messages ?? [])].sort((left, right) => left.id - right.id), [snapshot]); const messageRenderPayloadById = useMemo( () => new Map(sortedMessages.map((message) => [message.id, extractShareMessageRenderPayload(message)] as const)), [sortedMessages], ); const lastResponseMessage = [...sortedMessages].reverse().find((item) => { const payload = messageRenderPayloadById.get(item.id); return item.author === 'codex' && (payload?.promptParts.length ?? 0) === 0; }) ?? null; const promptTarget = snapshot?.promptTarget ?? null; const promptTargetRequestId = snapshot?.targetRequest?.requestId?.trim() ?? ''; const sortedRequests = useMemo( () => [...(snapshot?.requests ?? [])].sort(compareShareConversationRequests), [snapshot], ); const requestMessagesById = useMemo(() => { const nextMap = new Map(); sortedMessages.forEach((message) => { const requestId = message.clientRequestId?.trim() || ''; if (!requestId) { return; } const current = nextMap.get(requestId) ?? []; current.push(message); nextMap.set(requestId, current); }); return nextMap; }, [sortedMessages]); const childRequestCountByParentId = useMemo(() => buildShareChildRequestCountMap(sortedRequests), [sortedRequests]); const promptFollowupCountByParentId = useMemo(() => buildSharePromptFollowupCountMap(sortedRequests), [sortedRequests]); const requestById = useMemo( () => new Map(sortedRequests.map((request) => [request.requestId.trim(), request])), [sortedRequests], ); const promptFollowupStateByPrimaryRequestId = useMemo( () => buildSharePromptFollowupStateByPrimaryRequestId( sortedRequests, requestById, requestMessagesById, promptFollowupCountByParentId, messageRenderPayloadById, ), [messageRenderPayloadById, promptFollowupCountByParentId, requestById, requestMessagesById, sortedRequests], ); const promptShareFollowupRequests = useMemo(() => { if (!isPromptShare || !promptTargetRequestId) { return [] as ChatConversationRequest[]; } return sortedRequests.filter((request) => { const requestId = request.requestId.trim(); if (!requestId || requestId === promptTargetRequestId) { return false; } return resolveSharePrimaryRequestId(request, requestById) === promptTargetRequestId; }); }, [isPromptShare, promptTargetRequestId, requestById, sortedRequests]); const latestRequest = sortedRequests[sortedRequests.length - 1] ?? null; const latestRequestIndex = useMemo(() => { if (sortedRequests.length === 0) { return -1; } const matchedIndex = sortedRequests.findIndex((request) => request.requestId === latestRequestId); return matchedIndex >= 0 ? matchedIndex : sortedRequests.length - 1; }, [latestRequestId, sortedRequests]); const currentRequest = latestRequestIndex >= 0 ? sortedRequests[latestRequestIndex] ?? null : latestRequest; const pendingCompletionRequests = useMemo( () => sortedRequests.filter((request) => { return isPendingCompletionShareRequest( request, requestMessagesById.get(request.requestId) ?? [], childRequestCountByParentId, promptFollowupCountByParentId, messageRenderPayloadById, ); }), [childRequestCountByParentId, messageRenderPayloadById, promptFollowupCountByParentId, requestMessagesById, sortedRequests], ); const pendingManualActionRequests = useMemo( () => pendingCompletionRequests.filter((request) => isShareRequestAwaitingManualAction( request, requestMessagesById.get(request.requestId) ?? [], childRequestCountByParentId, promptFollowupCountByParentId, messageRenderPayloadById, )), [childRequestCountByParentId, messageRenderPayloadById, pendingCompletionRequests, promptFollowupCountByParentId, requestMessagesById], ); const displayedRequests = useMemo(() => { if (expandMode === 'all') { return sortedRequests; } if (expandMode === 'pending') { return pendingCompletionRequests; } return currentRequest ? [currentRequest] : []; }, [currentRequest, expandMode, pendingCompletionRequests, sortedRequests]); const headerInquiryRequest = useMemo(() => { if (displayedRequests.length > 0) { return displayedRequests[0] ?? null; } return currentRequest ?? latestRequest ?? sortedRequests[0] ?? null; }, [currentRequest, displayedRequests, latestRequest, sortedRequests]); const headerTitleText = useMemo(() => { const savedConversationTitle = snapshot?.conversation.title?.trim() || ''; if (savedConversationTitle) { return savedConversationTitle; } return headerInquiryRequest?.userText.trim() || '-'; }, [headerInquiryRequest?.userText, snapshot?.conversation.title]); const hiddenBeforeCount = expandMode === 'latest' && latestRequestIndex >= 0 ? latestRequestIndex : 0; const hiddenAfterCount = expandMode === 'latest' && latestRequestIndex >= 0 ? Math.max(0, sortedRequests.length - latestRequestIndex - 1) : 0; const pendingProcessingCount = pendingCompletionRequests.filter( (request) => isRequestInFlight(request.status), ).length; const pendingUnansweredCount = Math.max(0, pendingCompletionRequests.length - pendingProcessingCount); const requestProgressLabel = sortedRequests.length === 0 ? '' : expandMode === 'all' ? `전체 ${sortedRequests.length}건` : expandMode === 'pending' ? `처리중 ${pendingProcessingCount}건 · 미확인 ${pendingUnansweredCount}건` : `${latestRequestIndex + 1} / ${sortedRequests.length}`; const canMoveToPreviousRequest = expandMode === 'latest' && latestRequestIndex > 0; const canMoveToNextRequest = expandMode === 'latest' && latestRequestIndex >= 0 && latestRequestIndex < sortedRequests.length - 1; const aggregateStatusTag = useMemo(() => { if (sortedRequests.length === 0) { return null; } const processingTarget = displayedRequests.find((request) => isRequestProcessing(request.status)) ?? sortedRequests.find((request) => isRequestProcessing(request.status)) ?? null; if (processingTarget) { return { ...resolveRequestStatusTag('started'), elapsedLabel: formatElapsedDuration(processingTarget.updatedAt?.trim() || processingTarget.createdAt, nowMs), }; } const hasPendingPrompt = sortedRequests.some((request) => hasPendingPromptRequest( request, requestMessagesById.get(request.requestId) ?? [], promptFollowupCountByParentId.get(request.requestId.trim()) ?? 0, messageRenderPayloadById, )); if (hasPendingPrompt) { return { label: '입력 대기', color: 'warning' as const, elapsedLabel: '' }; } if (sortedRequests.some((request) => request.status === 'queued')) { return { ...resolveRequestStatusTag('queued'), elapsedLabel: '' }; } if (pendingCompletionRequests.length > 0) { return { label: '확인대기', color: 'warning' as const, elapsedLabel: '' }; } if (sortedRequests.every((request) => request.status === 'completed')) { return { ...resolveRequestStatusTag('completed'), elapsedLabel: '' }; } if (sortedRequests.some((request) => request.status === 'failed')) { return { label: '일부 실패', color: 'error' as const, elapsedLabel: '' }; } if (sortedRequests.some((request) => request.status === 'cancelled')) { return { ...resolveRequestStatusTag('cancelled'), elapsedLabel: '' }; } if (sortedRequests.some((request) => request.status === 'removed')) { return { ...resolveRequestStatusTag('removed'), elapsedLabel: '' }; } return { ...resolveRequestStatusTag(sortedRequests[sortedRequests.length - 1]?.status ?? 'queued'), elapsedLabel: '' }; }, [messageRenderPayloadById, nowMs, pendingCompletionRequests.length, promptFollowupCountByParentId, requestMessagesById, sortedRequests]); const requestAnswerTextById = useMemo(() => { const nextMap = new Map(); sortedRequests.forEach((request) => { nextMap.set( request.requestId, buildRequestAnswerText( request, requestMessagesById.get(request.requestId) ?? [], messageRenderPayloadById, ), ); }); return nextMap; }, [messageRenderPayloadById, requestMessagesById, sortedRequests]); const replyReferenceRequest = useMemo( () => (replyReferenceRequestId.trim() ? requestById.get(replyReferenceRequestId.trim()) ?? null : null), [replyReferenceRequestId, requestById], ); const replyReferenceSummary = useMemo(() => { if (!replyReferenceRequest) { return ''; } const answerText = requestAnswerTextById.get(replyReferenceRequest.requestId.trim()) || replyReferenceRequest.responseText || replyReferenceRequest.statusMessage || ''; return summarizeShareReplyReferenceText(answerText || replyReferenceRequest.userText || '선택한 답변'); }, [replyReferenceRequest, requestAnswerTextById]); const shareSendModeSummary = useMemo(() => { if (isImmediateSendPinned) { return { tagLabel: '즉시전송 고정', description: '전송 버튼과 Ctrl+Enter가 즉시전송으로 동작합니다. 번개 버튼을 1초 이상 눌러 해제할 수 있습니다.', }; } return { tagLabel: '일반 전송 대기열', description: '기본 전송은 대기열에 등록됩니다. 즉시전송은 번개 버튼을 누르거나 1초 이상 눌러 고정했을 때만 실행됩니다.', }; }, [isImmediateSendPinned]); const previousQuestionModalRequest = useMemo( () => (previousQuestionModalRequestId.trim() ? requestById.get(previousQuestionModalRequestId.trim()) ?? null : null), [previousQuestionModalRequestId, requestById], ); const previousQuestionModalText = useMemo( () => resolveShareRequestQuestionText(previousQuestionModalRequest), [previousQuestionModalRequest], ); const previousQuestionModalPreviewItems = useMemo( () => buildSharePreviewItemsFromText(previousQuestionModalRequest?.userText ?? '', normalizedToken), [normalizedToken, previousQuestionModalRequest?.userText], ); const activityLogByRequestId = useMemo( () => new Map((snapshot?.activityLogs ?? []).map((item) => [item.requestId.trim(), item])), [snapshot?.activityLogs], ); 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(() => { 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 canLaunchShareProgram = useCallback( (appId?: ShareProgramTarget['appId']) => { if (!appId) { 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); setProgramTarget(target); setProgramMinimizedTarget(target); setIsProgramMinimized(false); }, [canLaunchShareProgram, 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) { setProgramMinimizedTarget(programTarget); } setIsProgramMinimized(true); }, [programTarget]); 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); setProgramMinimizedTarget(null); setIsProgramMinimized(false); }, []); 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 searchResults = useMemo(() => { const keyword = normalizeSearchKeyword(searchKeyword); const results: ShareSearchResult[] = []; if (searchPanelMode === 'apps') { const photoprismLauncher = buildPhotoPrismProgramTarget(); 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, messageRenderPayloadById, normalizedToken, searchKeyword, searchPanelMode, 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: ( 화면 새로고침 PWA 반영이 늦을 때 현재 공유채팅방 화면을 다시 불러옵니다. ), icon: , }, ...(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: , }, ] : []), ...(hasWorkServerCommandApp ? [ { key: 'conversation-work-server-command', label: ( {resolveShareWorkServerStatusLabel(shareWorkServerCommand)} {resolveShareWorkServerVersionDescription(shareWorkServerCommand)} {resolveShareWorkServerVersionLabel(shareWorkServerCommand)} ), icon: , }, ] : []), { key: 'conversation-clear', label: ( 채팅방 비우기 메시지, 요청, 활동 로그를 초기화합니다. ), icon: , danger: true, disabled: !canSendMessage || isClearingConversation || !(snapshot?.share.sessionId?.trim()), }, ], [ allowedManagementApps.length, allowedPlayAppEntries.length, canSendMessage, canOpenSharedRoomSettings, hasWorkServerCommandApp, isClearingConversation, selectedTokenUsageSetting, shareWorkServerCommand, snapshot?.conversation.title, snapshot?.share.sessionId, 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-work-server-command') { openProgramTarget(buildShareManagementProgramTarget('server-command', '서버관리')); return; } if (key === 'conversation-clear') { void handleClearConversation(); } }, [handleClearConversation, handleReloadPage, 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(snapshot?.share.sessionId?.trim())} 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(snapshot?.share.sessionId?.trim())} onUploadAttachment={handleUploadPromptAttachment} onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} onSetPromptAnchor={setPromptAnchorRef} /> ))}
) : null}
) : (
채팅 {aggregateStatusTag ? {aggregateStatusTag.label} : null} {headerSummaryLabel}
{headerInquiryRequest ? (
{headerTitleText} {canOpenSharedRoomSettings ? (
) : 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(snapshot?.share.sessionId?.trim())} onUploadAttachment={handleUploadPromptAttachment} onSetRequestAnchor={setRequestAnchorRef} onSetResponseAnchor={setResponseAnchorRef} onSetPromptAnchor={setPromptAnchorRef} onOpenPreviousQuestion={(requestId) => { setPreviousQuestionModalRequestId(requestId.trim()); }} /> ))} {expandMode === 'latest' && hiddenAfterCount > 0 ? (
) : null} {expandMode === 'pending' && displayedRequests.length === 0 ? (
) : null}
{canSendMessage ? ( <> {expandMode === 'latest' && collapsedActivitySummary.length > 0 ? (
현재 진행 상황
{collapsedActivitySummary.map((item) => ( {item} ))}
) : 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); }} /> } placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'} value={searchKeyword} onChange={(event) => setSearchKeyword(event.target.value)} />
{searchKeyword.trim() ? `검색 결과 ${searchResults.length}건` : searchPanelMode === 'apps' ? '최근 자주 연 앱을 먼저 보여주며, 아이콘 중심으로 빠르게 실행할 수 있습니다.' : '질문, 답변, 리소스, 활동 로그를 함께 찾습니다.'}
{searchPanelMode === 'apps' ? (
실행 환경