Files
ai-code-app/src/app/main/pages/ChatSharePage.tsx
2026-05-27 11:35:26 +09:00

7547 lines
277 KiB
TypeScript

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<ChatMessagePart, { type: 'prompt' }>[];
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<string, boolean>;
}
try {
const raw = window.localStorage.getItem(SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY);
if (!raw) {
return {} as Record<string, boolean>;
}
const parsed = JSON.parse(raw) as Record<string, unknown>;
return Object.entries(parsed).reduce<Record<string, boolean>>((result, [token, isPinned]) => {
const normalizedToken = String(token ?? '').trim();
if (normalizedToken && typeof isPinned === 'boolean') {
result[normalizedToken] = isPinned;
}
return result;
}, {});
} catch {
return {} as Record<string, boolean>;
}
}
function writeStoredShareImmediateSendPinnedByToken(nextMap: Record<string, boolean>) {
if (typeof window === 'undefined') {
return;
}
const normalizedMap = Object.entries(nextMap).reduce<Record<string, boolean>>((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<void>((resolve) => {
let completed = false;
const finish = () => {
if (completed) {
return;
}
completed = true;
window.clearTimeout(timeoutId);
installingWorker.removeEventListener('statechange', onStateChange);
resolve();
};
const onStateChange = () => {
if (
installingWorker.state === 'activated'
|| installingWorker.state === 'installed'
|| Boolean(registration.active)
|| Boolean(registration.waiting)
) {
finish();
}
};
const timeoutId = window.setTimeout(finish, 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<null>((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 ? <EyeOutlined /> : <EyeInvisibleOutlined />);
function areStringListsEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function resolveShareRoomDefaultContextIds(
roomContextSettings: ReturnType<typeof resolveChatRoomContextSettings>,
chatTypeDefaults: Parameters<typeof resolveChatTypeDefaultContextIds>[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<ShareExpandMode, string> = {
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: <FileTextOutlined />,
usagePriority: 70,
},
{
value: 'token-setting',
label: '토큰관리 설정',
description: '토큰 설정 목록과 상세 편집 접근',
icon: <SettingOutlined />,
usagePriority: 55,
},
{
value: 'shared-resource',
label: '공유 리소스 관리',
description: '공유 링크와 권한, 활동 이력 관리',
icon: <AppstoreOutlined />,
usagePriority: 50,
},
{
value: 'app-settings',
label: '앱 설정',
description: '헤더 설정, 알림, 업데이트 화면 접근',
icon: <SettingOutlined />,
usagePriority: 75,
},
{
value: 'server-command',
label: '서버관리',
description: '여러 서버 상태 확인과 재기동 관리 화면 접근',
icon: <ReloadOutlined />,
usagePriority: 95,
},
] as const;
type ShareAppLaunchUsageMap = Record<string, { count: number; lastOpenedAt: number }>;
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<string, unknown>).reduce<ShareAppLaunchUsageMap>((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 <PhotoPrismAppView onBack={onBack} launchContext="embedded" />;
}
if (appId === 'baseball-ticket-bay') {
return <BaseballTicketBayPlayAppView onBack={onBack} launchContext="embedded" shareToken={shareToken} />;
}
if (appId === 'e-reader') {
return <EReaderAppView onBack={onBack} launchContext="embedded" shareToken={shareToken} />;
}
if (appId === 'photo-puzzle') {
return <PhotoPuzzleAppView onBack={onBack} launchContext="embedded" />;
}
if (appId === 'tetris') {
return <TetrisAppView onBack={onBack} launchContext="embedded" />;
}
if (appId === 'the-quest') {
return <TheQuestAppView onBack={onBack} launchContext="embedded" />;
}
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<number | null | undefined>) {
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<HTMLElement>('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<string | null | undefined>) {
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<ChatMessagePart, { type: 'prompt' }> => 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<ChatMessagePart, { type: 'prompt' }> => 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<number, ShareMessageRenderPayload>,
) {
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<string, ChatConversationRequest>,
) {
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<ChatMessagePart, { type: 'prompt' }>,
shareToken: string,
): Extract<ChatMessagePart, { type: 'prompt' }> {
const rewritePreview = <T extends { preview?: { url?: string | null } | null }>(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<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt',
);
return promptParts.some((part) => !isPromptResolved(part));
}
function countUnresolvedPromptParts(
promptParts: Extract<ChatMessagePart, { type: 'prompt' }>[],
) {
return promptParts.filter((part) => !isPromptResolved(part)).length;
}
function buildSharePromptSearchText(prompt: Extract<ChatMessagePart, { type: 'prompt' }>) {
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<number, ShareMessageRenderPayload>,
) {
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<ChatMessagePart, { type: 'prompt' }> => 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<number, ShareMessageRenderPayload>,
) {
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<string, number>();
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<string, number>();
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<string, number>,
) {
return (childRequestCountByParentId?.get(request.requestId.trim()) ?? 0) > 0;
}
function hasShareManualCompletion(request: ChatConversationRequest) {
return Boolean(request.manualPromptCompletedAt || request.manualVerificationCompletedAt);
}
function isCompletedHandledShareRequest(
request: ChatConversationRequest,
childRequestCountByParentId?: Map<string, number>,
) {
if (hasShareManualCompletion(request)) {
return true;
}
return hasShareChildFollowupRequest(request, childRequestCountByParentId);
}
function resolveSharePrimaryRequestId(
request: ChatConversationRequest | null | undefined,
requestById: ReadonlyMap<string, ChatConversationRequest>,
) {
let currentRequest = request ?? null;
const visitedRequestIds = new Set<string>();
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<string, number>,
promptFollowupCountByParentId?: Map<string, number>,
messageRenderPayloadById?: Map<number, ShareMessageRenderPayload>,
) {
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<string, number>,
promptFollowupCountByParentId?: Map<string, number>,
messageRenderPayloadById?: Map<number, ShareMessageRenderPayload>,
) {
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<string, number>,
promptFollowupCountByParentId?: Map<string, number>,
messageRenderPayloadById?: Map<number, ShareMessageRenderPayload>,
) {
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<string, ChatConversationRequest>,
requestMessagesById: Map<string, ChatMessage[]>,
promptFollowupCountByParentId?: Map<string, number>,
messageRenderPayloadById?: Map<number, ShareMessageRenderPayload>,
) {
const nextMap = new Map<string, SharePromptFollowupState>();
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<SharePreviewFetchError> {
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 (
<div className="chat-share-page__collapsible-text">
<Paragraph
className={`${className ?? 'chat-share-page__message-body'}${!isExpanded && canCollapse ? ' chat-share-page__message-body--collapsed' : ''}`}
>
{normalizedText}
</Paragraph>
{canCollapse ? (
<Button
type="text"
size="small"
className="chat-share-page__collapse-toggle"
onClick={() =>
setIsExpanded((current) => {
const nextExpanded = !current;
if (nextExpanded) {
onExpand?.();
}
return nextExpanded;
})
}
>
{isExpanded ? expandedLabel : collapsedLabel}
</Button>
) : null}
</div>
);
}
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<string, ChatConversationRequest>;
fallbackRequestId?: string;
emphasisLabel?: string;
onSubmitPrompt: (payload: PromptSubmitPayload & { parentRequestId: string; promptIndex: number; sourceMessageId: number }) => Promise<boolean>;
promptSelections: Record<string, PendingSharePromptSelection>;
onPromptSelectionChange: (selectionKey: string, selection: PromptDraftSelection | null) => void;
onPromptSubmitted: (selectionKey: string, selection: PromptDraftSelection) => void;
onCompletePrompt?: ((parentRequestId: string) => Promise<void>) | null;
onCompleteVerification?: ((parentRequestId: string) => Promise<void>) | 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<ChatComposerAttachment>) | 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 (
<div
className="chat-share-page__response-block"
ref={(element) => {
onSetResponseAnchor?.(message.id, element);
}}
>
<span className="chat-share-page__message-time">{formatTimeLabel(message.timestamp)}</span>
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
{emphasisLabel ? <Tag>{emphasisLabel}</Tag> : null}
{canCompletePrompt && parentRequestId && onCompletePrompt ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
icon={<CheckOutlined />}
loading={isPromptCompletionSaving}
onClick={() => {
void onCompletePrompt(parentRequestId);
}}
>
</Button>
) : null}
{!canCompletePrompt && isPromptManualCompleted ? <Tag color="success">prompt </Tag> : null}
{canCompleteVerification && parentRequestId && onCompleteVerification ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
icon={<CheckOutlined />}
loading={isVerificationCompletionSaving}
onClick={() => {
void onCompleteVerification(parentRequestId);
}}
>
</Button>
) : null}
{!canCompleteVerification && !hasOpenPromptInResponse && !hasChildRequest && isVerificationCompleted ? <Tag color="success"> </Tag> : null}
{canReplyToResponse && parentRequestId && onReplyToResponse ? (
<Button
type="text"
size="small"
className={`chat-share-page__prompt-complete-button chat-share-page__response-reply-button${
isReplyTargetActive ? ' chat-share-page__response-reply-button--active' : ''
}`}
icon={<SendOutlined />}
onClick={() => {
onReplyToResponse(parentRequestId);
}}
>
{isReplyTargetActive ? '답변 참조 중' : '답변하기'}
</Button>
) : null}
</div>
<div className="chat-share-page__message-tone chat-share-page__message-tone--answer">
<span className="chat-share-page__message-tone-label"></span>
<ExpandableMessageText text={visibleText} />
</div>
{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 (
<div
key={`${message.id}-${promptIndex}-${target.title}`}
ref={(element) => {
onSetPromptAnchor?.(message.id, promptIndex, element);
}}
>
<ChatPromptCard
target={target}
parentQuestionText={parentQuestionText}
readOnly={Boolean(isPromptManualCompleted)}
draftSelection={draftSelection}
submittedSelection={submittedSelection}
onSelectionChange={(selection) => 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}
/>
</div>
);
})
: null}
{shareToken && previewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{previewItems.map((item) => (
<ShareResourcePreviewCard
key={item.id}
item={item}
shareToken={shareToken}
onOpenProgram={onOpenProgram}
/>
))}
</div>
) : null}
{diffBlocks.length > 0 ? (
<div className="chat-share-page__resource-list">
{diffBlocks.map((diffText, index) => (
<ShareInlinePreviewCard
key={`${message.id}-diff-${index}`}
item={{
id: `${message.id}-diff-${index}`,
label: diffBlocks.length > 1 ? `diff preview ${index + 1}` : 'diff preview',
kind: 'diff',
previewText: diffText,
}}
/>
))}
</div>
) : null}
</div>
);
}
function ShareInlinePreviewCard({
item,
onPreviewViewed,
}: {
item: {
id: string;
label: string;
kind: PreviewKind;
previewText: string;
};
onPreviewViewed?: (() => void) | null;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const target = useMemo<ChatPreviewTarget>(
() => ({
label: item.label,
url: item.label,
kind: item.kind,
}),
[item.kind, item.label],
);
return (
<section className="chat-share-page__resource-card app-chat-preview-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{item.label}</span>
<span className="app-chat-preview-card__kind">{item.kind} preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__open-link"
aria-label={isExpanded ? 'diff preview 접기' : 'diff preview 미리보기'}
onClick={() =>
setIsExpanded((current) => {
const nextExpanded = !current;
if (nextExpanded) {
onPreviewViewed?.();
}
return nextExpanded;
})
}
icon={<EyeOutlined />}
/>
</div>
</div>
{isExpanded ? (
<div className="app-chat-preview-card__body chat-share-page__resource-card-body">
<ChatPreviewBody
target={target}
previewText={item.previewText}
isPreviewLoading={false}
previewError=""
renderHtmlAsFrame
/>
</div>
) : null}
</section>
);
}
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<ChatPreviewTarget>(
() => ({
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 (
<section className="chat-share-page__resource-card app-chat-preview-card">
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{item.label}</span>
<span className="app-chat-preview-card__kind">{item.kind} preview</span>
</div>
</div>
<div className="app-chat-preview-card__actions">
<Button
type="text"
size="small"
className="app-chat-preview-card__open-link"
aria-label={isPreviewOpen ? `${item.label} 접기` : `${item.label} 미리보기`}
onClick={() =>
setIsPreviewOpen((current) => {
const nextOpen = !current;
if (nextOpen) {
onPreviewViewed?.();
}
return nextOpen;
})
}
icon={<EyeOutlined />}
/>
<Button
type="text"
size="small"
className="app-chat-preview-card__open-link"
icon={<FullscreenOutlined />}
aria-label={`${item.label} 확대`}
onClick={() => {
onPreviewViewed?.();
setIsPreviewOpen(true);
setIsExpanded(true);
}}
/>
</div>
</div>
{isPreviewOpen ? (
<div className="app-chat-preview-card__body chat-share-page__resource-card-body">
<ChatPreviewBody
target={target}
previewText={previewText}
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType || undefined}
maxMarkdownBlocks={5}
renderHtmlAsFrame
/>
</div>
) : null}
<FullscreenPreviewModal
open={isExpanded}
title={item.label}
meta={`${item.kind} preview`}
fillContent
onMinimize={onOpenProgram ? () => {
handleOpenProgram();
setIsExpanded(false);
} : null}
onClose={() => setIsExpanded(false)}
>
<ChatPreviewBody
target={target}
previewText={previewText}
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType || undefined}
renderHtmlAsFrame
fullscreen
/>
</FullscreenPreviewModal>
</section>
);
}
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<string, ChatConversationRequest>;
answerText: string;
relatedMessages: ChatMessage[];
mode: ShareRequestCardMode;
onSubmitPrompt: (payload: PromptSubmitPayload & { parentRequestId: string; promptIndex: number; sourceMessageId: number }) => Promise<boolean>;
promptSelections: Record<string, PendingSharePromptSelection>;
onPromptSelectionChange: (selectionKey: string, selection: PromptDraftSelection | null) => void;
onPromptSubmitted: (selectionKey: string, selection: PromptDraftSelection) => void;
onCompletePrompt?: ((parentRequestId: string) => Promise<void>) | null;
onCompleteVerification?: ((parentRequestId: string) => Promise<void>) | null;
isPromptCompletionSaving?: boolean;
isVerificationCompletionSaving?: boolean;
isVerificationCompleted?: boolean;
hasChildRequest?: boolean;
activeReplyRequestId?: string | null;
onReplyToResponse?: ((parentRequestId: string) => void) | null;
onCancelDisconnectedRequest?: ((parentRequestId: string) => Promise<void>) | null;
isRequestCancellationSaving?: boolean;
onRetryDisconnectedRequest?: ((parentRequestId: string) => Promise<void>) | null;
isRequestRetrySaving?: boolean;
shareToken: string;
onOpenProgram?: ((target: ShareProgramTarget) => void) | null;
canUploadAttachments?: boolean;
onUploadAttachment?: ((file: File) => Promise<ChatComposerAttachment>) | 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 (
<section
id={`chat-share-request-${request.requestId}`}
className="chat-share-page__request-block"
ref={(element) => {
onSetRequestAnchor?.(request.requestId, element);
}}
>
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
<span className="chat-share-page__message-time">{formatTimeLabel(request.createdAt)}</span>
{isRetriedRequest(request) ? <Tag color="processing"> {retryCount}</Tag> : null}
{canCancelDisconnectedRequest ? (
<Button
type="text"
size="small"
danger
className="chat-share-page__prompt-complete-button"
loading={isRequestCancellationSaving}
icon={<CloseOutlined />}
onClick={() => {
void onCancelDisconnectedRequest?.(request.requestId);
}}
>
</Button>
) : null}
{canRetryDisconnectedRequest ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
loading={isRequestRetrySaving}
icon={<ReloadOutlined />}
onClick={() => {
void onRetryDisconnectedRequest?.(request.requestId);
}}
>
</Button>
) : null}
</div>
{shouldRenderQuestion ? (
<>
<div className="chat-share-page__message-tone chat-share-page__message-tone--question">
<span className="chat-share-page__message-tone-label"></span>
<ExpandableMessageText text={questionText || '-'} />
</div>
{questionPreviewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{questionPreviewItems.map((item) => (
<ShareResourcePreviewCard
key={`request-${request.requestId}-${item.id}`}
item={item}
shareToken={shareToken}
onOpenProgram={onOpenProgram}
/>
))}
</div>
) : null}
</>
) : null}
{shouldRenderFullAnswer ? (
<>
{shouldRenderAnswerSummary ? (
<>
<div className="chat-share-page__message-divider" aria-hidden="true" />
{canCompleteSummaryVerification || canReplyFromSummary || isVerificationCompleted ? (
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
{canCompleteSummaryVerification ? (
<Button
type="text"
size="small"
className="chat-share-page__prompt-complete-button"
icon={<CheckOutlined />}
loading={isVerificationCompletionSaving}
onClick={() => {
void onCompleteVerification?.(request.requestId);
}}
>
</Button>
) : null}
{!canCompleteSummaryVerification && isVerificationCompleted ? <Tag color="success"> </Tag> : null}
{canReplyFromSummary ? (
<Button
type="text"
size="small"
className={`chat-share-page__prompt-complete-button chat-share-page__response-reply-button${
isSummaryReplyActive ? ' chat-share-page__response-reply-button--active' : ''
}`}
icon={<SendOutlined />}
onClick={() => {
onReplyToResponse?.(request.requestId);
}}
>
{isSummaryReplyActive ? '답변 참조 중' : '답변하기'}
</Button>
) : null}
</div>
) : null}
<div className="chat-share-page__message-tone chat-share-page__message-tone--answer">
<span className="chat-share-page__message-tone-label"></span>
<ExpandableMessageText text={resolvedAnswerText} />
</div>
</>
) : null}
{responseMessages.length > 0 ? (
<div className="chat-share-page__bundle-list">
{responseMessages.map((message) => (
<ShareResponseBlock
key={message.id}
message={message}
requestById={responseRequestById}
fallbackRequestId={request.requestId}
onSubmitPrompt={onSubmitPrompt}
promptSelections={promptSelections}
onPromptSelectionChange={onPromptSelectionChange}
onPromptSubmitted={onPromptSubmitted}
onCompletePrompt={onCompletePrompt}
onCompleteVerification={onCompleteVerification}
isPromptCompletionSaving={isPromptCompletionSaving}
isPromptManualCompleted={Boolean(request.manualPromptCompletedAt)}
isVerificationCompletionSaving={isVerificationCompletionSaving}
isVerificationCompleted={isVerificationCompleted}
hasChildRequest={hasChildRequest}
activeReplyRequestId={activeReplyRequestId}
onReplyToResponse={onReplyToResponse}
shareToken={shareToken}
onOpenProgram={onOpenProgram}
canUploadAttachments={canUploadAttachments}
onUploadAttachment={onUploadAttachment}
onSetResponseAnchor={onSetResponseAnchor}
onSetPromptAnchor={onSetPromptAnchor}
/>
))}
</div>
) : null}
</>
) : null}
</section>
);
}
export function ChatSharePage() {
const { message, modal } = App.useApp();
const appConfig = useAppConfig();
const { token = '' } = useParams();
const normalizedToken = token.trim();
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const pageRef = useRef<HTMLDivElement | null>(null);
const requestAnchorRefs = useRef(new Map<string, HTMLElement>());
const responseAnchorRefs = useRef(new Map<number, HTMLDivElement>());
const promptAnchorRefs = useRef(new Map<string, HTMLDivElement>());
const isInteractingRef = useRef(false);
const deferredSnapshotRef = useRef<ChatShareSnapshot | null>(null);
const liveRefreshTimerRef = useRef<number | null>(null);
const snapshotRefreshPromiseRef = useRef<Promise<boolean> | null>(null);
const pendingSilentRefreshRef = useRef(false);
const scrollSyncFrameRef = useRef<number | null>(null);
const scrollIdleTimerRef = useRef<number | null>(null);
const programmaticScrollTargetRef = useRef<'top' | 'bottom' | null>(null);
const lastScrollTopRef = useRef(0);
const [snapshot, setSnapshot] = useState<ChatShareSnapshot | null>(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<ChatComposerAttachment[]>([]);
const [isSending, setIsSending] = useState(false);
const [isUploadingComposerAttachment, setIsUploadingComposerAttachment] = useState(false);
const [immediateSendPinnedByToken, setImmediateSendPinnedByToken] = useState<Record<string, boolean>>(() =>
readStoredShareImmediateSendPinnedByToken(),
);
const [nowMs, setNowMs] = useState(() => Date.now());
const [expandMode, setExpandMode] = useState<ShareExpandMode>('pending');
const [latestRequestId, setLatestRequestId] = useState('');
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingSharePromptSelection>>({});
const [pendingPromptCompletionRequestIds, setPendingPromptCompletionRequestIds] = useState<string[]>([]);
const [pendingVerificationCompletionRequestIds, setPendingVerificationCompletionRequestIds] = useState<string[]>([]);
const [pendingRequestCancellationIds, setPendingRequestCancellationIds] = useState<string[]>([]);
const [pendingRequestRetryIds, setPendingRequestRetryIds] = useState<string[]>([]);
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<string | null>(null);
const [editingRoomDefaultContextIds, setEditingRoomDefaultContextIds] = useState<string[]>([]);
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<number | null>(null);
const [editingRoomNotifyOffline, setEditingRoomNotifyOffline] = useState(false);
const [roomNotificationClientStatus, setRoomNotificationClientStatus] = useState<ShareNotificationClientStatus>(() =>
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<ShareSearchPanelMode>('all');
const [selectedAppEnvironment, setSelectedAppEnvironment] = useState<ShareAppEnvironment>(() =>
typeof window === 'undefined' ? 'preview' : resolveShareAppEnvironmentFromOrigin(window.location.origin),
);
const [programTarget, setProgramTarget] = useState<ShareProgramTarget | null>(null);
const [programMinimizedTarget, setProgramMinimizedTarget] = useState<ShareProgramTarget | null>(null);
const [isProgramMinimized, setIsProgramMinimized] = useState(false);
const programMinimizedCardRef = useRef<HTMLDivElement | null>(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<TextAreaRef | null>(null);
const composerAttachmentInputRef = useRef<HTMLInputElement | null>(null);
const composerInputShellRef = useRef<HTMLDivElement | null>(null);
const immediateSendHoldTimerRef = useRef<number | null>(null);
const suppressImmediateSendClickRef = useRef(false);
const focusedMobileInputRef = useRef<HTMLElement | null>(null);
const composerFocusScrollTimerIdsRef = useRef<number[]>([]);
const [isComposerViewportCompacted, setIsComposerViewportCompacted] = useState(false);
const [appLaunchUsage, setAppLaunchUsage] = useState<ShareAppLaunchUsageMap>(() => 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<ServerCommandItem | null>(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<HTMLDivElement>) => {
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 ? (
<div
ref={programMinimizedCardRef}
className="chat-share-page__program-minimized"
style={{
transform: `translate3d(${programMinimizedPosition.x}px, ${programMinimizedPosition.y}px, 0)`,
zIndex: SHARE_PROGRAM_MINIMIZED_Z_INDEX,
}}
>
<div
className="chat-share-page__program-minimized-drag"
onPointerDown={handleProgramMinimizedPointerDown}
>
<span className="chat-share-page__program-minimized-drag-grip" aria-hidden="true" />
<span className="chat-share-page__program-minimized-title">{minimizedProgramTarget.label}</span>
</div>
<div className="chat-share-page__program-minimized-actions">
<Button
type="primary"
size="small"
icon={<AppstoreOutlined />}
className="chat-share-page__program-minimized-button"
onClick={handleRestoreProgram}
>
</Button>
<Button
type="text"
size="small"
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
icon={<CloseOutlined />}
aria-label="프로그램 닫기"
onClick={handleCloseProgram}
/>
</div>
</div>
) : 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<boolean>((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<boolean>((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<boolean>((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<boolean>((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<HTMLTextAreaElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<ChatComposerAttachment> => 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<HTMLTextAreaElement>) => {
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<boolean>((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<string, ChatMessage[]>();
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<string, string>();
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<HTMLElement>(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<ShareSearchResult[]>(() => {
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<TokenUsagePeriodKey, TokenUsageWindowSummary>,
[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<MenuProps['items']>(
() => [
{
key: 'conversation-title',
label: (
<span className="chat-share-page__settings-item chat-share-page__settings-item--summary">
<span className="chat-share-page__settings-item-title">
{snapshot?.conversation.title?.trim() || '현재 채팅방'}
</span>
<span className="chat-share-page__settings-item-description"> </span>
</span>
),
disabled: true,
},
{
key: 'conversation-search',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"></span>
<span className="chat-share-page__settings-item-description">, , , .</span>
</span>
),
icon: <SearchOutlined />,
},
{
key: 'conversation-refresh',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-description">PWA .</span>
</span>
),
icon: <ReloadOutlined />,
},
...(allowedManagementApps.length > 0 || allowedPlayAppEntries.length > 0
? [
{
key: 'conversation-apps',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title">Apps</span>
<span className="chat-share-page__settings-item-description">
.
</span>
</span>
),
icon: <AppstoreOutlined />,
},
]
: []),
{
key: 'conversation-token-usage',
label: (
<span className="chat-share-page__settings-item chat-share-page__settings-item--token">
<span className="chat-share-page__settings-item-title-row">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-meta">
{selectedTokenUsageSetting ? selectedTokenUsageSetting.name : '설정 필요'}
</span>
</span>
<span className="chat-share-page__settings-item-description">
{selectedTokenUsageSetting
? `지금 사용 가능 ${tokenUsageOverview.currentAvailableLabel}`
: '공유 링크 생성 시 선택된 토큰 설정이 없습니다.'}
</span>
<span className="chat-share-page__settings-item-description">
5 {tokenUsageOverview.fiveHourCountdownLabel}
</span>
<span className="chat-share-page__settings-meter" aria-hidden="true">
<span
className="chat-share-page__settings-meter-fill"
style={{
width: `${
selectedTokenUsageSetting && tokenUsageFiveHourSummary.percentage != null
? Math.max(6, tokenUsageFiveHourSummary.percentage)
: 18
}%`,
}}
/>
</span>
</span>
),
icon: <SettingOutlined />,
},
...(canOpenSharedRoomSettings
? [
{
key: 'conversation-room-settings',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-description">
Codex Live와 Context .
</span>
</span>
),
icon: <AppstoreOutlined />,
},
]
: []),
...(hasWorkServerCommandApp
? [
{
key: 'conversation-work-server-command',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title-row">
<span className="chat-share-page__settings-item-title">
<span
className={`chat-share-page__settings-version-indicator chat-share-page__settings-version-indicator--${resolveShareWorkServerVersionStatus(shareWorkServerCommand)}`}
aria-hidden="true"
/>
</span>
<span
className={`chat-share-page__settings-status-badge chat-share-page__settings-status-badge--${resolveShareWorkServerVersionStatus(shareWorkServerCommand)}`}
>
{resolveShareWorkServerStatusLabel(shareWorkServerCommand)}
</span>
</span>
<span className="chat-share-page__settings-item-description">
{resolveShareWorkServerVersionDescription(shareWorkServerCommand)}
</span>
<span className="chat-share-page__settings-item-meta chat-share-page__settings-item-meta--detail">
{resolveShareWorkServerVersionLabel(shareWorkServerCommand)}
</span>
</span>
),
icon: <ReloadOutlined />,
},
]
: []),
{
key: 'conversation-clear',
label: (
<span className="chat-share-page__settings-item">
<span className="chat-share-page__settings-item-title"> </span>
<span className="chat-share-page__settings-item-description">, , .</span>
</span>
),
icon: <DeleteOutlined />,
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<NonNullable<MenuProps['onClick']>>(
({ 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<MenuProps['items']>(
() => [
{
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<string>();
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 (
<div className="chat-share-page chat-share-page--centered">
<Spin size="large" />
</div>
);
}
if (requiresAccessPin) {
return (
<div className="chat-share-page chat-share-page--centered">
<div className="chat-share-page__panel chat-share-page__empty-card chat-share-page__lock-card">
<Title level={4}> </Title>
<Paragraph> 4 .</Paragraph>
<div className="chat-share-page__lock-form">
<Input.Password
value={accessPinInput}
maxLength={SHARE_ACCESS_PIN_MAX_LENGTH}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="숫자 4자리"
iconRender={renderAccessPinVisibilityIcon}
onChange={(event) => {
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 ? <Text type="danger">{accessPinSubmitError}</Text> : null}
<Button type="primary" onClick={() => void handleUnlockShare()}>
</Button>
</div>
</div>
</div>
);
}
if (errorMessage || !snapshot) {
return (
<div className="chat-share-page chat-share-page--centered">
<div className="chat-share-page__panel chat-share-page__empty-card">
<Title level={4}> .</Title>
<Paragraph>{errorMessage || '공유 데이터가 없습니다.'}</Paragraph>
</div>
</div>
);
}
return (
<>
<div
ref={scrollContainerRef}
className={`chat-share-page${isComposerViewportCompacted ? ' chat-share-page--composer-viewport-compacted' : ''}`}
onScroll={handleScrollContainerScroll}
onFocusCapture={handleScrollContainerFocusCapture}
onBlurCapture={handleScrollContainerBlurCapture}
>
<div ref={pageRef} className="chat-share-page__shell">
{isPromptShare ? (
<div className="chat-share-page__prompt-layout">
<section className="chat-share-page__panel chat-share-page__panel--focus">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<Title level={5}> prompt</Title>
</div>
{promptTarget &&
promptTargetRequestId &&
!isPromptResolved(promptTarget.prompt) &&
!snapshot.targetRequest.manualPromptCompletedAt &&
!isRequestInFlight(snapshot.targetRequest.status) ? (
<div className="chat-share-page__section-actions">
<Button
size="small"
icon={<CheckOutlined />}
loading={pendingPromptCompletionRequestIds.includes(promptTargetRequestId)}
onClick={() => {
void handleCompletePrompt(promptTargetRequestId);
}}
>
</Button>
</div>
) : null}
</div>
{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 (
<ChatPromptCard
target={target}
readOnly={Boolean(snapshot.targetRequest.manualPromptCompletedAt)}
draftSelection={draftSelection}
submittedSelection={submittedSelection}
onSelectionChange={(selection) => 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}
/>
);
})()
) : (
<Text type="secondary"> prompt가 .</Text>
)}
</section>
{lastResponseMessage ? (
<section className="chat-share-page__panel chat-share-page__panel--focus">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<Title level={5}></Title>
<Text type="secondary"> prompt에 .</Text>
</div>
</div>
<ShareResponseBlock
message={lastResponseMessage}
requestById={new Map(promptTargetRequestId ? [[promptTargetRequestId, snapshot.targetRequest]] : [])}
emphasisLabel="최신 결과"
onSubmitPrompt={handleSubmitPrompt}
promptSelections={pendingPromptSelections}
onPromptSelectionChange={updatePendingPromptSelection}
onPromptSubmitted={markPendingPromptSelectionSubmitted}
onCompletePrompt={handleCompletePrompt}
onCompleteVerification={handleCompleteVerification}
isPromptCompletionSaving={pendingPromptCompletionRequestIds.includes(promptTargetRequestId)}
isPromptManualCompleted={Boolean(snapshot.targetRequest.manualPromptCompletedAt)}
isVerificationCompletionSaving={pendingVerificationCompletionRequestIds.includes(promptTargetRequestId)}
isVerificationCompleted={Boolean(snapshot.targetRequest.manualVerificationCompletedAt)}
shareToken={normalizedToken}
canUploadAttachments={Boolean(snapshot?.share.sessionId?.trim())}
onUploadAttachment={handleUploadPromptAttachment}
onSetResponseAnchor={setResponseAnchorRef}
onSetPromptAnchor={setPromptAnchorRef}
/>
</section>
) : null}
{promptShareFollowupRequests.length > 0 ? (
<section className="chat-share-page__panel chat-share-page__panel--focus">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<Title level={5}> </Title>
<Text type="secondary"> .</Text>
</div>
</div>
<div className="chat-share-page__message-list">
{promptShareFollowupRequests.map((request) => (
<ShareRequestCard
key={request.requestId}
request={request}
requestById={requestById}
answerText={requestAnswerTextById.get(request.requestId) ?? ''}
relatedMessages={requestMessagesById.get(request.requestId) ?? []}
mode="full"
onSubmitPrompt={handleSubmitPrompt}
promptSelections={pendingPromptSelections}
onPromptSelectionChange={updatePendingPromptSelection}
onPromptSubmitted={markPendingPromptSelectionSubmitted}
onCompletePrompt={handleCompletePrompt}
onCompleteVerification={handleCompleteVerification}
isPromptCompletionSaving={pendingPromptCompletionRequestIds.includes(request.requestId)}
isVerificationCompletionSaving={pendingVerificationCompletionRequestIds.includes(request.requestId)}
isVerificationCompleted={Boolean(request.manualVerificationCompletedAt)}
hasChildRequest={(childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 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}
/>
))}
</div>
</section>
) : null}
</div>
) : (
<div className={contentLayoutClassName}>
<section className="chat-share-page__panel chat-share-page__conversation-panel">
<div className="chat-share-page__section-head">
<div className="chat-share-page__section-copy">
<div className="chat-share-page__section-title-row">
<Title level={5}></Title>
<span
className={`chat-share-page__live-dot ${isLiveConnected ? 'chat-share-page__live-dot--connected' : 'chat-share-page__live-dot--disconnected'}`}
aria-label={isLiveConnected ? '웹소켓 연결됨' : '웹소켓 연결 끊김'}
title={isLiveConnected ? '웹소켓 연결됨' : '웹소켓 연결 끊김'}
/>
{aggregateStatusTag ? <Tag color={aggregateStatusTag.color}>{aggregateStatusTag.label}</Tag> : null}
<Text type="secondary" className="chat-share-page__header-summary">
{headerSummaryLabel}
</Text>
</div>
</div>
<div className="chat-share-page__section-actions">
<div className="chat-share-page__request-nav" aria-label="요청 이동">
<Button
type="text"
size="small"
className="chat-share-page__section-action"
icon={<LeftOutlined />}
disabled={!canMoveToPreviousRequest}
onClick={handleMoveToPreviousRequest}
>
</Button>
<Button
type="text"
size="small"
className="chat-share-page__section-action"
icon={<RightOutlined />}
iconPosition="end"
disabled={!canMoveToNextRequest}
onClick={handleMoveToNextRequest}
>
</Button>
</div>
<Dropdown
trigger={['click']}
menu={{
items: shareHeaderSettingsItems,
className: 'chat-share-page__settings-menu',
onClick: handleShareHeaderSettingsClick,
}}
placement="bottomRight"
>
<Button
type="text"
size="small"
className="chat-share-page__section-action chat-share-page__section-action--tool"
aria-label="채팅 설정"
title="채팅 설정"
icon={<SettingOutlined />}
>
<span className="chat-share-page__tool-button-label"></span>
</Button>
</Dropdown>
</div>
</div>
<div className="chat-share-page__message-list">
{headerInquiryRequest ? (
<section className="chat-share-page__first-inquiry">
<div className="chat-share-page__first-inquiry-head">
<div className="chat-share-page__first-inquiry-copy">
<div className="chat-share-page__first-inquiry-title-row">
<Title
level={5}
className="chat-share-page__first-inquiry-title"
ellipsis={{ rows: 1, tooltip: headerTitleText }}
>
{headerTitleText}
</Title>
{canOpenSharedRoomSettings ? (
<Button
type="text"
size="small"
className="chat-share-page__section-action chat-share-page__section-action--tool"
aria-label="채팅방 이름 및 설정 편집"
title="채팅방 이름 및 설정 편집"
icon={<EditOutlined />}
onClick={() => {
openSharedRoomSettings();
}}
/>
) : null}
</div>
</div>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: shareExpandModeMenuItems,
selectable: true,
selectedKeys: [expandMode],
onClick: handleSelectExpandMode,
}}
>
<Button
type="text"
size="small"
className={`chat-share-page__section-action chat-share-page__section-action--tool chat-share-page__expand-mode-filter${expandMode !== 'latest' ? ' chat-share-page__expand-mode-filter--active' : ''}`}
aria-label={`공유 채팅 펼치기 필터: ${getShareExpandModeLabel(expandMode)} ${requestProgressLabel}`.trim()}
title={`공유 채팅 펼치기 필터: ${getShareExpandModeLabel(expandMode)} ${requestProgressLabel}`.trim()}
icon={<FilterOutlined />}
>
<span className="chat-share-page__tool-button-label">
{expandMode === 'latest' ? '필터' : getShareExpandModeLabel(expandMode)}
</span>
</Button>
</Dropdown>
</div>
</section>
) : null}
{expandMode === 'latest' && hiddenBeforeCount > 0 ? (
<div className="chat-share-page__omission chat-share-page__omission--collapsed chat-share-page__omission--before" aria-label={`위쪽 채팅 ${hiddenBeforeCount}건 숨김`}>
<span className="chat-share-page__omission-line" aria-hidden="true" />
<Text type="secondary" className="chat-share-page__omission-label">{`위로 생략 ${hiddenBeforeCount}`}</Text>
<span className="chat-share-page__omission-line" aria-hidden="true" />
</div>
) : null}
{displayedRequests.map((request) => (
<ShareRequestCard
key={request.requestId}
request={request}
requestById={requestById}
answerText={requestAnswerTextById.get(request.requestId) ?? ''}
relatedMessages={requestMessagesById.get(request.requestId) ?? []}
mode="full"
onSubmitPrompt={handleSubmitPrompt}
promptSelections={pendingPromptSelections}
onPromptSelectionChange={updatePendingPromptSelection}
onPromptSubmitted={markPendingPromptSelectionSubmitted}
onCompletePrompt={handleCompletePrompt}
onCompleteVerification={handleCompleteVerification}
isPromptCompletionSaving={pendingPromptCompletionRequestIds.includes(request.requestId)}
isVerificationCompletionSaving={pendingVerificationCompletionRequestIds.includes(request.requestId)}
isVerificationCompleted={Boolean(request.manualVerificationCompletedAt)}
hasChildRequest={(childRequestCountByParentId.get(request.requestId.trim()) ?? 0) > 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 ? (
<div className="chat-share-page__omission chat-share-page__omission--collapsed chat-share-page__omission--after" aria-label={`아래쪽 채팅 ${hiddenAfterCount}건 숨김`}>
<span className="chat-share-page__omission-line" aria-hidden="true" />
<Text type="secondary" className="chat-share-page__omission-label">{`아래로 생략 ${hiddenAfterCount}`}</Text>
<span className="chat-share-page__omission-line" aria-hidden="true" />
</div>
) : null}
{expandMode === 'pending' && displayedRequests.length === 0 ? (
<div className="chat-share-page__omission chat-share-page__omission--empty">
<span className="chat-share-page__omission-line" aria-hidden="true" />
<Text type="secondary"> .</Text>
<span className="chat-share-page__omission-line" aria-hidden="true" />
</div>
) : null}
</div>
</section>
{canSendMessage ? (
<>
{expandMode === 'latest' && collapsedActivitySummary.length > 0 ? (
<section
id="chat-share-activity-panel"
className="chat-share-page__panel chat-share-page__activity-panel"
aria-label="현재 진행 상황"
>
<div className="chat-share-page__section-head chat-share-page__section-head--compact">
<div className="chat-share-page__section-copy">
<Title level={5}> </Title>
</div>
</div>
<div className="chat-share-page__activity-summary-list">
{collapsedActivitySummary.map((item) => (
<Text key={item} type="secondary" className="chat-share-page__activity-summary-item">{item}</Text>
))}
</div>
</section>
) : null}
<section className="chat-share-page__panel chat-share-page__composer-panel">
<div className="chat-share-page__composer-shell app-chat-panel__composer">
<input
ref={composerAttachmentInputRef}
type="file"
multiple
accept={SHARE_ATTACHMENT_ACCEPT}
className="chat-share-page__composer-file-input"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
void handleUploadComposerAttachments(files);
}}
/>
<div className="chat-share-page__composer-topline">
<div className="app-chat-panel__composer-utility-buttons">
<Button
icon={<PlusOutlined />}
aria-label="파일 첨부"
title="파일 첨부"
onClick={() => {
composerAttachmentInputRef.current?.click();
}}
disabled={isSending || isUploadingComposerAttachment}
loading={isUploadingComposerAttachment}
/>
</div>
<div className="app-chat-panel__composer-type chat-share-page__composer-type-readonly">
<Select
value={shareChatTypeLabel}
aria-label="현재 채팅유형"
options={[
{
value: shareChatTypeLabel,
label: (
<div className="app-chat-panel__type-option">
<span>{shareChatTypeLabel}</span>
</div>
),
},
]}
disabled
/>
</div>
<div className="app-chat-panel__composer-actions chat-share-page__composer-topline-actions">
<div className="app-chat-panel__composer-action-buttons">
<Button
icon={<ThunderboltOutlined />}
type={isImmediateSendPinned ? 'primary' : 'default'}
title={isImmediateSendPinned ? '즉시전송 항상 켜짐' : '즉시 전송'}
aria-label={isImmediateSendPinned ? '즉시전송 항상 켜짐' : '즉시 전송'}
onPointerDown={startImmediateSendHoldTimer}
onPointerUp={clearImmediateSendHoldTimer}
onPointerLeave={clearImmediateSendHoldTimer}
onPointerCancel={clearImmediateSendHoldTimer}
onClick={handleImmediateSendButtonClick}
loading={isSending}
disabled={(!draftText.trim() && composerAttachments.length === 0) || isUploadingComposerAttachment}
/>
<Button
type="primary"
icon={<SendOutlined />}
title={isImmediateSendPinned ? '즉시전송으로 보내기' : '대기열로 보내기'}
aria-label={isImmediateSendPinned ? '즉시전송으로 보내기' : '대기열로 보내기'}
onClick={() => void handleSendMessage()}
loading={isSending}
disabled={(!draftText.trim() && composerAttachments.length === 0) || isUploadingComposerAttachment}
/>
</div>
</div>
</div>
<div className="chat-share-page__composer-send-mode" aria-live="polite">
<Tag color={isImmediateSendPinned ? 'blue' : 'default'}>{shareSendModeSummary.tagLabel}</Tag>
<Text type="secondary" className="chat-share-page__composer-send-mode-text">
{shareSendModeSummary.description}
</Text>
</div>
{replyReferenceRequest ? (
<div className="chat-share-page__reply-reference" aria-live="polite">
<div className="chat-share-page__reply-reference-copy">
<span className="chat-share-page__reply-reference-label"> </span>
<span className="chat-share-page__reply-reference-text">{replyReferenceSummary}</span>
</div>
<Button
type="text"
size="small"
className="chat-share-page__reply-reference-clear"
onClick={() => {
setReplyReferenceRequestId('');
}}
>
</Button>
</div>
) : null}
<div className="chat-share-page__composer-entry-row">
<div
ref={composerInputShellRef}
className={`app-chat-panel__composer-input-shell chat-share-page__composer-input-shell${
isSending ? ' chat-share-page__composer-input-shell--sending' : ''
}`}
>
<Input.TextArea
ref={composerRef}
value={draftText}
onChange={(event) => setDraftText(event.target.value)}
onKeyDown={handleComposerKeyDown}
onPaste={handleComposerPaste}
onFocus={handleComposerFocus}
onBlur={handleComposerBlur}
placeholder={
replyReferenceRequest
? '선택한 답변을 바탕으로 공유채팅에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
: '공유채팅에 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
}
aria-busy={isSending}
rows={1}
maxLength={20000}
disabled={isSending || isUploadingComposerAttachment}
/>
</div>
</div>
{composerAttachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{composerAttachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
setComposerAttachments((current) => current.filter((item) => item.id !== attachment.id));
}}
/>
</div>
))}
</div>
) : null}
</div>
</section>
</>
) : !isPromptShare && shareBlockedReason ? (
<section className="chat-share-page__panel chat-share-page__composer-panel">
<Alert
showIcon
type="warning"
message="이 공유 링크에서는 더 이상 답변을 보낼 수 없습니다."
description={shareBlockedReason}
/>
</section>
) : null}
</div>
)}
</div>
</div>
{showScrollToTop || showScrollToBottom ? (
<div className="chat-share-page__scroll-jump">
{showScrollToTop ? (
<Button type="primary" shape="circle" icon={<UpOutlined />} aria-label="채팅 처음으로 이동" onClick={handleScrollToTop} />
) : null}
{showScrollToBottom ? (
<Button type="primary" shape="circle" icon={<DownOutlined />} aria-label="채팅 끝으로 이동" onClick={handleScrollToBottom} />
) : null}
</div>
) : null}
<Modal
open={Boolean(previousQuestionModalRequest)}
title="부모 요청"
footer={null}
centered
onCancel={() => {
setPreviousQuestionModalRequestId('');
}}
>
<div className="chat-share-page__previous-question-modal">
<div className="chat-share-page__previous-question-modal-section">
<Text strong> </Text>
<Text type="secondary">
{previousQuestionModalRequest ? formatTimeLabel(previousQuestionModalRequest.createdAt) || '요청 시각 없음' : ''}
</Text>
<Paragraph className="chat-share-page__previous-question-modal-text">
{previousQuestionModalText || '부모 요청 내용을 찾지 못했습니다.'}
</Paragraph>
{previousQuestionModalPreviewItems.length > 0 ? (
<div className="chat-share-page__resource-list">
{previousQuestionModalPreviewItems.map((item) => (
<ShareResourcePreviewCard
key={`previous-question-${item.id}`}
item={item}
shareToken={normalizedToken}
onOpenProgram={openProgramTarget}
/>
))}
</div>
) : null}
</div>
</div>
</Modal>
<Modal
open={isTokenUsageOpen}
footer={null}
title="토큰 관리"
className="chat-share-page__token-usage-modal"
onCancel={() => setIsTokenUsageOpen(false)}
>
<div className="chat-share-page__token-usage-modal-body">
{selectedTokenUsageSetting ? (
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<Text strong>{`${selectedTokenUsageSetting.name} (${selectedTokenUsageSetting.id})`}</Text>
</div>
) : null}
<div className="chat-share-page__token-usage-overview-card" aria-label="토큰 집계 요약">
<div className="chat-share-page__token-usage-overview-head">
<div>
<div className="chat-share-page__token-usage-overview-label"> </div>
<div className="chat-share-page__token-usage-overview-value">{tokenUsageOverview.currentAvailableLabel}</div>
</div>
<Tag color={tokenUsageFiveHourSummary.requestCount > 0 ? 'gold' : 'default'}>
{tokenUsageOverview.fiveHourBucketLabel}
</Tag>
</div>
<div className="chat-share-page__token-usage-meter-card">
<div className="chat-share-page__token-usage-meter-track chat-share-page__token-usage-meter-track--merged" aria-hidden="true">
{tokenUsageOverview.meterLayers.map((item) => (
<span
key={item.key}
className={`chat-share-page__token-usage-meter-fill ${item.colorClassName}`}
style={{ width: `${item.widthPercent}%` }}
/>
))}
</div>
<div className="chat-share-page__token-usage-meter-legend">
{tokenUsageOverview.meterLegendItems.map((item) => (
<div key={item.key} className="chat-share-page__token-usage-meter-row">
<span className={`chat-share-page__token-usage-meter-dot ${item.colorClassName}`} aria-hidden="true" />
<span className="chat-share-page__token-usage-meter-label">{item.label}</span>
<span className="chat-share-page__token-usage-meter-value">{item.caption}</span>
</div>
))}
</div>
</div>
<div className="chat-share-page__token-usage-summary-copy">{tokenUsageOverview.oneLineSummary}</div>
<div className="chat-share-page__token-usage-reset-grid">
<div className="chat-share-page__token-usage-reset-card chat-share-page__token-usage-reset-card--primary">
<span className="chat-share-page__token-usage-reset-label">5 </span>
<strong className="chat-share-page__token-usage-reset-value">{tokenUsageOverview.fiveHourCountdownLabel}</strong>
<span className="chat-share-page__token-usage-summary-copy">
{tokenUsageOverview.fiveHourResetTimeLabel} · {tokenUsageOverview.fiveHourResetAmountLabel}
</span>
</div>
<div className="chat-share-page__token-usage-reset-card">
<span className="chat-share-page__token-usage-reset-label">1 </span>
<strong className="chat-share-page__token-usage-reset-value chat-share-page__token-usage-reset-value--compact">
{tokenUsageOverview.sevenDayResetTimeLabel}
</strong>
</div>
</div>
</div>
<div className="chat-share-page__token-usage-select-row">
<Text type="secondary"> </Text>
<div className="chat-share-page__token-usage-share-url-row">
<Paragraph
className="chat-share-page__token-usage-share-url"
ellipsis={currentShareUrl ? { rows: 1, tooltip: currentShareUrl } : { rows: 1 }}
style={{ maxWidth: '100%', marginBottom: 0 }}
>
{currentShareUrl || 'URL 없음'}
</Paragraph>
{currentShareUrl ? (
<Button
type="text"
size="small"
className="chat-share-page__token-usage-copy-button"
icon={<CopyOutlined />}
aria-label="토큰 URL 복사"
onClick={() => {
void handleCopyCurrentShareUrl();
}}
/>
) : null}
</div>
<div className="chat-share-page__token-usage-token-meta">
<Text type="secondary">{currentShareTokenUsageStatusLabel}</Text>
</div>
</div>
<div className="chat-share-page__token-usage-summary-copy">
{selectedTokenUsageSetting
? `현재 토큰 ${
currentShareEffectiveExpiresAt ? currentShareExpiresAtLabel : currentShareDefaultPolicyLabel
} · 기본 정책 ${
currentShareDefaultExpiryLabel ? currentShareDefaultExpiryLabel.replace(/^기본 유효시간\s*/, '') : '사용기간 제한 없음'
}`
: '토큰 설정이 없으면 사용량만 집계합니다.'}
</div>
</div>
</Modal>
<Drawer
open={isRoomSettingsOpen}
title="공유 채팅방 설정"
placement="right"
width="100vw"
className="chat-share-page__room-settings-drawer"
onClose={() => {
if (isSavingRoomSettings) {
return;
}
setIsRoomSettingsOpen(false);
}}
extra={(
<div className="chat-share-page__room-settings-actions">
<Button
onClick={() => setIsRoomSettingsOpen(false)}
disabled={isSavingRoomSettings}
>
</Button>
<Button
type="primary"
loading={isSavingRoomSettings}
disabled={
isSavingRoomSettings ||
(!canManageSharedRoomSettings && !canEditSharedRoomAccessPin) ||
(canManageSharedRoomSettings && (isChatTypesLoading || isChatContextSettingsLoading))
}
onClick={() => {
void handleSaveSharedRoomSettings();
}}
>
{canManageSharedRoomSettings || canEditSharedRoomAccessPin ? '저장' : '닫기'}
</Button>
</div>
)}
>
<div className="chat-share-page__room-settings-shell">
{chatTypesErrorMessage ? <Alert showIcon type="error" message={chatTypesErrorMessage} /> : null}
{chatContextSettingsErrorMessage ? <Alert showIcon type="error" message={chatContextSettingsErrorMessage} /> : null}
{!canManageSharedRoomSettings ? (
<Alert
showIcon
type="info"
message={
canEditSharedRoomAccessPin
? '이 공유 링크는 채팅유형과 문맥은 읽기 전용이며, 비밀번호만 변경할 수 있습니다.'
: '이 공유 링크는 채팅방 설정을 읽기 전용으로만 볼 수 있습니다.'
}
/>
) : null}
<Tabs
activeKey={roomSettingsTabKey}
onChange={(value) => setRoomSettingsTabKey(value as typeof roomSettingsTabKey)}
className="chat-share-page__room-settings-tabs"
items={[
{
key: 'chat-type',
label: '채팅유형',
children: (
<div className="chat-share-page__room-settings-panel">
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
<Input
value={editingRoomTitle}
placeholder="예: 관리자 공유 채팅"
readOnly={!canManageSharedRoomSettings}
maxLength={200}
onChange={(event) => {
setEditingRoomTitle(event.target.value);
}}
/>
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
<Select
value={editingRoomChatTypeId ?? undefined}
placeholder="채팅유형 선택"
loading={isChatTypesLoading}
disabled={!canManageSharedRoomSettings}
onChange={(value) => {
setEditingRoomChatTypeId(value);
if (!isEditingRoomDefaultContextsDirty) {
setEditingRoomDefaultContextIds(resolveShareRoomDefaultContextIds(activeRoomContextSettings, chatTypeDefaults, value));
}
}}
options={enabledChatTypes.map((item) => ({
value: item.id,
label: item.name,
}))}
/>
<div className="chat-share-page__room-settings-card">
<Text strong>{enabledChatTypes.find((item) => item.id === editingRoomChatTypeId)?.name ?? '채팅유형 선택 필요'}</Text>
<Text type="secondary">
{enabledChatTypes.find((item) => item.id === editingRoomChatTypeId)?.description || '선택한 채팅유형의 기본 설명이 여기에 표시됩니다.'}
</Text>
</div>
</div>
),
},
{
key: 'default-contexts',
label: '공통 문맥',
children: (
<div className="chat-share-page__room-settings-panel">
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
{enabledDefaultContexts.length > 0 ? (
<Checkbox.Group
className="chat-share-page__room-settings-checkbox-group"
value={editingRoomDefaultContextIds}
disabled={!canManageSharedRoomSettings || isChatContextSettingsLoading}
onChange={(checkedValues) => {
setIsEditingRoomDefaultContextsDirty(true);
setEditingRoomDefaultContextIds(
checkedValues
.map((value) => String(value).trim())
.filter((value) => enabledDefaultContexts.some((context) => context.id === value)),
);
}}
>
{enabledDefaultContexts.map((item) => (
<label key={item.id} className="chat-share-page__room-settings-card">
<Checkbox value={item.id}>{item.title}</Checkbox>
<Text type="secondary">
{item.content.split('\n')[0]?.replace(/^#+\s*/, '') || '설명 없음'}
</Text>
</label>
))}
</Checkbox.Group>
) : (
<Alert showIcon type="info" message="등록된 공통 문맥이 없습니다." />
)}
</div>
),
},
{
key: 'room-context',
label: '방 전용',
children: (
<div className="chat-share-page__room-settings-panel">
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
<Input
value={editingRoomCustomContextTitle}
placeholder="예: 공유 검수용 응답 규칙"
readOnly={!canManageSharedRoomSettings}
onChange={(event) => {
setEditingRoomCustomContextTitle(event.target.value);
}}
/>
<Input.TextArea
value={editingRoomCustomContextContent}
rows={14}
placeholder="이 공유 채팅방에만 적용할 추가 규칙을 입력하세요."
readOnly={!canManageSharedRoomSettings}
onChange={(event) => {
setEditingRoomCustomContextContent(event.target.value);
}}
/>
</div>
),
},
{
key: 'notifications',
label: '채팅 알림',
children: (
<div className="chat-share-page__room-settings-panel">
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> .</Text>
</div>
<label className="chat-share-page__room-settings-toggle-card">
<Checkbox
checked={editingRoomNotifyOffline}
disabled={!canManageSharedRoomSettings}
onChange={(event) => {
setEditingRoomNotifyOffline(event.target.checked);
}}
>
</Checkbox>
<Text type="secondary">
{editingRoomNotifyOffline
? '새 답변이 도착하면 이 브라우저 클라이언트 기준으로 알림 대상에 포함합니다.'
: '이 기기는 현재 공유채팅방 오프라인 알림 대상에서 제외됩니다.'}
</Text>
</label>
<div className="chat-share-page__room-settings-card chat-share-page__room-settings-card--status">
<Text strong>{roomNotificationClientStatus.summaryLabel}</Text>
<div className="chat-share-page__room-settings-status-tags">
<Tag color={editingRoomNotifyOffline ? 'green' : 'default'}>{roomNotificationClientStatus.roomLabel}</Tag>
<Tag color={appConfig.chat.receiveRoomNotifications ? 'green' : 'default'}>{roomNotificationClientStatus.appLabel}</Tag>
<Tag color={roomNotificationClientStatus.tone === 'warning' ? 'gold' : 'default'}>
{roomNotificationClientStatus.permissionLabel}
</Tag>
<Tag color={roomNotificationClientStatus.tone === 'success' ? 'green' : 'default'}>
{roomNotificationClientStatus.registrationLabel}
</Tag>
</div>
<Text type="secondary">
, , , .
</Text>
</div>
<div className="chat-share-page__room-settings-inline-actions">
<Button size="small" onClick={() => void refreshRoomNotificationStatus(editingRoomNotifyOffline)} loading={isRefreshingRoomNotificationStatus}>
</Button>
<Button
size="small"
type="primary"
icon={<SendOutlined />}
onClick={() => void handleSendRoomNotificationTest()}
loading={isSendingRoomNotificationTest}
>
</Button>
</div>
</div>
),
},
{
key: 'security',
label: '보안',
children: (
<div className="chat-share-page__room-settings-panel">
<div className="chat-share-page__room-settings-panel-head">
<Text strong> </Text>
<Text type="secondary"> URL 4 .</Text>
</div>
<label className="chat-share-page__room-settings-toggle-card">
<Checkbox
checked={editingRoomUseAccessPin}
disabled={!canEditSharedRoomAccessPin}
onChange={(event) => {
setEditingRoomUseAccessPin(event.target.checked);
if (!event.target.checked) {
setEditingRoomAccessPin('');
}
}}
>
URL 4
</Checkbox>
</label>
<Input.Password
value={editingRoomAccessPin}
maxLength={4}
inputMode="numeric"
autoComplete="one-time-code"
iconRender={renderAccessPinVisibilityIcon}
placeholder={
editingRoomUseAccessPin
? snapshot?.share.hasAccessPin
? '변경할 때만 새 4자리를 입력'
: '숫자 4자리'
: '사용 안 함'
}
disabled={!editingRoomUseAccessPin || !canEditSharedRoomAccessPin}
onChange={(event) => {
setEditingRoomAccessPin(event.target.value);
}}
/>
<Select
value={resolveAccessPinPromptTtlOptionValue(editingRoomAccessPinPromptTtlMinutes)}
disabled={!editingRoomUseAccessPin || !canEditSharedRoomAccessPin}
options={SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
onChange={(value) => {
setEditingRoomAccessPinPromptTtlMinutes(parseAccessPinPromptTtlOptionValue(value));
}}
/>
<Text type="secondary">
{!canManageSharedRoomSettings && !canEditSharedRoomAccessPin
? '현재 비밀번호 상태만 확인할 수 있습니다.'
: editingRoomUseAccessPin
? snapshot?.share.hasAccessPin
? (editingRoomAccessPinPromptTtlMinutes ?? 0) > 0
? `현재 비밀번호가 설정되어 있습니다. 유지하려면 비워 두고 저장하세요. 한 번 잠금 해제하면 ${resolveAccessPinPromptTtlLabel(editingRoomAccessPinPromptTtlMinutes ?? 0)} 동안은 새로고침해도 다시 묻지 않습니다.`
: '현재 비밀번호가 설정되어 있습니다. 유지하려면 비워 두고 저장하세요. 공유채팅방에 다시 접근할 때마다 4자리를 다시 입력합니다.'
: (editingRoomAccessPinPromptTtlMinutes ?? 0) > 0
? `비밀번호를 켜면 공유받은 사용자는 진입 전에 4자리를 입력해야 합니다. 한 번 잠금 해제하면 ${resolveAccessPinPromptTtlLabel(editingRoomAccessPinPromptTtlMinutes ?? 0)} 동안은 새로고침해도 다시 묻지 않습니다.`
: '비밀번호를 켜면 공유받은 사용자는 공유채팅방에 접근할 때마다 4자리를 입력해야 합니다.'
: '비밀번호를 끄면 기존 잠금이 있더라도 해제됩니다. 이 비밀번호는 공유받은 사용자가 필요할 때 직접 설정하는 용도입니다.'}
</Text>
</div>
),
},
]}
/>
</div>
</Drawer>
<Modal
open={isSearchOpen}
footer={null}
title={searchPanelMode === 'apps' ? '공유채팅방 Apps' : '공유채팅방 통합검색'}
className="chat-share-page__search-modal"
onCancel={() => setIsSearchOpen(false)}
>
<div className="chat-share-page__search-modal-body">
<Input
autoFocus
allowClear
size="large"
prefix={<SearchOutlined />}
placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'}
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.target.value)}
/>
<div className="chat-share-page__search-summary">
<Text type="secondary">
{searchKeyword.trim()
? `검색 결과 ${searchResults.length}`
: searchPanelMode === 'apps'
? '최근 자주 연 앱을 먼저 보여주며, 아이콘 중심으로 빠르게 실행할 수 있습니다.'
: '질문, 답변, 리소스, 활동 로그를 함께 찾습니다.'}
</Text>
</div>
{searchPanelMode === 'apps' ? (
<div className="chat-share-page__search-app-environment">
<Text type="secondary"> </Text>
<Select
size="middle"
value={selectedAppEnvironment}
options={SHARE_APP_ENVIRONMENT_OPTIONS.map((environment) => ({
label: environment.label,
value: environment.key,
}))}
onChange={(value) => setSelectedAppEnvironment(value)}
/>
</div>
) : null}
<div className={`chat-share-page__search-results${searchPanelMode === 'apps' ? ' chat-share-page__search-results--apps' : ''}`}>
{searchResults.length > 0 ? (
searchResults.map((result) => (
searchPanelMode === 'apps' ? (
<button
key={result.key}
type="button"
className={`chat-share-page__app-tile${
result.appEntry && !isPlayAppSupportedInEnvironment(result.appEntry, selectedAppEnvironment)
? ' chat-share-page__app-tile--disabled'
: ''
}`}
disabled={Boolean(result.appEntry) && !isPlayAppSupportedInEnvironment(result.appEntry, selectedAppEnvironment)}
onClick={() => handleSearchResultSelect(result)}
>
<span className="chat-share-page__app-tile-icon">{result.icon ?? <AppstoreOutlined />}</span>
<span className="chat-share-page__app-tile-title">{result.title}</span>
<span className="chat-share-page__app-tile-description">
{result.appEntry ? `지원 ${result.description}` : result.description}
</span>
<span className="chat-share-page__app-tile-meta">
<span className="chat-share-page__app-tile-meta-label">
{result.appEntry
? isPlayAppSupportedInEnvironment(result.appEntry, selectedAppEnvironment)
? `${selectedAppEnvironment} 실행`
: '환경 미지원'
: '관리 앱'}
</span>
{result.usageBadge ? (
<span className="chat-share-page__app-tile-usage">{result.usageBadge}</span>
) : null}
</span>
</button>
) : (
<div key={result.key} className="chat-share-page__search-result">
<button type="button" className="chat-share-page__search-result-main" onClick={() => handleSearchResultSelect(result)}>
<span className="chat-share-page__search-result-title">{result.title}</span>
<span className="chat-share-page__search-result-description">{result.description}</span>
</button>
{result.appEntry ? (
<div className="chat-share-page__search-result-action-group">
<Tag bordered={false} className="chat-share-page__search-result-tag">
{`지원 ${resolveSupportedEnvironmentSummary(result.appEntry)}`}
</Tag>
<Button
type="text"
size="small"
disabled={!isPlayAppSupportedInEnvironment(result.appEntry, selectedAppEnvironment)}
className="chat-share-page__search-result-action chat-share-page__search-result-action--environment"
onClick={() => openAllowedPlayAppEnvironment(result.appEntry, selectedAppEnvironment)}
>
{isPlayAppSupportedInEnvironment(result.appEntry, selectedAppEnvironment) ? '앱 실행' : '미지원'}
</Button>
</div>
) : result.resource ? (
<Button
type="text"
size="small"
className="chat-share-page__search-result-action"
icon={<AppstoreOutlined />}
onClick={() => { if (result.resource) openProgramTarget(result.resource); }}
>
</Button>
) : null}
</div>
)
))
) : (
<div className="chat-share-page__search-empty">
<Text type="secondary">
{searchKeyword.trim() ? '검색 결과가 없습니다.' : searchPanelMode === 'apps' ? '허용된 Apps가 없습니다.' : '검색어를 입력해 주세요.'}
</Text>
</div>
)}
</div>
</div>
</Modal>
<FullscreenPreviewModal
open={Boolean(programTarget) && !isProgramMinimized}
title={programTarget?.label ?? '공유 프로그램'}
meta={programTarget?.meta ?? '공유 토큰 실행'}
zIndex={SHARE_PROGRAM_MODAL_Z_INDEX}
maskClosable={false}
hideHeader={false}
onMinimize={programTarget ? handleMinimizeProgram : null}
onClose={closeProgramTarget}
className={`chat-share-page__program-modal${
''
}`}
contentClassName="chat-share-page__program-modal-content"
fillContent
>
{programTarget ? (
embeddedPlayAppContent ? (
<div className="chat-share-page__program-app-shell">
{embeddedPlayAppContent}
</div>
) : programTarget.appId && findReadyPlayAppEntryById(programTarget.appId) ? (
<iframe
title={programTarget.label}
src={programTarget.url}
className="app-chat-panel__preview-frame"
/>
) : programTarget.appId === 'text-memo-widget' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<Suspense
fallback={(
<div className="chat-share-page__program-app-loading" role="status" aria-live="polite">
<Spin size="large" />
</div>
)}
>
<LazyTextMemoWidget cardWrapper={false} headerless skin="flat" title="공유 메모" />
</Suspense>
</div>
) : programTarget.appId === 'token-setting' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<TokenSettingManagementPage
sharedPreviewTokenSetting={shareTokenSetting}
sharedAccess={
canManageSharedTokenSetting
? {
shareToken: normalizedToken,
canManage: true,
}
: null
}
/>
</div>
) : programTarget.appId === 'shared-resource' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<SharedResourceManagementPage
disableInstallMetadata
sharedPreview={{
managedResourceTokenId: snapshot?.share.managedResourceTokenId ?? null,
sharePath: snapshot?.share.sharePath ?? null,
expiresAt: snapshot?.share.expiresAt ?? null,
tokenSetting: shareTokenSetting,
}}
sharedAccess={
sharePermissionSet.has('manage') && shareAllowedAppIdSet.has('shared-resource')
? {
shareToken: normalizedToken,
managedResourceTokenId: snapshot?.share.managedResourceTokenId ?? null,
}
: null
}
/>
</div>
) : programTarget.appId === 'app-settings' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<SharedAppSettingsPage shareToken={normalizedToken} />
</div>
) : programTarget.appId === 'server-command' ? (
<div className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface">
<ServerCommandPage sharedAccess={sharedServerCommandAccess} />
</div>
) : (
<ChatPreviewBody
target={{
label: programTarget.label,
url: programTarget.url,
kind: programTarget.kind,
}}
previewText=""
isPreviewLoading={false}
previewError=""
renderHtmlAsFrame
fullscreen
/>
)
) : null}
</FullscreenPreviewModal>
{typeof document !== 'undefined' && minimizedProgramCard
? createPortal(minimizedProgramCard, document.body)
: minimizedProgramCard}
</>
);
}