10993 lines
411 KiB
TypeScript
10993 lines
411 KiB
TypeScript
import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons';
|
|
import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd';
|
|
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
|
import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useParams } from 'react-router-dom';
|
|
import { FullscreenPreviewModal } from '../../../components/previewer';
|
|
import { BaseballTicketBayPlayAppView } from '../../../views/play/apps/baseball-ticket-bay/BaseballTicketBayPlayAppView';
|
|
import { EReaderAppView } from '../../../views/play/apps/e-reader/EReaderAppView';
|
|
import {
|
|
findReadyPlayAppEntryById,
|
|
getReadyPlayAppEntries,
|
|
getSupportedPlayAppEnvironments,
|
|
isPlayAppSupportedInEnvironment,
|
|
type PlayAppEntry,
|
|
type PlayAppEnvironment,
|
|
} from '../../../views/play/apps/apps/appsRegistry';
|
|
import { PhotoPuzzleAppView } from '../../../views/play/apps/photo-puzzle/PhotoPuzzleAppView';
|
|
import { PhotoPrismAppView } from '../../../views/play/apps/photoprism/PhotoPrismAppView';
|
|
import { TetrisAppView } from '../../../views/play/apps/tetris/TetrisAppView';
|
|
import { TheQuestAppView } from '../../../views/play/apps/the-quest/TheQuestAppView';
|
|
import { SharedResourceManagementPage } from '../SharedResourceManagementPage';
|
|
import { SharedAppSettingsPage } from '../SharedAppSettingsPage';
|
|
import { TokenSettingManagementPage } from '../TokenSettingManagementPage';
|
|
import { ServerCommandPage } from '../../../features/serverCommand';
|
|
import { fetchServerCommands } from '../../../features/serverCommand/api';
|
|
import type { ServerCommandItem } from '../../../features/serverCommand/types';
|
|
import { saveAppConfigToServer, setStoredAppConfig, useAppConfig } from '../appConfig';
|
|
import { useChatTypeRegistry } from '../chatTypeAccess';
|
|
import {
|
|
resolveChatRoomContextSettings,
|
|
resolveChatTypeDefaultContextIds,
|
|
upsertChatRoomContextSettings,
|
|
useChatContextSettingsRegistry,
|
|
} from '../chatContextSettingsAccess';
|
|
import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from '../mainChatPanel/ChatPromptCard';
|
|
import { ChatPreviewBody, type ChatPreviewTarget } from '../mainChatPanel/ChatPreviewBody';
|
|
import {
|
|
cancelChatShareRuntimeRequest,
|
|
cancelChatShareRequest,
|
|
ChatApiError,
|
|
clearChatShareConversationRoom,
|
|
completeChatShareManualBadge,
|
|
createChatShareRoom,
|
|
fetchChatConversationDetail,
|
|
fetchChatConversations,
|
|
deleteChatShareRoom,
|
|
fetchChatShareRuntimeSnapshot,
|
|
fetchChatShareSnapshot,
|
|
getStoredChatShareAccessPin,
|
|
resolveChatWebSocketUrl,
|
|
retryChatShareRequest,
|
|
saveChatShareRoomSettings,
|
|
setStoredChatShareAccessPin,
|
|
submitChatShareOriginReply,
|
|
submitChatShareMessage,
|
|
submitChatSharePrompt,
|
|
uploadChatShareComposerFile,
|
|
type ChatShareRoomSummary,
|
|
type ChatShareSnapshot,
|
|
} from '../mainChatPanel/chatUtils';
|
|
import { extractAttachmentPreviewUrls, extractChatMessageParts } from '../mainChatPanel/messageParts';
|
|
import { stripHiddenPreviewTags } from '../mainChatPanel/previewMarkers';
|
|
import { extractPreviewItems, type PreviewItem } from '../mainChatPanel/previewItems';
|
|
import { buildChatPath, buildPlayAppPath } from '../routes';
|
|
import type { PreviewKind } from '../mainChatPanel/previewKind';
|
|
import { normalizeChatResourceUrl } from '../mainChatPanel/chatResourceUrl';
|
|
import { forceReloadApp } from '../appUpdate';
|
|
import type {
|
|
ChatComposerAttachment,
|
|
ChatConversationSummary,
|
|
ChatConversationRequest,
|
|
ChatMessage,
|
|
ChatMessagePart,
|
|
ChatShareRoomLinkContext,
|
|
ChatRuntimeJobItem,
|
|
ChatRuntimeSnapshot,
|
|
ChatRuntimeTerminalStatus,
|
|
ChatServerEvent,
|
|
} from '../mainChatPanel/types';
|
|
import { isPromptResolved } from '../mainChatPanel/promptState';
|
|
import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi';
|
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars';
|
|
import { isPreviewRuntime } from '../previewRuntime';
|
|
import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest';
|
|
import { getSavedNotificationDeviceId } from '../notificationIdentity';
|
|
import { ensureWebPushSubscriptionRegistered, syncExistingWebPushSubscriptionRegistration } from '../webPushRegistration';
|
|
import '../mainChatPanel/styles/MainChatPanel.conversation.css';
|
|
import '../mainChatPanel/styles/MainChatPanel.preview-runtime.css';
|
|
import './ChatSharePage.css';
|
|
|
|
const { Paragraph, Text, Title } = Typography;
|
|
|
|
const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources/';
|
|
const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/';
|
|
const CHAT_PUBLIC_DOT_CODEX_PREFIX = '/public/.codex_chat/';
|
|
const RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX = '/api/resource-manager/preview/';
|
|
const RESOURCE_MANAGER_ROOT_PREFIX = 'resource/';
|
|
const SHARE_COMPOSER_VIEWPORT_COMPACT_THRESHOLD_PX = 24;
|
|
const SHARE_ACCESS_PIN_MAX_LENGTH = 4;
|
|
const SHARE_PROCESSING_CLOCK_INTERVAL_MS = 60 * 1000;
|
|
const SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS = 1000;
|
|
const SHARE_EXPIRY_CLOCK_INTERVAL_MS = 60 * 1000;
|
|
const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token';
|
|
const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token';
|
|
const SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY = 'codex-live-share-room-snapshot-index:v1';
|
|
const SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1';
|
|
const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6;
|
|
const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000;
|
|
const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [
|
|
{ value: 'always', label: '매번 묻기', minutes: 0 },
|
|
{ value: '5', label: '5분 유지', minutes: 5 },
|
|
{ value: '30', label: '30분 유지', minutes: 30 },
|
|
{ value: '60', label: '1시간 유지', minutes: 60 },
|
|
{ value: '180', label: '3시간 유지', minutes: 180 },
|
|
{ value: '1440', label: '24시간 유지', minutes: 1440 },
|
|
] as const;
|
|
|
|
type SharePreviewFetchError = Error & { status?: number };
|
|
|
|
type ShareRequestCardMode = 'question-only' | 'answer-only' | 'full';
|
|
|
|
type ShareRenderedMessage = {
|
|
visibleText: string;
|
|
promptParts: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
|
diffBlocks: string[];
|
|
};
|
|
|
|
type ShareMessageRenderPayload = ShareRenderedMessage & {
|
|
previewItems: PreviewItem[];
|
|
};
|
|
|
|
type ShareExpandMode = 'latest' | 'pending' | 'all';
|
|
type PendingSharePromptSelection = PromptDraftSelection & {
|
|
status: 'draft' | 'submitted';
|
|
};
|
|
type ShareProgramRestoreSnapshot = {
|
|
roomSessionId: string;
|
|
latestRequestId: string;
|
|
expandMode: ShareExpandMode;
|
|
scrollTop: number;
|
|
};
|
|
type ShareProgramTarget = {
|
|
key: string;
|
|
label: string;
|
|
url: string;
|
|
kind: PreviewKind;
|
|
meta?: string;
|
|
appId?: string;
|
|
restoreSnapshot?: ShareProgramRestoreSnapshot | null;
|
|
};
|
|
type ShareMinimizedProgramItem = {
|
|
target: ShareProgramTarget;
|
|
position: {
|
|
x: number;
|
|
y: number;
|
|
};
|
|
};
|
|
type ShareAppEnvironment = PlayAppEnvironment;
|
|
type ShareSearchResult = {
|
|
key: string;
|
|
title: string;
|
|
description: string;
|
|
category: 'request' | 'response' | 'resource' | 'activity';
|
|
icon?: ReactNode;
|
|
usageBadge?: string | null;
|
|
requestId?: string;
|
|
resource?: ShareProgramTarget | null;
|
|
appEntry?: PlayAppEntry | null;
|
|
scrollTarget?: {
|
|
type: 'request' | 'activity' | 'response' | 'prompt';
|
|
value: string;
|
|
};
|
|
};
|
|
type ShareSearchPanelMode = 'all' | 'apps';
|
|
type ShareWorkServerVersionStatus = 'latest' | 'unknown' | 'update-available' | 'build-required';
|
|
type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied';
|
|
type ShareNotificationStatusTone = 'success' | 'warning' | 'default';
|
|
type ShareNotificationClientStatus = {
|
|
roomLabel: string;
|
|
appLabel: string;
|
|
permissionLabel: string;
|
|
registrationLabel: string;
|
|
summaryLabel: string;
|
|
tone: ShareNotificationStatusTone;
|
|
};
|
|
type ShareProcessInspectorMode = 'default' | 'fullscreen' | 'minimized';
|
|
type ShareProcessInspectorExpandedSection = 'summary' | 'narratives' | null;
|
|
type ShareProcessChecklistStep = {
|
|
key: string;
|
|
label: string;
|
|
status: 'pending' | 'in_progress' | 'completed';
|
|
note: string;
|
|
};
|
|
type ShareProcessInspectorPayload = {
|
|
requestId: string;
|
|
statusTag: { color: string; label: string };
|
|
summary: string;
|
|
elapsedLabel: string;
|
|
startedAtLabel: string;
|
|
updatedAtLabel: string;
|
|
activityLines: string[];
|
|
latestActivityLine: string;
|
|
checklist: ShareProcessChecklistStep[];
|
|
narratives: string[];
|
|
};
|
|
type ShareRoomPendingCounts = {
|
|
processingCount: number;
|
|
unansweredCount: number;
|
|
};
|
|
|
|
type ShareRoomSourceGroup = {
|
|
key: string;
|
|
title: string;
|
|
requestPreview: string;
|
|
chatTypeLabel: string;
|
|
sourceSessionId: string | null;
|
|
sourceRequestId: string | null;
|
|
linkContext: ChatShareRoomLinkContext | null;
|
|
rooms: ChatShareRoomSummary[];
|
|
};
|
|
|
|
const LazyTextMemoWidget = lazy(async () => {
|
|
const module = await import('../../../widgets/text-memo-widget');
|
|
return { default: module.TextMemoWidget };
|
|
});
|
|
|
|
function normalizeAccessPinInput(value: string) {
|
|
return value.replace(/\D+/gu, '').slice(0, SHARE_ACCESS_PIN_MAX_LENGTH);
|
|
}
|
|
|
|
function resolveShareConversationRequestSortKey(request: ChatConversationRequest) {
|
|
const userMessageId = typeof request.userMessageId === 'number' && Number.isFinite(request.userMessageId)
|
|
? request.userMessageId
|
|
: null;
|
|
|
|
if (userMessageId != null) {
|
|
return userMessageId;
|
|
}
|
|
|
|
const responseMessageId = typeof request.responseMessageId === 'number' && Number.isFinite(request.responseMessageId)
|
|
? request.responseMessageId
|
|
: null;
|
|
|
|
if (responseMessageId != null) {
|
|
return responseMessageId;
|
|
}
|
|
|
|
const createdAt = Date.parse(request.createdAt?.trim() ?? '');
|
|
return Number.isFinite(createdAt) ? createdAt : null;
|
|
}
|
|
|
|
function compareShareConversationRequests(left: ChatConversationRequest, right: ChatConversationRequest) {
|
|
const leftSortKey = resolveShareConversationRequestSortKey(left);
|
|
const rightSortKey = resolveShareConversationRequestSortKey(right);
|
|
|
|
if (leftSortKey != null && rightSortKey != null && leftSortKey !== rightSortKey) {
|
|
return leftSortKey - rightSortKey;
|
|
}
|
|
|
|
const leftCreatedAt = Date.parse(left.createdAt?.trim() ?? '');
|
|
const rightCreatedAt = Date.parse(right.createdAt?.trim() ?? '');
|
|
if (Number.isFinite(leftCreatedAt) && Number.isFinite(rightCreatedAt) && leftCreatedAt !== rightCreatedAt) {
|
|
return leftCreatedAt - rightCreatedAt;
|
|
}
|
|
|
|
return left.requestId.localeCompare(right.requestId, 'ko');
|
|
}
|
|
|
|
function isSnapshotDeferrableFocusTarget(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
const tagName = target.tagName.toLowerCase();
|
|
if (tagName === 'textarea') {
|
|
return true;
|
|
}
|
|
|
|
if (tagName === 'input') {
|
|
const inputType = target.getAttribute('type')?.toLowerCase() ?? 'text';
|
|
return !['button', 'checkbox', 'file', 'hidden', 'radio', 'range', 'reset', 'submit'].includes(inputType);
|
|
}
|
|
|
|
return target.isContentEditable;
|
|
}
|
|
|
|
function resolveAccessPinPromptTtlMinutes(value?: number | null) {
|
|
if (value == null) {
|
|
return 0;
|
|
}
|
|
|
|
return Number.isFinite(value) ? Math.max(0, Number(value)) : 0;
|
|
}
|
|
|
|
function resolveAccessPinPromptTtlOptionValue(value?: number | null) {
|
|
const normalizedValue = resolveAccessPinPromptTtlMinutes(value);
|
|
return SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.minutes === normalizedValue)?.value ?? 'always';
|
|
}
|
|
|
|
function resolveAccessPinPromptTtlLabel(value?: number | null) {
|
|
const option = SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.minutes === resolveAccessPinPromptTtlMinutes(value));
|
|
return option?.label ?? '매번 묻기';
|
|
}
|
|
|
|
function parseAccessPinPromptTtlOptionValue(value: string) {
|
|
const matchedOption = SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS.find((item) => item.value === value);
|
|
return matchedOption?.minutes ?? null;
|
|
}
|
|
|
|
function canDeleteShareRoom(room: ChatShareRoomSummary, rooms: ChatShareRoomSummary[]) {
|
|
if (room.isDefault) {
|
|
return false;
|
|
}
|
|
|
|
return rooms.length > 1;
|
|
}
|
|
|
|
function readStoredShareImmediateSendPinnedByToken() {
|
|
if (typeof window === 'undefined') {
|
|
return {} as Record<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 readStoredShareLastRoomMapFromStorage(storage: Storage | null | undefined) {
|
|
if (!storage) {
|
|
return {} as Record<string, string>;
|
|
}
|
|
|
|
try {
|
|
const raw = storage.getItem(SHARE_LAST_ROOM_STORAGE_KEY);
|
|
|
|
if (!raw) {
|
|
return {} as Record<string, string>;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
|
|
return Object.entries(parsed).reduce<Record<string, string>>((result, [token, sessionId]) => {
|
|
const normalizedToken = String(token ?? '').trim();
|
|
const normalizedSessionId = String(sessionId ?? '').trim();
|
|
|
|
if (normalizedToken && normalizedSessionId) {
|
|
result[normalizedToken] = normalizedSessionId;
|
|
}
|
|
|
|
return result;
|
|
}, {});
|
|
} catch {
|
|
return {} as Record<string, string>;
|
|
}
|
|
}
|
|
|
|
function readStoredShareLastRoomByToken() {
|
|
if (typeof window === 'undefined') {
|
|
return {} as Record<string, string>;
|
|
}
|
|
|
|
return {
|
|
...readStoredShareLastRoomMapFromStorage(window.sessionStorage),
|
|
...readStoredShareLastRoomMapFromStorage(window.localStorage),
|
|
};
|
|
}
|
|
|
|
function readStoredShareLastRoomSessionId(token: string) {
|
|
const normalizedToken = token.trim();
|
|
|
|
if (!normalizedToken) {
|
|
return '';
|
|
}
|
|
|
|
return readStoredShareLastRoomByToken()[normalizedToken] ?? '';
|
|
}
|
|
|
|
function writeStoredShareLastRoomSessionId(token: string, sessionId: string | null) {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const normalizedToken = token.trim();
|
|
|
|
if (!normalizedToken) {
|
|
return;
|
|
}
|
|
|
|
const normalizedSessionId = String(sessionId ?? '').trim();
|
|
const nextMap = readStoredShareLastRoomByToken();
|
|
|
|
if (normalizedSessionId) {
|
|
nextMap[normalizedToken] = normalizedSessionId;
|
|
} else {
|
|
delete nextMap[normalizedToken];
|
|
}
|
|
|
|
const serialized = JSON.stringify(nextMap);
|
|
window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized);
|
|
window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized);
|
|
}
|
|
|
|
function buildShareRoomSnapshotSessionStorageKey(token: string, sessionId: string) {
|
|
return `${SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX}:${token}:${sessionId}`;
|
|
}
|
|
|
|
function canUseShareRoomSnapshotSessionStorage() {
|
|
return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
|
|
}
|
|
|
|
function readStoredShareRoomSnapshotSessionIndex() {
|
|
if (!canUseShareRoomSnapshotSessionStorage()) {
|
|
return {} as Record<string, Array<{ sessionId: string; savedAt: number }>>;
|
|
}
|
|
|
|
try {
|
|
const raw = window.sessionStorage.getItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY);
|
|
|
|
if (!raw) {
|
|
return {} as Record<string, Array<{ sessionId: string; savedAt: number }>>;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
return Object.entries(parsed).reduce<Record<string, Array<{ sessionId: string; savedAt: number }>>>((result, [token, entries]) => {
|
|
const normalizedToken = String(token ?? '').trim();
|
|
|
|
if (!normalizedToken || !Array.isArray(entries)) {
|
|
return result;
|
|
}
|
|
|
|
result[normalizedToken] = entries.flatMap((entry) => {
|
|
if (!entry || typeof entry !== 'object') {
|
|
return [];
|
|
}
|
|
|
|
const sessionId = String((entry as { sessionId?: unknown }).sessionId ?? '').trim();
|
|
const savedAt = Number((entry as { savedAt?: unknown }).savedAt);
|
|
|
|
if (!sessionId || !Number.isFinite(savedAt) || savedAt <= 0) {
|
|
return [];
|
|
}
|
|
|
|
return [{ sessionId, savedAt }];
|
|
});
|
|
return result;
|
|
}, {});
|
|
} catch {
|
|
return {} as Record<string, Array<{ sessionId: string; savedAt: number }>>;
|
|
}
|
|
}
|
|
|
|
function writeStoredShareRoomSnapshotSessionIndex(index: Record<string, Array<{ sessionId: string; savedAt: number }>>) {
|
|
if (!canUseShareRoomSnapshotSessionStorage()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const normalizedIndex = Object.entries(index).reduce<Record<string, Array<{ sessionId: string; savedAt: number }>>>((result, [token, entries]) => {
|
|
const normalizedToken = String(token ?? '').trim();
|
|
|
|
if (!normalizedToken || !Array.isArray(entries) || entries.length === 0) {
|
|
return result;
|
|
}
|
|
|
|
const normalizedEntries = entries
|
|
.map((entry) => ({
|
|
sessionId: String(entry.sessionId ?? '').trim(),
|
|
savedAt: Number(entry.savedAt),
|
|
}))
|
|
.filter((entry) => entry.sessionId && Number.isFinite(entry.savedAt) && entry.savedAt > 0);
|
|
|
|
if (normalizedEntries.length > 0) {
|
|
result[normalizedToken] = normalizedEntries;
|
|
}
|
|
|
|
return result;
|
|
}, {});
|
|
|
|
if (Object.keys(normalizedIndex).length === 0) {
|
|
window.sessionStorage.removeItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY);
|
|
return;
|
|
}
|
|
|
|
window.sessionStorage.setItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY, JSON.stringify(normalizedIndex));
|
|
} catch {
|
|
// Ignore sessionStorage failures and keep runtime fallback active.
|
|
}
|
|
}
|
|
|
|
function resolveShareSnapshotCacheSessionId(snapshot: ChatShareSnapshot | null | undefined) {
|
|
return snapshot?.activeSessionId?.trim() || snapshot?.conversation.sessionId?.trim() || snapshot?.share.sessionId?.trim() || '';
|
|
}
|
|
|
|
function doesShareSnapshotMatchRequestedRoom(
|
|
snapshot: ChatShareSnapshot | null | undefined,
|
|
requestedSessionId: string | null | undefined,
|
|
) {
|
|
const normalizedRequestedSessionId = requestedSessionId?.trim() || '';
|
|
|
|
if (!normalizedRequestedSessionId) {
|
|
return true;
|
|
}
|
|
|
|
return resolveShareSnapshotCacheSessionId(snapshot) === normalizedRequestedSessionId;
|
|
}
|
|
|
|
function readStoredShareRoomSnapshot(token: string, sessionId: string) {
|
|
const normalizedToken = token.trim();
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const raw = window.sessionStorage.getItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId));
|
|
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = JSON.parse(raw) as { snapshot?: ChatShareSnapshot | null };
|
|
const snapshot = parsed?.snapshot ?? null;
|
|
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
|
|
if (snapshot.share.hasAccessPin && !getStoredChatShareAccessPin(normalizedToken)) {
|
|
return null;
|
|
}
|
|
|
|
const cachedSessionId = resolveShareSnapshotCacheSessionId(snapshot);
|
|
return cachedSessionId === normalizedSessionId ? snapshot : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function removeStoredShareRoomSnapshot(token: string, sessionId: string) {
|
|
const normalizedToken = token.trim();
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId));
|
|
const nextIndex = readStoredShareRoomSnapshotSessionIndex();
|
|
const currentEntries = nextIndex[normalizedToken] ?? [];
|
|
const remainingEntries = currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId);
|
|
|
|
if (remainingEntries.length > 0) {
|
|
nextIndex[normalizedToken] = remainingEntries;
|
|
} else {
|
|
delete nextIndex[normalizedToken];
|
|
}
|
|
|
|
writeStoredShareRoomSnapshotSessionIndex(nextIndex);
|
|
} catch {
|
|
// Ignore sessionStorage failures and keep runtime fallback active.
|
|
}
|
|
}
|
|
|
|
function clearStoredShareRoomSnapshotCache(token: string) {
|
|
const normalizedToken = token.trim();
|
|
|
|
if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = readStoredShareRoomSnapshotSessionIndex();
|
|
const currentEntries = nextIndex[normalizedToken] ?? [];
|
|
currentEntries.forEach((entry) => {
|
|
window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId));
|
|
});
|
|
delete nextIndex[normalizedToken];
|
|
writeStoredShareRoomSnapshotSessionIndex(nextIndex);
|
|
}
|
|
|
|
function writeStoredShareRoomSnapshot(token: string, snapshot: ChatShareSnapshot | null | undefined) {
|
|
const normalizedToken = token.trim();
|
|
const normalizedSessionId = resolveShareSnapshotCacheSessionId(snapshot);
|
|
|
|
if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId || !snapshot) {
|
|
return;
|
|
}
|
|
|
|
const nextSavedAt = Date.now();
|
|
|
|
try {
|
|
window.sessionStorage.setItem(
|
|
buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId),
|
|
JSON.stringify({
|
|
savedAt: nextSavedAt,
|
|
snapshot,
|
|
}),
|
|
);
|
|
const nextIndex = readStoredShareRoomSnapshotSessionIndex();
|
|
const currentEntries = nextIndex[normalizedToken] ?? [];
|
|
const dedupedEntries = [
|
|
{ sessionId: normalizedSessionId, savedAt: nextSavedAt },
|
|
...currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId),
|
|
]
|
|
.sort((left, right) => right.savedAt - left.savedAt)
|
|
.slice(0, SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT);
|
|
|
|
nextIndex[normalizedToken] = dedupedEntries;
|
|
writeStoredShareRoomSnapshotSessionIndex(nextIndex);
|
|
|
|
currentEntries
|
|
.filter((entry) => !dedupedEntries.some((keptEntry) => keptEntry.sessionId === entry.sessionId))
|
|
.forEach((entry) => {
|
|
window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId));
|
|
});
|
|
} catch {
|
|
// Ignore sessionStorage quota failures and keep network refresh behavior.
|
|
}
|
|
}
|
|
|
|
function readShareRoomSessionIdFromLocation() {
|
|
if (typeof window === 'undefined') {
|
|
return '';
|
|
}
|
|
|
|
return new URLSearchParams(window.location.search).get('roomSessionId')?.trim() || '';
|
|
}
|
|
|
|
function writeShareRoomSessionIdToLocation(roomSessionId: string | null, mode: 'push' | 'replace' = 'replace') {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const normalizedSessionId = String(roomSessionId ?? '').trim();
|
|
const nextUrl = new URL(window.location.href);
|
|
const currentSessionId = nextUrl.searchParams.get('roomSessionId')?.trim() || '';
|
|
|
|
if (normalizedSessionId) {
|
|
if (currentSessionId === normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
nextUrl.searchParams.set('roomSessionId', normalizedSessionId);
|
|
} else {
|
|
if (!currentSessionId) {
|
|
return;
|
|
}
|
|
|
|
nextUrl.searchParams.delete('roomSessionId');
|
|
}
|
|
|
|
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
|
|
|
if (mode === 'push') {
|
|
window.history.pushState(window.history.state, '', nextPath);
|
|
return;
|
|
}
|
|
|
|
window.history.replaceState(window.history.state, '', nextPath);
|
|
}
|
|
|
|
function getClientNotificationPermission(): ClientNotificationPermissionState {
|
|
if (
|
|
typeof window === 'undefined'
|
|
|| typeof Notification === 'undefined'
|
|
|| typeof navigator === 'undefined'
|
|
|| !('serviceWorker' in navigator)
|
|
|| !('PushManager' in window)
|
|
) {
|
|
return 'unsupported';
|
|
}
|
|
|
|
if (Notification.permission === 'granted') {
|
|
return 'granted';
|
|
}
|
|
|
|
if (Notification.permission === 'denied') {
|
|
return 'denied';
|
|
}
|
|
|
|
return 'default';
|
|
}
|
|
|
|
function hasSecureOrigin() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return window.isSecureContext || window.location.hostname === 'localhost';
|
|
}
|
|
|
|
function isStandaloneDisplayMode() {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
|
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
|
);
|
|
}
|
|
|
|
function isAppleMobileDevice() {
|
|
if (typeof navigator === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
|
}
|
|
|
|
async function getPushServiceWorkerRegistration() {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
return null;
|
|
}
|
|
|
|
const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => {
|
|
if (!registration) {
|
|
return null;
|
|
}
|
|
|
|
if (registration.active || registration.waiting) {
|
|
return registration;
|
|
}
|
|
|
|
const installingWorker = registration.installing;
|
|
|
|
if (!installingWorker) {
|
|
return registration;
|
|
}
|
|
|
|
await new Promise<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 SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING = 12;
|
|
const SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH = 520;
|
|
const SHARE_PROCESS_INSPECTOR_DEFAULT_HEIGHT = 540;
|
|
const SHARE_PROCESS_INSPECTOR_FULLSCREEN_WIDTH = 1120;
|
|
const SHARE_PROCESS_INSPECTOR_FULLSCREEN_HEIGHT = 820;
|
|
const SHARE_PROCESS_INSPECTOR_MINIMIZED_WIDTH = 250;
|
|
const SHARE_PROCESS_INSPECTOR_MINIMIZED_HEIGHT = 72;
|
|
const SHARE_PROCESS_INSPECTOR_Z_INDEX = SHARE_PROGRAM_MINIMIZED_Z_INDEX + 5;
|
|
const MOBILE_INPUT_VIEWPORT_TOP_PADDING_PX = 6;
|
|
const MOBILE_INPUT_VIEWPORT_BOTTOM_PADDING_PX = 8;
|
|
const MOBILE_INPUT_VIEWPORT_SYNC_RETRY_DELAYS_MS = [180, 360] as const;
|
|
const renderAccessPinVisibilityIcon = (visible: boolean) => (visible ? <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';
|
|
const SHARE_CURRENT_CHAT_APP_ID = 'shared-chat-current';
|
|
|
|
type TokenUsagePeriodKey = (typeof TOKEN_USAGE_PERIODS)[number]['key'];
|
|
|
|
type TokenUsageWindowSummary = {
|
|
totalUsedTokens: number;
|
|
inputTokens: number;
|
|
outputTokens: number;
|
|
cachedTokens: number;
|
|
reasoningTokens: number;
|
|
requestCount: number;
|
|
percentage: number | null;
|
|
remainingTokens: number | null;
|
|
windowStartedAt: string | null;
|
|
nextUsageDropAt: string | null;
|
|
fullResetAt: string | null;
|
|
nextResetTokens: number;
|
|
};
|
|
const APPS_LAUNCHER_SEARCH_TERMS = ['apps', 'app', '앱', '프로그램', '실행', '빠른 실행'];
|
|
const SHARE_MANAGEMENT_APP_OPTIONS = [
|
|
{
|
|
value: 'text-memo-widget',
|
|
label: '메모',
|
|
description: '공유채팅 안에서 메모 컴포넌트를 바로 실행',
|
|
icon: <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 buildShareChatEnvironmentUrl(
|
|
sharePath: string | null | undefined,
|
|
shareToken: string | null | undefined,
|
|
environment: ShareAppEnvironment,
|
|
) {
|
|
const origin = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.origin ?? SHARE_APP_ENVIRONMENT_OPTIONS[0].origin;
|
|
const normalizedSharePath = sharePath?.trim() ?? '';
|
|
const normalizedShareToken = shareToken?.trim() ?? '';
|
|
const pathname = normalizedSharePath || (normalizedShareToken ? `/chat/share/${encodeURIComponent(normalizedShareToken)}` : '/chat/live');
|
|
return new URL(pathname, origin).toString();
|
|
}
|
|
|
|
function buildSharePlayAppInstallPath(appId: string, shareToken?: string | null) {
|
|
const installPath = new URL(buildPlayAppPath(appId), 'https://preview.sm-home.cloud');
|
|
const normalizedShareToken = shareToken?.trim() ?? '';
|
|
|
|
if (appId === 'e-reader' && normalizedShareToken) {
|
|
installPath.searchParams.set('shareToken', normalizedShareToken);
|
|
}
|
|
|
|
return `${installPath.pathname}${installPath.search}${installPath.hash}`;
|
|
}
|
|
|
|
function resolveSharePlayAppInstallThemeColor(appId: string) {
|
|
switch (appId) {
|
|
case 'baseball-ticket-bay':
|
|
return '#1b3f91';
|
|
case 'photoprism':
|
|
return '#0f766e';
|
|
case 'photo-puzzle':
|
|
return '#d97706';
|
|
case 'the-quest':
|
|
return '#7c3aed';
|
|
case 'tetris':
|
|
return '#0f172a';
|
|
default:
|
|
return '#165dff';
|
|
}
|
|
}
|
|
|
|
function resolveShareAppEnvironmentFromOrigin(origin?: string | null): ShareAppEnvironment {
|
|
const normalizedOrigin = origin?.trim().toLowerCase();
|
|
|
|
if (!normalizedOrigin) {
|
|
return 'preview';
|
|
}
|
|
|
|
return SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.origin.toLowerCase() === normalizedOrigin)?.key ?? 'preview';
|
|
}
|
|
|
|
function buildPlayAppEnvironmentTarget(
|
|
appId: string,
|
|
label: string,
|
|
environment: ShareAppEnvironment,
|
|
): ShareProgramTarget {
|
|
const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment;
|
|
|
|
return {
|
|
key: `launcher:${environment}:${appId}`,
|
|
label,
|
|
url: buildPlayAppEnvironmentUrl(appId, environment),
|
|
kind: 'document',
|
|
meta: `허용된 Apps 실행 · ${environmentLabel}`,
|
|
appId,
|
|
};
|
|
}
|
|
|
|
function buildShareChatEnvironmentTarget(
|
|
sharePath: string | null | undefined,
|
|
shareToken: string | null | undefined,
|
|
environment: ShareAppEnvironment,
|
|
): ShareProgramTarget {
|
|
const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment;
|
|
|
|
return {
|
|
key: `launcher:${environment}:${SHARE_CURRENT_CHAT_APP_ID}`,
|
|
label: '채팅',
|
|
url: buildShareChatEnvironmentUrl(sharePath, shareToken, environment),
|
|
kind: 'document',
|
|
meta: `현재 공유토큰 열기 · ${environmentLabel}`,
|
|
appId: SHARE_CURRENT_CHAT_APP_ID,
|
|
};
|
|
}
|
|
|
|
function shouldRenderSharePlayAppInline(target: ShareProgramTarget | null | undefined) {
|
|
if (!target?.appId || !findReadyPlayAppEntryById(target.appId)) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
return new URL(target.url, window.location.origin).origin === window.location.origin;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function resolveSupportedEnvironmentSummary(entry: PlayAppEntry) {
|
|
return getSupportedPlayAppEnvironments(entry).join(', ');
|
|
}
|
|
|
|
function resolveFirstSupportedShareAppEnvironment(entries: PlayAppEntry[]) {
|
|
for (const option of SHARE_APP_ENVIRONMENT_OPTIONS) {
|
|
if (entries.some((entry) => isPlayAppSupportedInEnvironment(entry, option.key))) {
|
|
return option.key;
|
|
}
|
|
}
|
|
|
|
return 'preview';
|
|
}
|
|
|
|
function renderEmbeddedSharePlayApp(appId: string | undefined, onBack: () => void, shareToken?: string | null) {
|
|
if (appId === 'photoprism') {
|
|
return <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 createChatShareManifestHref(pathname: string, title?: string | null) {
|
|
const normalizedTitle = title?.trim();
|
|
return createInstallManifestObjectUrl({
|
|
startPath: pathname,
|
|
scope: pathname,
|
|
name: normalizedTitle || CHAT_SHARE_INSTALL_NAME,
|
|
shortName: CHAT_SHARE_INSTALL_SHORT_NAME,
|
|
description: '리소스 공유 채팅방을 홈 화면 앱으로 바로 엽니다.',
|
|
themeColor: CHAT_SHARE_INSTALL_THEME_COLOR,
|
|
backgroundColor: '#f4f7fb',
|
|
});
|
|
}
|
|
|
|
function swapManifestForChatShare(manifestHref: string, title?: string | null) {
|
|
return swapInstallDocumentMetadata({
|
|
manifestHref,
|
|
title: title?.trim() || CHAT_SHARE_INSTALL_NAME,
|
|
themeColor: CHAT_SHARE_INSTALL_THEME_COLOR,
|
|
});
|
|
}
|
|
|
|
function getShareExpandModeLabel(mode: ShareExpandMode) {
|
|
return SHARE_EXPAND_MODE_LABELS[mode];
|
|
}
|
|
|
|
function normalizeSearchKeyword(value: string) {
|
|
return value.trim().toLocaleLowerCase('ko-KR');
|
|
}
|
|
|
|
function isShareInteractivePointerTarget(target: EventTarget | null) {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(target.closest('button, a, input, textarea, select, [role="button"], [data-no-drag="true"]'));
|
|
}
|
|
|
|
function formatTokenCount(value: number) {
|
|
return Math.max(0, Math.round(Number(value) || 0)).toLocaleString('ko-KR');
|
|
}
|
|
|
|
function resolveSmallestFiniteNumber(...values: Array<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 resolveRequestMessageTimestamp(request: ChatConversationRequest) {
|
|
const candidates = [request.answeredAt, request.terminalAt, request.updatedAt, request.createdAt];
|
|
|
|
for (const candidate of candidates) {
|
|
const normalized = candidate?.trim();
|
|
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function resolveRequestUsageTokens(request: ChatConversationRequest) {
|
|
return Math.max(
|
|
0,
|
|
Math.round(
|
|
Number(
|
|
request.usageSnapshot?.tokenTotals?.total ??
|
|
request.usageSnapshot?.totalTokens ??
|
|
request.totalTokens ??
|
|
0,
|
|
) || 0,
|
|
),
|
|
);
|
|
}
|
|
|
|
function resolveTokenUsageWindowSummary(
|
|
requests: ChatConversationRequest[],
|
|
periodKey: TokenUsagePeriodKey,
|
|
nowMs: number,
|
|
limit: number,
|
|
): TokenUsageWindowSummary {
|
|
const period = TOKEN_USAGE_PERIODS.find((item) => item.key === periodKey) ?? TOKEN_USAGE_PERIODS[0];
|
|
const threshold = nowMs - period.windowMs;
|
|
const matchingRequests = requests.filter((request) => resolveRequestUsageAt(request) >= threshold);
|
|
const tokenAccumulator = matchingRequests.reduce(
|
|
(accumulator, request) => {
|
|
accumulator.totalUsedTokens += resolveRequestUsageTokens(request);
|
|
accumulator.inputTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.input ?? 0) || 0));
|
|
accumulator.outputTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.output ?? 0) || 0));
|
|
accumulator.cachedTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.cached ?? 0) || 0));
|
|
accumulator.reasoningTokens += Math.max(0, Math.round(Number(request.usageSnapshot?.tokenTotals?.reasoning ?? 0) || 0));
|
|
return accumulator;
|
|
},
|
|
{
|
|
totalUsedTokens: 0,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cachedTokens: 0,
|
|
reasoningTokens: 0,
|
|
},
|
|
);
|
|
const usageResetSchedule = matchingRequests
|
|
.map((request) => resolveRequestUsageAt(request) + period.windowMs)
|
|
.filter((value) => Number.isFinite(value))
|
|
.sort((left, right) => left - right);
|
|
const nextUsageDropAtMs = usageResetSchedule.length > 0 ? usageResetSchedule[0] : null;
|
|
const normalizedLimit = Math.max(0, Math.round(Number(limit) || 0));
|
|
const percentage = normalizedLimit > 0 ? Math.min(100, (tokenAccumulator.totalUsedTokens / normalizedLimit) * 100) : null;
|
|
const remainingTokens = normalizedLimit > 0 ? Math.max(0, normalizedLimit - tokenAccumulator.totalUsedTokens) : null;
|
|
const nextResetTokens =
|
|
nextUsageDropAtMs == null
|
|
? 0
|
|
: matchingRequests.reduce((accumulator, request) => {
|
|
if (resolveRequestUsageAt(request) + period.windowMs !== nextUsageDropAtMs) {
|
|
return accumulator;
|
|
}
|
|
|
|
return accumulator + resolveRequestUsageTokens(request);
|
|
}, 0);
|
|
|
|
return {
|
|
...tokenAccumulator,
|
|
requestCount: matchingRequests.length,
|
|
percentage,
|
|
remainingTokens,
|
|
windowStartedAt: new Date(threshold).toISOString(),
|
|
nextUsageDropAt: nextUsageDropAtMs != null ? new Date(nextUsageDropAtMs).toISOString() : null,
|
|
fullResetAt: usageResetSchedule.length > 0 ? new Date(usageResetSchedule[usageResetSchedule.length - 1]).toISOString() : null,
|
|
nextResetTokens,
|
|
};
|
|
}
|
|
|
|
function resolveTokenUsageLimitForPeriod(
|
|
setting:
|
|
| {
|
|
maxTokensPer30Days: number;
|
|
maxTokensPer7Days: number;
|
|
maxTokensPer5Hours: number;
|
|
}
|
|
| null
|
|
| undefined,
|
|
periodKey: TokenUsagePeriodKey,
|
|
) {
|
|
if (!setting) {
|
|
return 0;
|
|
}
|
|
|
|
if (periodKey === '7d') {
|
|
return setting.maxTokensPer7Days;
|
|
}
|
|
|
|
if (periodKey === '5h') {
|
|
return setting.maxTokensPer5Hours;
|
|
}
|
|
|
|
return setting.maxTokensPer7Days;
|
|
}
|
|
|
|
function clampProgramMinimizedValue(value: number, min: number, max: number) {
|
|
if (max < min) {
|
|
return min;
|
|
}
|
|
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function resetMobileDocumentScrollOffset() {
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const documentElement = document.documentElement;
|
|
const body = document.body;
|
|
const visualViewport = window.visualViewport;
|
|
const layoutScrollTop = Math.max(window.scrollY || 0, documentElement?.scrollTop || 0, body?.scrollTop || 0);
|
|
const visualOffsetTop = Math.max(visualViewport?.offsetTop ?? 0, visualViewport?.pageTop ?? 0);
|
|
|
|
if (layoutScrollTop <= 0 && visualOffsetTop <= 0) {
|
|
return;
|
|
}
|
|
|
|
window.scrollTo(0, 0);
|
|
|
|
if (documentElement) {
|
|
documentElement.scrollTop = 0;
|
|
}
|
|
|
|
if (body) {
|
|
body.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
function isMobileShareInputTarget(target: EventTarget | null): target is HTMLElement {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
const inputTarget = target.closest<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 getStackedProgramMinimizedPosition(index: number) {
|
|
const basePosition = getDefaultProgramMinimizedPosition();
|
|
const offsetX = 18 * Math.max(0, index);
|
|
const offsetY = 14 * Math.max(0, index);
|
|
|
|
return {
|
|
x: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.x - offsetX),
|
|
y: Math.max(PROGRAM_MINIMIZED_VIEWPORT_PADDING, basePosition.y - offsetY),
|
|
};
|
|
}
|
|
|
|
function getDefaultShareProcessInspectorPosition() {
|
|
if (typeof window === 'undefined') {
|
|
return { x: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, y: SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING };
|
|
}
|
|
|
|
const width = Math.min(SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH, window.innerWidth - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING * 2);
|
|
const centeredX = Math.round((window.innerWidth - width) / 2);
|
|
|
|
return {
|
|
x: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, centeredX),
|
|
y: Math.max(SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, 88),
|
|
};
|
|
}
|
|
|
|
function matchesSearchKeyword(keyword: string, ...values: Array<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 buildShareRequestMessagesById(messages: ChatMessage[]) {
|
|
const nextMap = new Map<string, ChatMessage[]>();
|
|
|
|
messages.forEach((message) => {
|
|
const requestId = message.clientRequestId?.trim() || '';
|
|
|
|
if (!requestId) {
|
|
return;
|
|
}
|
|
|
|
const current = nextMap.get(requestId) ?? [];
|
|
current.push(message);
|
|
nextMap.set(requestId, current);
|
|
});
|
|
|
|
return nextMap;
|
|
}
|
|
|
|
function resolveShareRoomPendingCounts(snapshot: Pick<ChatShareSnapshot, 'requests' | 'messages'>): ShareRoomPendingCounts {
|
|
const sortedRequests = [...snapshot.requests].sort(compareShareConversationRequests);
|
|
const sortedMessages = [...snapshot.messages].sort((left, right) => left.id - right.id);
|
|
const messageRenderPayloadById = new Map(
|
|
sortedMessages.map((message) => [message.id, extractShareMessageRenderPayload(message)] as const),
|
|
);
|
|
const requestMessagesById = buildShareRequestMessagesById(sortedMessages);
|
|
const childRequestCountByParentId = buildShareChildRequestCountMap(sortedRequests);
|
|
const promptFollowupCountByParentId = buildSharePromptFollowupCountMap(sortedRequests);
|
|
const pendingCompletionRequests = sortedRequests.filter((request) =>
|
|
isPendingCompletionShareRequest(
|
|
request,
|
|
requestMessagesById.get(request.requestId) ?? [],
|
|
childRequestCountByParentId,
|
|
promptFollowupCountByParentId,
|
|
messageRenderPayloadById,
|
|
));
|
|
const processingCount = pendingCompletionRequests.filter((request) => isRequestInFlight(request.status)).length;
|
|
|
|
return {
|
|
processingCount,
|
|
unansweredCount: Math.max(0, pendingCompletionRequests.length - processingCount),
|
|
};
|
|
}
|
|
|
|
function buildVisibleMessageText(message: ChatMessage, payload?: ShareMessageRenderPayload) {
|
|
return (payload ?? resolveRenderedMessage(message)).visibleText.trim();
|
|
}
|
|
|
|
const SHARE_ATTACHMENT_ACCEPT = 'image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed';
|
|
|
|
function isCollapsibleText(value: string) {
|
|
const normalized = value.trim();
|
|
|
|
if (!normalized) {
|
|
return false;
|
|
}
|
|
|
|
if (normalized.length > COLLAPSIBLE_TEXT_MAX_LENGTH) {
|
|
return true;
|
|
}
|
|
|
|
return normalized.split(/\r?\n/).length > COLLAPSIBLE_TEXT_MAX_LINES;
|
|
}
|
|
|
|
function isShareSendDelayError(error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
return /지연|timeout|연결/i.test(message);
|
|
}
|
|
|
|
function buildRequestAnswerText(
|
|
request: ChatConversationRequest,
|
|
relatedMessages: ChatMessage[],
|
|
messageRenderPayloadById?: Map<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 resolveShareRequestFallbackAnswerText(request: ChatConversationRequest) {
|
|
if (request.status === 'queued') {
|
|
return '요청 대기 등록 하였습니다.';
|
|
}
|
|
|
|
if (request.status === 'accepted' || request.status === 'started') {
|
|
return '요청 처리 중 입니다.';
|
|
}
|
|
|
|
return request.statusMessage?.trim() || '아직 답변이 없습니다.';
|
|
}
|
|
|
|
function replaceChatShareSnapshotRequest(snapshot: ChatShareSnapshot, nextRequest: ChatConversationRequest): ChatShareSnapshot {
|
|
return {
|
|
...snapshot,
|
|
targetRequest:
|
|
snapshot.targetRequest.requestId === nextRequest.requestId
|
|
? nextRequest
|
|
: snapshot.targetRequest,
|
|
requests: snapshot.requests.map((request) =>
|
|
request.requestId === nextRequest.requestId ? nextRequest : request,
|
|
),
|
|
};
|
|
}
|
|
|
|
function buildShareVisibleText(text: string) {
|
|
return stripHiddenPreviewTags(extractAttachmentPreviewUrls(text).strippedText).trim();
|
|
}
|
|
|
|
function resolvePromptParentQuestionText(request: ChatConversationRequest | null | undefined) {
|
|
return request?.userText?.replace(/\s+/g, ' ').trim() || '';
|
|
}
|
|
|
|
function resolveShareConversationParentRequest(
|
|
request: ChatConversationRequest | null | undefined,
|
|
requestById: ReadonlyMap<string, ChatConversationRequest>,
|
|
) {
|
|
const parentRequestId = request?.parentRequestId?.trim() || '';
|
|
return parentRequestId ? requestById.get(parentRequestId) ?? null : null;
|
|
}
|
|
|
|
function resolveShareRequestLineage(
|
|
request: ChatConversationRequest | null | undefined,
|
|
requestById: ReadonlyMap<string, ChatConversationRequest>,
|
|
) {
|
|
const directParentRequest = resolveShareConversationParentRequest(request, requestById);
|
|
|
|
if (!directParentRequest) {
|
|
return {
|
|
directParentRequest: null,
|
|
topParentRequest: null,
|
|
};
|
|
}
|
|
|
|
let currentRequest: ChatConversationRequest | null = directParentRequest;
|
|
let topParentRequest: ChatConversationRequest | null = directParentRequest;
|
|
const visitedRequestIds = new Set<string>();
|
|
|
|
while (currentRequest) {
|
|
const currentRequestId = currentRequest.requestId.trim();
|
|
|
|
if (!currentRequestId || visitedRequestIds.has(currentRequestId)) {
|
|
break;
|
|
}
|
|
|
|
visitedRequestIds.add(currentRequestId);
|
|
topParentRequest = currentRequest;
|
|
currentRequest = resolveShareConversationParentRequest(currentRequest, requestById);
|
|
}
|
|
|
|
return {
|
|
directParentRequest,
|
|
topParentRequest,
|
|
};
|
|
}
|
|
|
|
function buildLinkedRoomDraftTitle(source: ChatConversationSummary) {
|
|
const titleBase =
|
|
source.title?.trim()
|
|
|| source.requestBadgeLabel?.trim()
|
|
|| source.contextLabel?.trim()
|
|
|| '연결 작업';
|
|
return `${titleBase} 작업방`;
|
|
}
|
|
|
|
function buildLinkedRoomDraftSeedMessage(source: ChatConversationSummary) {
|
|
const preview =
|
|
source.lastRequestPreview?.trim()
|
|
|| source.lastMessagePreview?.trim()
|
|
|| source.lastResponsePreview?.trim()
|
|
|| '원 세션 내용을 참고해 이어서 처리해 주세요.';
|
|
return `참조 세션을 기준으로 작업을 이어갑니다.\n\n원 요청 요약: ${preview}`;
|
|
}
|
|
|
|
function buildShareRoomSourceGroups(
|
|
rooms: ChatShareRoomSummary[],
|
|
conversationBySessionId: ReadonlyMap<string, ChatConversationSummary>,
|
|
) {
|
|
const groupMap = new Map<string, ShareRoomSourceGroup>();
|
|
|
|
rooms.forEach((room) => {
|
|
const linkContext = room.linkContext?.kind === 'linked-session' ? room.linkContext : null;
|
|
const linkedConversation = linkContext ? conversationBySessionId.get(linkContext.sourceSessionId) ?? null : null;
|
|
const key = linkContext ? `linked:${linkContext.sourceSessionId}` : `room:${room.sessionId}`;
|
|
const current = groupMap.get(key);
|
|
const title =
|
|
linkContext?.sourceTitle?.trim()
|
|
|| linkedConversation?.title?.trim()
|
|
|| room.title.trim()
|
|
|| '공유 채팅방';
|
|
const requestPreview =
|
|
linkContext?.sourceRequestPreview?.trim()
|
|
|| linkedConversation?.lastRequestPreview?.trim()
|
|
|| linkedConversation?.lastMessagePreview?.trim()
|
|
|| '';
|
|
const chatTypeLabel =
|
|
linkContext?.sourceChatTypeLabel?.trim()
|
|
|| linkedConversation?.contextLabel?.trim()
|
|
|| room.contextLabel?.trim()
|
|
|| '';
|
|
|
|
if (current) {
|
|
current.rooms.push(room);
|
|
return;
|
|
}
|
|
|
|
groupMap.set(key, {
|
|
key,
|
|
title,
|
|
requestPreview,
|
|
chatTypeLabel,
|
|
sourceSessionId: linkContext?.sourceSessionId ?? null,
|
|
sourceRequestId: linkContext?.sourceRequestId ?? null,
|
|
linkContext,
|
|
rooms: [room],
|
|
});
|
|
});
|
|
|
|
return Array.from(groupMap.values()).map((group) => ({
|
|
...group,
|
|
rooms: [...group.rooms].sort((left, right) => {
|
|
if (left.isDefault !== right.isDefault) {
|
|
return left.isDefault ? -1 : 1;
|
|
}
|
|
|
|
if (left.sortOrder !== right.sortOrder) {
|
|
return left.sortOrder - right.sortOrder;
|
|
}
|
|
|
|
return left.title.localeCompare(right.title, 'ko');
|
|
}),
|
|
}));
|
|
}
|
|
|
|
function dedupeShareRooms(rooms: ChatShareRoomSummary[]) {
|
|
const dedupedRooms: ChatShareRoomSummary[] = [];
|
|
const knownSessionIds = new Set<string>();
|
|
|
|
rooms.forEach((room) => {
|
|
const normalizedSessionId = room.sessionId.trim();
|
|
|
|
if (!normalizedSessionId || knownSessionIds.has(normalizedSessionId)) {
|
|
return;
|
|
}
|
|
|
|
knownSessionIds.add(normalizedSessionId);
|
|
dedupedRooms.push(room);
|
|
});
|
|
|
|
return dedupedRooms;
|
|
}
|
|
|
|
function buildSharePreviewItemsFromText(text: string, shareToken: string) {
|
|
if (!shareToken) {
|
|
return [];
|
|
}
|
|
|
|
return extractPreviewItems([
|
|
{
|
|
id: -1,
|
|
author: 'user',
|
|
text,
|
|
timestamp: '',
|
|
},
|
|
]).map((item) => ({
|
|
...item,
|
|
url: resolveShareScopedResourceUrl(item.url, shareToken),
|
|
}));
|
|
}
|
|
|
|
function resolveShareScopedResourceUrl(url: string, shareToken: string) {
|
|
const normalizedShareToken = shareToken.trim();
|
|
const normalizedUrl = normalizeChatResourceUrl(String(url ?? '').trim());
|
|
const storedSharePin = getStoredChatShareAccessPin(normalizedShareToken);
|
|
|
|
if (!normalizedShareToken || !normalizedUrl || typeof window === 'undefined') {
|
|
return normalizedUrl;
|
|
}
|
|
|
|
try {
|
|
const resolvedUrl = new URL(normalizedUrl, window.location.origin);
|
|
let relativePath = '';
|
|
const searchParams = new URLSearchParams(resolvedUrl.search);
|
|
|
|
searchParams.delete('token');
|
|
searchParams.delete('clientId');
|
|
searchParams.delete('sharePin');
|
|
|
|
if (resolvedUrl.pathname.startsWith(CHAT_API_RESOURCE_ROUTE_PREFIX)) {
|
|
relativePath = resolvedUrl.pathname.slice(CHAT_API_RESOURCE_ROUTE_PREFIX.length);
|
|
} else if (resolvedUrl.pathname.startsWith(CHAT_PUBLIC_DOT_CODEX_PREFIX)) {
|
|
relativePath = resolvedUrl.pathname.slice(CHAT_PUBLIC_DOT_CODEX_PREFIX.length);
|
|
} else if (resolvedUrl.pathname.startsWith(CHAT_PUBLIC_ROUTE_PREFIX)) {
|
|
relativePath = resolvedUrl.pathname.slice(CHAT_PUBLIC_ROUTE_PREFIX.length);
|
|
} else if (resolvedUrl.pathname.startsWith(RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX)) {
|
|
const resourceRelativePath = decodeURIComponent(
|
|
resolvedUrl.pathname.slice(RESOURCE_MANAGER_PREVIEW_ROUTE_PREFIX.length),
|
|
).replace(/^\/+/, '');
|
|
|
|
if (!resourceRelativePath) {
|
|
return normalizedUrl;
|
|
}
|
|
|
|
relativePath = `${RESOURCE_MANAGER_ROOT_PREFIX}${resourceRelativePath}`;
|
|
}
|
|
|
|
if (!relativePath) {
|
|
return normalizedUrl;
|
|
}
|
|
|
|
const encodedPath = relativePath
|
|
.split('/')
|
|
.filter(Boolean)
|
|
.map((segment) => encodeURIComponent(decodeURIComponent(segment)))
|
|
.join('/');
|
|
|
|
if (storedSharePin) {
|
|
searchParams.set('sharePin', storedSharePin);
|
|
}
|
|
|
|
const nextSearch = searchParams.toString();
|
|
return `${window.location.origin}/api/chat/shares/${encodeURIComponent(normalizedShareToken)}/resources/${encodedPath}${nextSearch ? `?${nextSearch}` : ''}${resolvedUrl.hash || ''}`;
|
|
} catch {
|
|
return normalizedUrl;
|
|
}
|
|
}
|
|
function rewritePromptPartForShare(
|
|
prompt: Extract<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')}`;
|
|
}
|
|
|
|
function formatShareRuntimeTimestamp(value: string | null | undefined) {
|
|
const normalized = value?.trim();
|
|
|
|
if (!normalized) {
|
|
return '-';
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return '-';
|
|
}
|
|
|
|
return parsed.toLocaleString('ko-KR', {
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
function resolveShareProcessRequestStatusTag(request: ChatConversationRequest) {
|
|
switch (request.status) {
|
|
case 'accepted':
|
|
return { color: 'blue', label: '접수됨' } as const;
|
|
case 'queued':
|
|
return { color: 'default', label: '대기중' } as const;
|
|
case 'started':
|
|
return { color: 'processing', label: '처리중' } as const;
|
|
case 'completed':
|
|
return { color: 'green', label: '완료' } as const;
|
|
case 'failed':
|
|
return { color: 'red', label: '실패' } as const;
|
|
case 'cancelled':
|
|
return { color: 'gold', label: '취소됨' } as const;
|
|
case 'removed':
|
|
default:
|
|
return { color: 'default', label: '대기취소' } as const;
|
|
}
|
|
}
|
|
|
|
function resolveShareRuntimeTerminalTag(terminalStatus: ChatRuntimeTerminalStatus) {
|
|
switch (terminalStatus) {
|
|
case 'cancelled':
|
|
return { color: 'gold', label: '취소됨' } as const;
|
|
case 'removed':
|
|
return { color: 'default', label: '대기취소' } as const;
|
|
case 'failed':
|
|
return { color: 'red', label: '실패' } as const;
|
|
case 'completed':
|
|
default:
|
|
return { color: 'green', label: '완료' } as const;
|
|
}
|
|
}
|
|
|
|
function resolveShareRuntimeStatusTag(item: ChatRuntimeJobItem) {
|
|
if (item.status === 'running') {
|
|
return { color: 'processing', label: '실행중' } as const;
|
|
}
|
|
|
|
return { color: 'default', label: '대기중' } as const;
|
|
}
|
|
|
|
function matchesShareProcessKeywords(lines: string[], pattern: RegExp) {
|
|
return lines.some((line) => pattern.test(line.toLowerCase()));
|
|
}
|
|
|
|
function resolveShareProcessChecklistStepStatus(
|
|
request: ChatConversationRequest,
|
|
enabled: boolean,
|
|
completed: boolean,
|
|
): ShareProcessChecklistStep['status'] {
|
|
if (completed) {
|
|
return 'completed';
|
|
}
|
|
|
|
if (enabled || request.status === 'started') {
|
|
return 'in_progress';
|
|
}
|
|
|
|
return 'pending';
|
|
}
|
|
|
|
function buildShareProcessInspectorPayload(
|
|
request: ChatConversationRequest,
|
|
activityLines: string[],
|
|
nowMs: number,
|
|
runtimeItem?: ChatRuntimeJobItem | (ChatRuntimeJobItem & { terminalStatus?: ChatRuntimeTerminalStatus; lastUpdatedAt?: string }) | null,
|
|
): ShareProcessInspectorPayload {
|
|
const normalizedLines = activityLines.map((line) => line.trim()).filter(Boolean);
|
|
const startedAt = runtimeItem?.startedAt ?? runtimeItem?.enqueuedAt ?? request.updatedAt?.trim() ?? request.createdAt;
|
|
const updatedAt = 'lastUpdatedAt' in (runtimeItem ?? {}) ? runtimeItem?.lastUpdatedAt ?? request.updatedAt : request.updatedAt;
|
|
const latestActivityLine = normalizedLines[normalizedLines.length - 1] ?? '';
|
|
const hasAnalysis = matchesShareProcessKeywords(normalizedLines, /(read|search|inspect|context|analysis|analy|조사|확인|정리|검토|조회)/);
|
|
const hasImplementation = matchesShareProcessKeywords(normalizedLines, /(edit|patch|write|implement|fix|update|modify|반영|수정|작성)/);
|
|
const hasVerification = matchesShareProcessKeywords(normalizedLines, /(test|build|verify|check|validate|검증|점검|실행)/);
|
|
const isTerminal = ['completed', 'failed', 'cancelled', 'removed'].includes(request.status);
|
|
const answerSummary = summarizeShareReplyReferenceText(
|
|
request.responseText || request.statusMessage || latestActivityLine || '아직 기록된 응답이 없습니다.',
|
|
120,
|
|
);
|
|
const summary = summarizeShareReplyReferenceText(request.userText || runtimeItem?.summary || '요약 정보 없음', 140);
|
|
const analysisStatus = resolveShareProcessChecklistStepStatus(request, hasAnalysis, hasAnalysis && (hasImplementation || hasVerification || isTerminal));
|
|
const implementationStatus = resolveShareProcessChecklistStepStatus(
|
|
request,
|
|
hasImplementation,
|
|
(hasImplementation && (hasVerification || isTerminal)) || request.hasResponse,
|
|
);
|
|
const verificationStatus = resolveShareProcessChecklistStepStatus(
|
|
request,
|
|
hasVerification || request.hasResponse,
|
|
request.status === 'completed' || request.hasResponse,
|
|
);
|
|
const currentStepLabel =
|
|
verificationStatus === 'in_progress'
|
|
? '검증/정리'
|
|
: implementationStatus === 'in_progress'
|
|
? '응답 작성'
|
|
: analysisStatus === 'in_progress'
|
|
? '요청 분석'
|
|
: request.status === 'queued'
|
|
? '대기'
|
|
: request.status === 'completed'
|
|
? '완료'
|
|
: resolveShareProcessRequestStatusTag(request).label;
|
|
|
|
return {
|
|
requestId: request.requestId,
|
|
statusTag: resolveShareProcessRequestStatusTag(request),
|
|
summary,
|
|
elapsedLabel: formatElapsedDuration(startedAt, nowMs) || '-',
|
|
startedAtLabel: formatShareRuntimeTimestamp(startedAt),
|
|
updatedAtLabel: formatShareRuntimeTimestamp(updatedAt),
|
|
activityLines: normalizedLines,
|
|
latestActivityLine,
|
|
checklist: [
|
|
{
|
|
key: 'accepted',
|
|
label: '요청 접수',
|
|
status: 'completed',
|
|
note: `${formatShareRuntimeTimestamp(request.createdAt)} 접수`,
|
|
},
|
|
{
|
|
key: 'analysis',
|
|
label: '계획·문맥 확인',
|
|
status: analysisStatus,
|
|
note: hasAnalysis ? '질문과 문맥, 관련 리소스를 확인 중입니다.' : '활동 로그 대기',
|
|
},
|
|
{
|
|
key: 'implementation',
|
|
label: '실행·응답 작성',
|
|
status: implementationStatus,
|
|
note: hasImplementation ? '수정/실행/응답 초안을 진행 중입니다.' : '아직 실행 단계에 도달하지 않았습니다.',
|
|
},
|
|
{
|
|
key: 'verification',
|
|
label: '검증·결과 정리',
|
|
status: verificationStatus,
|
|
note: request.status === 'completed' ? '최종 응답 또는 결과 정리가 끝났습니다.' : hasVerification ? '검증과 결과 정리를 진행 중입니다.' : '검증 단계 대기',
|
|
},
|
|
],
|
|
narratives: [
|
|
`현재 단계는 ${currentStepLabel}입니다.`,
|
|
latestActivityLine ? `최근 실행 설명: ${latestActivityLine}` : '최근 실행 설명이 아직 기록되지 않았습니다.',
|
|
request.hasResponse ? `현재 응답 요약: ${answerSummary}` : '아직 최종 응답은 기록되지 않았습니다.',
|
|
],
|
|
};
|
|
}
|
|
|
|
async function createSharePreviewFetchError(response: Response): Promise<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 ShareMessageTextBlock({
|
|
tone,
|
|
label,
|
|
text,
|
|
timestamp,
|
|
actions,
|
|
}: {
|
|
tone: 'question' | 'answer';
|
|
label: string;
|
|
text: string;
|
|
timestamp?: string | null;
|
|
actions?: ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={`chat-share-page__message-tone chat-share-page__message-tone--${tone}`}>
|
|
<div className="chat-share-page__message-tone-head">
|
|
<div className="chat-share-page__message-tone-meta">
|
|
<span className="chat-share-page__message-tone-label">{label}</span>
|
|
{timestamp ? <span className="chat-share-page__message-tone-time">{timestamp}</span> : null}
|
|
</div>
|
|
{actions ? <div className="chat-share-page__message-actions">{actions}</div> : null}
|
|
</div>
|
|
<ExpandableMessageText text={text} />
|
|
</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,
|
|
onCopyMessage,
|
|
}: {
|
|
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;
|
|
onCopyMessage?: ((text: string, label: string) => void) | null;
|
|
}) {
|
|
const { visibleText, promptParts: rawPromptParts, diffBlocks, previewItems: rawPreviewItems } = useMemo(
|
|
() => extractShareMessageRenderPayload(message),
|
|
[message],
|
|
);
|
|
const promptParts = useMemo(
|
|
() => rawPromptParts.map((part) => (shareToken ? rewritePromptPartForShare(part, shareToken) : part)),
|
|
[rawPromptParts, shareToken],
|
|
);
|
|
const previewItems = useMemo(
|
|
() =>
|
|
shareToken
|
|
? rawPreviewItems.map((item) => ({
|
|
...item,
|
|
url: resolveShareScopedResourceUrl(item.url, shareToken),
|
|
}))
|
|
: [],
|
|
[rawPreviewItems, shareToken],
|
|
);
|
|
const parentRequestId = message.clientRequestId?.trim() || fallbackRequestId?.trim() || '';
|
|
const parentRequest = parentRequestId ? requestById.get(parentRequestId) ?? null : null;
|
|
const isParentRequestInFlight = parentRequest ? isRequestInFlight(parentRequest.status) : false;
|
|
const parentQuestionText = useMemo(
|
|
() => resolvePromptParentQuestionText(parentRequestId ? requestById.get(parentRequestId) ?? null : null),
|
|
[parentRequestId, requestById],
|
|
);
|
|
const canCompletePrompt =
|
|
countUnresolvedPromptParts(promptParts) > 0 &&
|
|
!isPromptManualCompleted &&
|
|
!isParentRequestInFlight;
|
|
const hasOpenPromptInResponse = countUnresolvedPromptParts(promptParts) > 0;
|
|
const canCompleteVerification =
|
|
!hasOpenPromptInResponse &&
|
|
Boolean(parentRequestId) &&
|
|
Boolean(onCompleteVerification) &&
|
|
!hasChildRequest &&
|
|
!isVerificationCompleted &&
|
|
!isParentRequestInFlight;
|
|
const canReplyToResponse =
|
|
!hasOpenPromptInResponse &&
|
|
Boolean(parentRequestId) &&
|
|
Boolean(onReplyToResponse) &&
|
|
!isParentRequestInFlight;
|
|
const isReplyTargetActive = canReplyToResponse && activeReplyRequestId?.trim() === parentRequestId;
|
|
const answerActions = (
|
|
<>
|
|
{canCompletePrompt && parentRequestId && onCompletePrompt ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CheckOutlined />}
|
|
loading={isPromptCompletionSaving}
|
|
aria-label="완료 처리"
|
|
title="완료 처리"
|
|
onClick={() => {
|
|
void onCompletePrompt(parentRequestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canCompleteVerification && parentRequestId && onCompleteVerification ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CheckOutlined />}
|
|
loading={isVerificationCompletionSaving}
|
|
aria-label="완료 처리"
|
|
title="완료 처리"
|
|
onClick={() => {
|
|
void onCompleteVerification(parentRequestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canReplyToResponse && parentRequestId && onReplyToResponse ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className={`chat-share-page__message-action-button chat-share-page__response-reply-button${
|
|
isReplyTargetActive ? ' chat-share-page__response-reply-button--active' : ''
|
|
}`}
|
|
icon={<SendOutlined />}
|
|
aria-label={isReplyTargetActive ? '답변 참조 중' : '답변하기'}
|
|
title={isReplyTargetActive ? '답변 참조 중' : '답변하기'}
|
|
onClick={() => {
|
|
onReplyToResponse(parentRequestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{onCopyMessage ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CopyOutlined />}
|
|
aria-label="답변 복사"
|
|
title="답변 복사"
|
|
onClick={() => onCopyMessage(visibleText, '답변')}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className="chat-share-page__response-block"
|
|
ref={(element) => {
|
|
onSetResponseAnchor?.(message.id, element);
|
|
}}
|
|
>
|
|
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
|
|
{emphasisLabel ? <Tag>{emphasisLabel}</Tag> : null}
|
|
{!canCompletePrompt && isPromptManualCompleted ? <Tag color="success">prompt 완료 처리됨</Tag> : null}
|
|
{!canCompleteVerification && !hasOpenPromptInResponse && !hasChildRequest && isVerificationCompleted ? <Tag color="success">응답 확인 완료</Tag> : null}
|
|
</div>
|
|
<ShareMessageTextBlock
|
|
tone="answer"
|
|
label="답변"
|
|
text={visibleText}
|
|
timestamp={formatTimeLabel(message.timestamp)}
|
|
actions={answerActions}
|
|
/>
|
|
{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,
|
|
onCopyMessage,
|
|
onCancelActiveRequest,
|
|
isActiveRequestCancellationSaving = false,
|
|
onResubmitRequestDirect,
|
|
isDirectResubmitSaving = false,
|
|
onOpenProcessInspector,
|
|
}: {
|
|
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;
|
|
onCopyMessage?: ((text: string, label: string) => void) | null;
|
|
onCancelActiveRequest?: ((requestId: string) => Promise<void>) | null;
|
|
isActiveRequestCancellationSaving?: boolean;
|
|
onResubmitRequestDirect?: ((requestId: string) => Promise<void>) | null;
|
|
isDirectResubmitSaving?: boolean;
|
|
onOpenProcessInspector?: ((requestId: string) => void) | null;
|
|
}) {
|
|
const questionText = useMemo(() => buildShareVisibleText(request.userText), [request.userText]);
|
|
const questionPreviewItems = useMemo(
|
|
() => buildSharePreviewItemsFromText(request.userText, shareToken),
|
|
[request.userText, shareToken],
|
|
);
|
|
const { directParentRequest, topParentRequest } = useMemo(
|
|
() => resolveShareRequestLineage(request, requestById),
|
|
[request, requestById],
|
|
);
|
|
const resolvedAnswerText = answerText.trim() || resolveShareRequestFallbackAnswerText(request);
|
|
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 canCancelActiveRequest =
|
|
Boolean(onCancelActiveRequest)
|
|
&& !request.hasResponse
|
|
&& isRequestInFlight(request.status);
|
|
const canResubmitDirectRequest =
|
|
Boolean(onResubmitRequestDirect)
|
|
&& !request.hasResponse
|
|
&& request.status === 'queued';
|
|
const retryCount = Math.max(0, Number(request.retryCount ?? 0) || 0);
|
|
const hasQuestionLineage = Boolean(directParentRequest || topParentRequest);
|
|
const questionActions = (
|
|
<>
|
|
{hasQuestionLineage && onOpenPreviousQuestion ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CommentOutlined />}
|
|
aria-label="부모 질의 보기"
|
|
title="부모 질의 보기"
|
|
onClick={() => {
|
|
onOpenPreviousQuestion(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canCancelDisconnectedRequest ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
className="chat-share-page__message-action-button"
|
|
loading={isRequestCancellationSaving}
|
|
icon={<CloseOutlined />}
|
|
aria-label="취소 처리"
|
|
title="취소 처리"
|
|
onClick={() => {
|
|
void onCancelDisconnectedRequest?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canRetryDisconnectedRequest ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
loading={isRequestRetrySaving}
|
|
icon={<ReloadOutlined />}
|
|
aria-label="재처리"
|
|
title="재처리"
|
|
onClick={() => {
|
|
void onRetryDisconnectedRequest?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canCancelActiveRequest ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
danger
|
|
className="chat-share-page__message-action-button"
|
|
loading={isActiveRequestCancellationSaving}
|
|
icon={<CloseOutlined />}
|
|
aria-label="취소"
|
|
title="취소"
|
|
onClick={() => {
|
|
void onCancelActiveRequest?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canResubmitDirectRequest ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
loading={isDirectResubmitSaving}
|
|
icon={<ThunderboltOutlined />}
|
|
aria-label="즉시전송"
|
|
title="즉시전송"
|
|
onClick={() => {
|
|
void onResubmitRequestDirect?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{isRequestStillRunning && onOpenProcessInspector ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<EyeOutlined />}
|
|
aria-label="상세 과정 보기"
|
|
title="상세 과정 보기"
|
|
onClick={() => {
|
|
onOpenProcessInspector(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{questionText && onCopyMessage ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CopyOutlined />}
|
|
aria-label="질문 복사"
|
|
title="질문 복사"
|
|
onClick={() => onCopyMessage(questionText, '질문')}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
const answerSummaryActions = (
|
|
<>
|
|
{canCompleteSummaryVerification ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CheckOutlined />}
|
|
loading={isVerificationCompletionSaving}
|
|
aria-label="완료 처리"
|
|
title="완료 처리"
|
|
onClick={() => {
|
|
void onCompleteVerification?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{canReplyFromSummary ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className={`chat-share-page__message-action-button chat-share-page__response-reply-button${
|
|
isSummaryReplyActive ? ' chat-share-page__response-reply-button--active' : ''
|
|
}`}
|
|
icon={<SendOutlined />}
|
|
aria-label={isSummaryReplyActive ? '답변 참조 중' : '답변하기'}
|
|
title={isSummaryReplyActive ? '답변 참조 중' : '답변하기'}
|
|
onClick={() => {
|
|
onReplyToResponse?.(request.requestId);
|
|
}}
|
|
/>
|
|
) : null}
|
|
{resolvedAnswerText && onCopyMessage ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__message-action-button"
|
|
icon={<CopyOutlined />}
|
|
aria-label="답변 복사"
|
|
title="답변 복사"
|
|
onClick={() => onCopyMessage(resolvedAnswerText, '답변')}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<section
|
|
id={`chat-share-request-${request.requestId}`}
|
|
className="chat-share-page__request-block"
|
|
ref={(element) => {
|
|
onSetRequestAnchor?.(request.requestId, element);
|
|
}}
|
|
>
|
|
{isRetriedRequest(request) ? (
|
|
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
|
|
<Tag color="processing">재처리 {retryCount}회</Tag>
|
|
</div>
|
|
) : null}
|
|
{shouldRenderQuestion ? (
|
|
<>
|
|
<ShareMessageTextBlock
|
|
tone="question"
|
|
label="질문"
|
|
text={questionText || '-'}
|
|
timestamp={formatTimeLabel(request.createdAt)}
|
|
actions={questionActions}
|
|
/>
|
|
{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" />
|
|
{isVerificationCompleted ? (
|
|
<div className="chat-share-page__message-headline chat-share-page__message-headline--inline">
|
|
<Tag color="success">응답 확인 완료</Tag>
|
|
</div>
|
|
) : null}
|
|
<ShareMessageTextBlock
|
|
tone="answer"
|
|
label="답변"
|
|
text={resolvedAnswerText}
|
|
timestamp={formatTimeLabel(resolveRequestMessageTimestamp(request))}
|
|
actions={answerSummaryActions}
|
|
/>
|
|
</>
|
|
) : 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}
|
|
onCopyMessage={onCopyMessage}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function ChatSharePage() {
|
|
const { message, modal } = App.useApp();
|
|
const appConfig = useAppConfig();
|
|
const { token = '' } = useParams();
|
|
const normalizedToken = token.trim();
|
|
const initialRequestedRoomSessionId =
|
|
typeof window === 'undefined'
|
|
? ''
|
|
: readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken);
|
|
const initialCachedSnapshot =
|
|
typeof window === 'undefined' || !normalizedToken
|
|
? null
|
|
: readStoredShareRoomSnapshot(normalizedToken, initialRequestedRoomSessionId);
|
|
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>(initialCachedSnapshot);
|
|
const [requestedRoomSessionId, setRequestedRoomSessionId] = useState<string>(initialRequestedRoomSessionId);
|
|
const requestedRoomSessionIdRef = useRef(requestedRoomSessionId);
|
|
const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null);
|
|
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 [isShareRoomListVisible, setIsShareRoomListVisible] = useState(false);
|
|
const [shareRoomListLayerStyle, setShareRoomListLayerStyle] = useState<CSSProperties | null>(null);
|
|
const [conversationToolbarStickyTop, setConversationToolbarStickyTop] = useState(52);
|
|
const [isRoomSwitching, setIsRoomSwitching] = useState(false);
|
|
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 [isCreateRoomOpen, setIsCreateRoomOpen] = useState(false);
|
|
const [isSavingRoomSettings, setIsSavingRoomSettings] = useState(false);
|
|
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
|
|
const [isLoadingConversationCandidates, setIsLoadingConversationCandidates] = useState(false);
|
|
const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security' | 'runtime'>('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 [creatingRoomTitle, setCreatingRoomTitle] = useState('');
|
|
const [creatingRoomChatTypeId, setCreatingRoomChatTypeId] = useState<string | null>(null);
|
|
const [creatingRoomRequestBadgeLabel, setCreatingRoomRequestBadgeLabel] = useState('');
|
|
const [creatingRoomLinkedSessionId, setCreatingRoomLinkedSessionId] = useState('');
|
|
const [creatingRoomSeedMessage, setCreatingRoomSeedMessage] = useState(
|
|
'이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.',
|
|
);
|
|
const [conversationCandidates, setConversationCandidates] = useState<ChatConversationSummary[]>([]);
|
|
const [originReplyDraftText, setOriginReplyDraftText] = useState('');
|
|
const [originReplyTargetGroupKey, setOriginReplyTargetGroupKey] = useState('');
|
|
const [isOriginReplyModalOpen, setIsOriginReplyModalOpen] = useState(false);
|
|
const [isSubmittingOriginReply, setIsSubmittingOriginReply] = useState(false);
|
|
const [sourceGroupDetailKey, setSourceGroupDetailKey] = useState('');
|
|
const [roomNotificationClientStatus, setRoomNotificationClientStatus] = useState<ShareNotificationClientStatus>(() =>
|
|
buildShareNotificationClientStatus({
|
|
roomEnabled: false,
|
|
appEnabled: appConfig.chat.receiveRoomNotifications,
|
|
permission: getClientNotificationPermission(),
|
|
registrationReady: false,
|
|
}),
|
|
);
|
|
const [shareRuntimeSnapshot, setShareRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(null);
|
|
const [isShareRuntimeLoading, setIsShareRuntimeLoading] = useState(false);
|
|
const [pendingShareRuntimeRequestIds, setPendingShareRuntimeRequestIds] = useState<string[]>([]);
|
|
const [activeProcessInspectorRequestId, setActiveProcessInspectorRequestId] = useState('');
|
|
const [processInspectorMode, setProcessInspectorMode] = useState<ShareProcessInspectorMode>('default');
|
|
const [processInspectorExpandedSection, setProcessInspectorExpandedSection] = useState<ShareProcessInspectorExpandedSection>(null);
|
|
const [optimisticShareRooms, setOptimisticShareRooms] = useState<ChatShareRoomSummary[]>([]);
|
|
const [shareRoomPendingCountsBySessionId, setShareRoomPendingCountsBySessionId] = useState<Record<string, ShareRoomPendingCounts>>({});
|
|
const [isLoadingShareRoomPendingCounts, setIsLoadingShareRoomPendingCounts] = useState(false);
|
|
const [isRefreshingRoomNotificationStatus, setIsRefreshingRoomNotificationStatus] = useState(false);
|
|
const [isSendingRoomNotificationTest, setIsSendingRoomNotificationTest] = useState(false);
|
|
const [isDeletingRoom, setIsDeletingRoom] = useState(false);
|
|
const [pendingDeleteRoomSessionId, setPendingDeleteRoomSessionId] = useState('');
|
|
const [shareRoomFilterKeyword, setShareRoomFilterKeyword] = useState('');
|
|
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 [minimizedPrograms, setMinimizedPrograms] = useState<ShareMinimizedProgramItem[]>([]);
|
|
const [programReloadKey, setProgramReloadKey] = useState(0);
|
|
const shareRoomPendingCountFetchSequenceRef = useRef(0);
|
|
const shareRoomPendingCountRefreshPromiseBySessionIdRef = useRef<Record<string, Promise<void> | null>>({});
|
|
const shareRoomPendingCountRefreshQueuedBySessionIdRef = useRef<Record<string, boolean>>({});
|
|
const conversationHeaderRef = useRef<HTMLDivElement | null>(null);
|
|
const conversationToolbarRef = useRef<HTMLDivElement | null>(null);
|
|
const roomListTriggerButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
const roomListPanelRef = useRef<HTMLElement | null>(null);
|
|
const processInspectorCardRef = useRef<HTMLDivElement | null>(null);
|
|
const processInspectorDragStateRef = useRef<{
|
|
pointerId: number;
|
|
lastX: number;
|
|
lastY: number;
|
|
captureTarget: HTMLDivElement;
|
|
} | null>(null);
|
|
const processInspectorPositionRef = useRef(getDefaultShareProcessInspectorPosition());
|
|
const [processInspectorPosition, setProcessInspectorPosition] = useState(() => processInspectorPositionRef.current);
|
|
const programMinimizedCardRefs = useRef(new Map<string, HTMLDivElement>());
|
|
const programMinimizedDragStateRef = useRef<{
|
|
key: string;
|
|
pointerId: number;
|
|
lastX: number;
|
|
lastY: number;
|
|
captureTarget: HTMLDivElement;
|
|
} | null>(null);
|
|
const programMinimizedMovedRef = useRef(false);
|
|
const minimizedProgramsRef = useRef<ShareMinimizedProgramItem[]>([]);
|
|
const minimizedProgramPositionByKeyRef = useRef<Record<string, ShareMinimizedProgramItem['position']>>({});
|
|
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());
|
|
|
|
minimizedProgramsRef.current = minimizedPrograms;
|
|
hasSnapshotRef.current = snapshot != null;
|
|
requiresAccessPinRef.current = requiresAccessPin;
|
|
requestedRoomSessionIdRef.current = requestedRoomSessionId;
|
|
const isRoomSwitchingRef = useRef(isRoomSwitching);
|
|
isRoomSwitchingRef.current = isRoomSwitching;
|
|
|
|
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 shareRooms = useMemo(() => {
|
|
const snapshotRooms = snapshot?.rooms ?? [];
|
|
|
|
if (optimisticShareRooms.length === 0) {
|
|
return dedupeShareRooms(snapshotRooms);
|
|
}
|
|
|
|
const nextRooms = [...snapshotRooms];
|
|
const knownSessionIds = new Set(snapshotRooms.map((room) => room.sessionId));
|
|
|
|
optimisticShareRooms.forEach((room) => {
|
|
if (!knownSessionIds.has(room.sessionId)) {
|
|
nextRooms.push(room);
|
|
}
|
|
});
|
|
|
|
return dedupeShareRooms(nextRooms);
|
|
}, [optimisticShareRooms, snapshot?.rooms]);
|
|
const conversationCandidateBySessionId = useMemo(
|
|
() => new Map(conversationCandidates.map((item) => [item.sessionId, item])),
|
|
[conversationCandidates],
|
|
);
|
|
const activeShareRoomSessionId = snapshot?.activeSessionId?.trim() || snapshot?.share.sessionId?.trim() || '';
|
|
const selectedShareRoomSessionId = requestedRoomSessionId.trim() || activeShareRoomSessionId;
|
|
const activeShareRoom = useMemo(
|
|
() => shareRooms.find((item) => item.sessionId === selectedShareRoomSessionId) ?? null,
|
|
[selectedShareRoomSessionId, shareRooms],
|
|
);
|
|
const hasVisibleSnapshot = snapshot != null;
|
|
const showRoomSwitchingOverlay = isRoomSwitching && hasVisibleSnapshot;
|
|
const showRoomSwitchingSkeleton = isRoomSwitching && !hasVisibleSnapshot;
|
|
const roomSwitchingStatusLabel = activeShareRoom?.title?.trim() || '선택한 채팅방';
|
|
const filteredShareRooms = useMemo(() => {
|
|
const keyword = shareRoomFilterKeyword.trim().toLowerCase();
|
|
if (!keyword) {
|
|
return shareRooms;
|
|
}
|
|
|
|
return shareRooms.filter((room) => {
|
|
const searchIndex = [
|
|
room.title,
|
|
room.contextLabel,
|
|
room.requestBadgeLabel,
|
|
room.sessionId,
|
|
room.linkContext?.sourceTitle,
|
|
room.linkContext?.sourceRequestPreview,
|
|
room.linkContext?.sourceChatTypeLabel,
|
|
]
|
|
.map((value) => value?.trim().toLowerCase() ?? '')
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
return searchIndex.includes(keyword);
|
|
});
|
|
}, [shareRoomFilterKeyword, shareRooms]);
|
|
const filteredShareRoomGroups = useMemo(
|
|
() => buildShareRoomSourceGroups(filteredShareRooms, conversationCandidateBySessionId),
|
|
[conversationCandidateBySessionId, filteredShareRooms],
|
|
);
|
|
const sourceGroupDetail = useMemo(
|
|
() => filteredShareRoomGroups.find((group) => group.key === sourceGroupDetailKey) ?? null,
|
|
[filteredShareRoomGroups, sourceGroupDetailKey],
|
|
);
|
|
const originReplyTargetGroup = useMemo(
|
|
() => filteredShareRoomGroups.find((group) => group.key === originReplyTargetGroupKey) ?? null,
|
|
[filteredShareRoomGroups, originReplyTargetGroupKey],
|
|
);
|
|
const pendingDeleteRoom = useMemo(
|
|
() => shareRooms.find((item) => item.sessionId === pendingDeleteRoomSessionId) ?? null,
|
|
[pendingDeleteRoomSessionId, shareRooms],
|
|
);
|
|
useEffect(() => {
|
|
if (optimisticShareRooms.length === 0 || !snapshot?.rooms?.length) {
|
|
return;
|
|
}
|
|
|
|
const snapshotRoomIds = new Set(snapshot.rooms.map((room) => room.sessionId));
|
|
setOptimisticShareRooms((current) => {
|
|
const next = current.filter((room) => !snapshotRoomIds.has(room.sessionId));
|
|
return next.length === current.length ? current : next;
|
|
});
|
|
}, [optimisticShareRooms.length, snapshot?.rooms]);
|
|
const shareRuntimeRunningItems = shareRuntimeSnapshot?.running ?? [];
|
|
const shareRuntimeQueuedItems = shareRuntimeSnapshot?.queued ?? [];
|
|
const shareRuntimeRecentItems = shareRuntimeSnapshot?.recent ?? [];
|
|
const shareRuntimeItemByRequestId = useMemo(
|
|
() =>
|
|
new Map(
|
|
[...shareRuntimeRunningItems, ...shareRuntimeQueuedItems, ...shareRuntimeRecentItems].map((item) => [
|
|
item.requestId.trim(),
|
|
item,
|
|
]),
|
|
),
|
|
[shareRuntimeQueuedItems, shareRuntimeRecentItems, shareRuntimeRunningItems],
|
|
);
|
|
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 currentShareChatTarget = useMemo(
|
|
() => buildShareChatEnvironmentTarget(snapshot?.share.sharePath ?? null, normalizedToken, selectedAppEnvironment),
|
|
[normalizedToken, selectedAppEnvironment, snapshot?.share.sharePath],
|
|
);
|
|
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 canCreateSharedRooms = sharePermissionSet.has('manage');
|
|
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',
|
|
})
|
|
: createChatShareManifestHref(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.startsWith('blob:')) {
|
|
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 sortedRequests = useMemo(
|
|
() => [...(snapshot?.requests ?? [])].sort(compareShareConversationRequests),
|
|
[snapshot],
|
|
);
|
|
const isServerCommandDrawerMobile = typeof window !== 'undefined' ? window.innerWidth <= 768 : false;
|
|
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,
|
|
]);
|
|
const openCreateRoomDialog = useCallback(() => {
|
|
const nextChatTypeId = currentSharedChatTypeId ?? enabledChatTypes[0]?.id ?? null;
|
|
const nextChatTypeName = enabledChatTypes.find((item) => item.id === nextChatTypeId)?.name ?? '';
|
|
|
|
setCreatingRoomTitle(nextChatTypeName ? `${nextChatTypeName} 작업방` : '새 공유 채팅방');
|
|
setCreatingRoomChatTypeId(nextChatTypeId);
|
|
setCreatingRoomRequestBadgeLabel('');
|
|
setCreatingRoomLinkedSessionId('');
|
|
setCreatingRoomSeedMessage('이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.');
|
|
setIsCreateRoomOpen(true);
|
|
setIsLoadingConversationCandidates(true);
|
|
void fetchChatConversations()
|
|
.then((items) => {
|
|
const nextItems = items.filter((item) => item.sessionId !== snapshot?.conversation.sessionId);
|
|
setConversationCandidates(nextItems);
|
|
})
|
|
.catch((error) => {
|
|
console.error('failed to load share room candidates', error);
|
|
setConversationCandidates([]);
|
|
})
|
|
.finally(() => {
|
|
setIsLoadingConversationCandidates(false);
|
|
});
|
|
}, [currentSharedChatTypeId, enabledChatTypes, snapshot?.conversation.sessionId]);
|
|
const refreshShareRuntime = useCallback(async (options?: { silent?: boolean }) => {
|
|
if (!normalizedToken || !selectedShareRoomSessionId) {
|
|
setShareRuntimeSnapshot(null);
|
|
return false;
|
|
}
|
|
|
|
if (!options?.silent) {
|
|
setIsShareRuntimeLoading(true);
|
|
}
|
|
|
|
try {
|
|
const nextSnapshot = await fetchChatShareRuntimeSnapshot(normalizedToken, {
|
|
sessionId: selectedShareRoomSessionId,
|
|
});
|
|
setShareRuntimeSnapshot(nextSnapshot);
|
|
return true;
|
|
} catch (error) {
|
|
if (!options?.silent) {
|
|
message.error(error instanceof Error ? error.message : '처리중 세션 상태를 불러오지 못했습니다.');
|
|
}
|
|
return false;
|
|
} finally {
|
|
setIsShareRuntimeLoading(false);
|
|
}
|
|
}, [message, normalizedToken, selectedShareRoomSessionId]);
|
|
useEffect(() => {
|
|
if (!isRoomSettingsOpen) {
|
|
return;
|
|
}
|
|
|
|
void refreshRoomNotificationStatus(editingRoomNotifyOffline);
|
|
}, [appConfig.chat.receiveRoomNotifications, editingRoomNotifyOffline, isRoomSettingsOpen, refreshRoomNotificationStatus]);
|
|
useEffect(() => {
|
|
if (!isRoomSettingsOpen || roomSettingsTabKey !== 'runtime') {
|
|
return;
|
|
}
|
|
|
|
void refreshShareRuntime();
|
|
}, [isRoomSettingsOpen, refreshShareRuntime, roomSettingsTabKey]);
|
|
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,
|
|
sessionId: requestedRoomSessionIdRef.current || undefined,
|
|
view: initialLoad ? 'initial' : 'full',
|
|
});
|
|
const requestedSessionId = requestedRoomSessionIdRef.current.trim();
|
|
const matchedRequestedRoom = doesShareSnapshotMatchRequestedRoom(nextSnapshot, requestedSessionId);
|
|
|
|
const shouldApplyImmediately =
|
|
!isInteractingRef.current
|
|
|| initialLoad
|
|
|| requiresAccessPinRef.current
|
|
|| !hasSnapshotRef.current
|
|
|| isRoomSwitchingRef.current;
|
|
|
|
if (!shouldApplyImmediately) {
|
|
deferredSnapshotRef.current = nextSnapshot;
|
|
} else {
|
|
setSnapshot(nextSnapshot);
|
|
deferredSnapshotRef.current = null;
|
|
}
|
|
if (nextSnapshot.detailLevel !== 'initial') {
|
|
writeStoredShareRoomSnapshot(normalizedToken, nextSnapshot);
|
|
}
|
|
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);
|
|
}
|
|
|
|
if (matchedRequestedRoom) {
|
|
setIsRoomSwitching(false);
|
|
} else if (requestedSessionId) {
|
|
pendingSilentRefreshRef.current = true;
|
|
}
|
|
|
|
setErrorMessage('');
|
|
setRequiresAccessPin(false);
|
|
setAccessPinSubmitError('');
|
|
|
|
if (initialLoad && nextSnapshot.detailLevel === 'initial') {
|
|
window.setTimeout(() => {
|
|
void refreshSnapshot({ silent: true });
|
|
}, 0);
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) {
|
|
setStoredChatShareAccessPin(normalizedToken, null);
|
|
clearStoredShareRoomSnapshotCache(normalizedToken);
|
|
setSnapshot(null);
|
|
setRequiresAccessPin(true);
|
|
|
|
if (error.code === 'share_pin_invalid') {
|
|
setAccessPinInput('');
|
|
setAccessPinSubmitError(error.message);
|
|
} else {
|
|
setAccessPinSubmitError('');
|
|
}
|
|
}
|
|
|
|
if (!silent) {
|
|
setErrorMessage(error instanceof Error ? error.message : '공유 화면을 불러오지 못했습니다.');
|
|
}
|
|
setIsRoomSwitching(false);
|
|
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 handleDeleteShareRoom = useCallback(async (room: ChatShareRoomSummary) => {
|
|
if (!normalizedToken || !room.sessionId || isDeletingRoom) {
|
|
return;
|
|
}
|
|
|
|
if (!canDeleteShareRoom(room, shareRooms)) {
|
|
message.warning(room.isDefault ? '기본 채팅방은 삭제할 수 없습니다.' : '마지막 채팅방은 삭제할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
setIsDeletingRoom(true);
|
|
|
|
try {
|
|
const result = await deleteChatShareRoom(normalizedToken, room.sessionId);
|
|
const fallbackSessionId =
|
|
result.nextRoomSessionId
|
|
|| shareRooms.find((item) => item.sessionId !== room.sessionId)?.sessionId
|
|
|| '';
|
|
|
|
setOptimisticShareRooms((current) => current.filter((item) => item.sessionId !== room.sessionId));
|
|
setSnapshot((current) => {
|
|
if (!current) {
|
|
return current;
|
|
}
|
|
|
|
const nextRooms = current.rooms.filter((item) => item.sessionId !== room.sessionId);
|
|
const nextActiveSessionId =
|
|
current.activeSessionId === room.sessionId
|
|
? (result.nextRoomSessionId || nextRooms.find((item) => item.isDefault)?.sessionId || nextRooms[0]?.sessionId || '')
|
|
: current.activeSessionId;
|
|
|
|
return {
|
|
...current,
|
|
rooms: nextRooms,
|
|
activeSessionId: nextActiveSessionId,
|
|
};
|
|
});
|
|
setPendingDeleteRoomSessionId('');
|
|
setSwipedRoomSessionId('');
|
|
removeStoredShareRoomSnapshot(normalizedToken, room.sessionId);
|
|
requestedRoomSessionIdRef.current =
|
|
requestedRoomSessionIdRef.current === room.sessionId ? fallbackSessionId : requestedRoomSessionIdRef.current;
|
|
setRequestedRoomSessionId((current) => (current === room.sessionId ? fallbackSessionId : current));
|
|
|
|
if (selectedShareRoomSessionId === room.sessionId) {
|
|
setDraftText('');
|
|
setComposerAttachments([]);
|
|
setReplyReferenceRequestId('');
|
|
setLatestRequestId('');
|
|
setExpandMode('pending');
|
|
}
|
|
|
|
await refreshSnapshot({ silent: true });
|
|
message.success(`"${room.title}" 채팅방을 삭제했습니다.`);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '공유 채팅방 삭제 중 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsDeletingRoom(false);
|
|
}
|
|
}, [
|
|
isDeletingRoom,
|
|
message,
|
|
normalizedToken,
|
|
refreshSnapshot,
|
|
selectedShareRoomSessionId,
|
|
shareRooms,
|
|
]);
|
|
const handleCancelShareRuntimeRequest = useCallback((item: ChatRuntimeJobItem) => {
|
|
const actionLabel = item.status === 'queued' ? '대기 요청' : '실행 중 요청';
|
|
|
|
modal.confirm({
|
|
title: `${actionLabel}을 취소할까요?`,
|
|
content: item.summary || '요약 정보가 없는 요청입니다.',
|
|
okText: '취소 실행',
|
|
cancelText: '닫기',
|
|
autoFocusButton: 'cancel',
|
|
okButtonProps: { danger: true },
|
|
onOk: async () => {
|
|
setPendingShareRuntimeRequestIds((current) => [...current, item.requestId]);
|
|
|
|
try {
|
|
const action = await cancelChatShareRuntimeRequest(normalizedToken, {
|
|
requestId: item.requestId,
|
|
sessionId: activeShareRoomSessionId,
|
|
});
|
|
message.success(action === 'removed' ? '대기 요청을 취소했습니다.' : '실행 중 요청 취소를 요청했습니다.');
|
|
await Promise.all([
|
|
refreshShareRuntime({ silent: true }),
|
|
refreshSnapshot({ silent: true }),
|
|
]);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '처리중 세션 취소에 실패했습니다.');
|
|
} finally {
|
|
setPendingShareRuntimeRequestIds((current) => current.filter((requestId) => requestId !== item.requestId));
|
|
}
|
|
},
|
|
});
|
|
}, [activeShareRoomSessionId, message, modal, normalizedToken, refreshShareRuntime, refreshSnapshot]);
|
|
const handleCopyShareMessageText = useCallback(async (text: string, label: string) => {
|
|
const normalizedText = text.trim();
|
|
|
|
if (!normalizedText) {
|
|
message.warning(`복사할 ${label} 내용이 없습니다.`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await copyTextToClipboard(normalizedText);
|
|
message.success(`${label}을 복사했습니다.`);
|
|
} catch (error) {
|
|
console.error(`failed to copy ${label} text`, error);
|
|
message.error(`${label} 복사에 실패했습니다.`);
|
|
}
|
|
}, [message]);
|
|
const handleCancelActiveShareRequest = useCallback(async (requestId: string) => {
|
|
const normalizedRequestId = requestId.trim();
|
|
|
|
if (!normalizedToken || !normalizedRequestId || pendingShareRuntimeRequestIds.includes(normalizedRequestId)) {
|
|
return;
|
|
}
|
|
|
|
const targetRequest = sortedRequests.find((item) => item.requestId === normalizedRequestId) ?? null;
|
|
|
|
if (!targetRequest || targetRequest.hasResponse || !isRequestInFlight(targetRequest.status)) {
|
|
return;
|
|
}
|
|
|
|
const actionLabel = targetRequest.status === 'queued' ? '대기 요청' : '실행 중 요청';
|
|
const confirmed = await new Promise<boolean>((resolve) => {
|
|
modal.confirm({
|
|
title: `${actionLabel}을 취소할까요?`,
|
|
content: targetRequest.userText.trim() || targetRequest.statusMessage?.trim() || '요약 정보가 없는 요청입니다.',
|
|
okText: '취소 실행',
|
|
cancelText: '닫기',
|
|
autoFocusButton: 'cancel',
|
|
okButtonProps: { danger: true },
|
|
centered: true,
|
|
onOk: async () => {
|
|
resolve(true);
|
|
},
|
|
onCancel: () => {
|
|
resolve(false);
|
|
},
|
|
});
|
|
});
|
|
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
|
|
setPendingShareRuntimeRequestIds((current) => Array.from(new Set([...current, normalizedRequestId])));
|
|
|
|
try {
|
|
const action = await cancelChatShareRuntimeRequest(normalizedToken, {
|
|
requestId: normalizedRequestId,
|
|
sessionId: activeShareRoomSessionId,
|
|
});
|
|
message.success(action === 'removed' ? '대기 요청을 취소했습니다.' : '실행 중 요청 취소를 요청했습니다.');
|
|
await Promise.all([
|
|
refreshShareRuntime({ silent: true }),
|
|
refreshSnapshot({ silent: true }),
|
|
]);
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '처리 중 요청 취소에 실패했습니다.');
|
|
} finally {
|
|
setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId));
|
|
}
|
|
}, [activeShareRoomSessionId, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]);
|
|
const handleResubmitQueuedRequestDirect = useCallback(async (requestId: string) => {
|
|
const normalizedRequestId = requestId.trim();
|
|
|
|
if (!normalizedToken || !normalizedRequestId || isSending || pendingShareRuntimeRequestIds.includes(normalizedRequestId)) {
|
|
return;
|
|
}
|
|
|
|
const targetRequest = sortedRequests.find((item) => item.requestId === normalizedRequestId) ?? null;
|
|
|
|
if (!targetRequest || targetRequest.hasResponse || targetRequest.status !== 'queued') {
|
|
return;
|
|
}
|
|
|
|
const outgoingText = buildOutgoingShareMessageText(targetRequest.userText, []).trim();
|
|
|
|
if (!outgoingText) {
|
|
message.warning('즉시 전송할 질문 내용이 없습니다.');
|
|
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;
|
|
}
|
|
|
|
setPendingShareRuntimeRequestIds((current) => Array.from(new Set([...current, normalizedRequestId])));
|
|
setIsSending(true);
|
|
|
|
try {
|
|
await cancelChatShareRuntimeRequest(normalizedToken, {
|
|
requestId: normalizedRequestId,
|
|
sessionId: activeShareRoomSessionId,
|
|
});
|
|
await submitChatShareMessage(normalizedToken, outgoingText, {
|
|
sessionId: activeShareRoomSessionId,
|
|
mode: 'direct',
|
|
parentRequestId: targetRequest.parentRequestId?.trim() || '',
|
|
});
|
|
message.success('대기 요청을 취소하고 즉시전송했습니다.');
|
|
await Promise.all([
|
|
refreshShareRuntime({ silent: true }),
|
|
refreshSnapshot({ silent: true }),
|
|
]);
|
|
} catch (error) {
|
|
if (isShareSendDelayError(error)) {
|
|
message.warning('즉시전송 후 응답 확인이 지연되고 있습니다. 연결 복구 시 최신 내용을 다시 불러옵니다.');
|
|
} else {
|
|
message.error(error instanceof Error ? error.message : '즉시전송 처리에 실패했습니다.');
|
|
}
|
|
} finally {
|
|
setIsSending(false);
|
|
setPendingShareRuntimeRequestIds((current) => current.filter((item) => item !== normalizedRequestId));
|
|
}
|
|
}, [activeShareRoomSessionId, isSending, message, modal, normalizedToken, pendingShareRuntimeRequestIds, refreshShareRuntime, refreshSnapshot, sortedRequests]);
|
|
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, {
|
|
sessionId: snapshot.conversation.sessionId,
|
|
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);
|
|
void refreshSnapshot({ silent: true });
|
|
} 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,
|
|
refreshSnapshot,
|
|
activeRoomContextSettings?.codexParticipants,
|
|
activeRoomContextSettings?.customContextContent,
|
|
activeRoomContextSettings?.customContextTitle,
|
|
activeRoomContextSettings?.defaultContextIds,
|
|
roomContexts,
|
|
setChatContextSettingsStore,
|
|
snapshot?.conversation.notifyOffline,
|
|
snapshot?.conversation.sessionId,
|
|
snapshot?.share.accessPinPromptTtlMinutes,
|
|
snapshot?.share.hasAccessPin,
|
|
]);
|
|
useEffect(() => {
|
|
if (minimizedPrograms.length === 0) {
|
|
programMinimizedDragStateRef.current = null;
|
|
programMinimizedMovedRef.current = false;
|
|
return;
|
|
}
|
|
|
|
const clampPosition = (key: string, position: { x: number; y: number }) => {
|
|
const cardElement = programMinimizedCardRefs.current.get(key) ?? null;
|
|
const cardWidth = cardElement?.offsetWidth ?? PROGRAM_MINIMIZED_DEFAULT_WIDTH;
|
|
const cardHeight = cardElement?.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 = (key: string, position: { x: number; y: number }) => {
|
|
const nextPosition = clampPosition(key, position);
|
|
minimizedProgramPositionByKeyRef.current[key] = nextPosition;
|
|
setMinimizedPrograms((current) => current.map((item) => (
|
|
item.target.key === key
|
|
? {
|
|
...item,
|
|
position: nextPosition,
|
|
}
|
|
: item
|
|
)));
|
|
};
|
|
|
|
const handleResize = () => {
|
|
setMinimizedPrograms((current) => current.map((item) => {
|
|
const nextPosition = clampPosition(item.target.key, item.position);
|
|
minimizedProgramPositionByKeyRef.current[item.target.key] = nextPosition;
|
|
return {
|
|
...item,
|
|
position: nextPosition,
|
|
};
|
|
}));
|
|
};
|
|
|
|
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;
|
|
|
|
const currentItem = minimizedProgramsRef.current.find((item) => item.target.key === dragState.key);
|
|
|
|
if (!currentItem) {
|
|
return;
|
|
}
|
|
|
|
syncPosition(dragState.key, {
|
|
x: currentItem.position.x + deltaX,
|
|
y: currentItem.position.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);
|
|
};
|
|
}, [minimizedPrograms.length]);
|
|
|
|
useLayoutEffect(() => {
|
|
const headerElement = conversationHeaderRef.current;
|
|
|
|
if (!headerElement) {
|
|
return undefined;
|
|
}
|
|
|
|
const updateStickyOffset = () => {
|
|
const headerHeight = headerElement.getBoundingClientRect().height;
|
|
const nextStickyTop = Math.max(48, Math.ceil(headerHeight) + 8);
|
|
|
|
setConversationToolbarStickyTop((current) => (current === nextStickyTop ? current : nextStickyTop));
|
|
};
|
|
|
|
const resizeObserver =
|
|
typeof ResizeObserver === 'undefined'
|
|
? null
|
|
: new ResizeObserver(() => {
|
|
updateStickyOffset();
|
|
});
|
|
|
|
updateStickyOffset();
|
|
window.addEventListener('resize', updateStickyOffset);
|
|
resizeObserver?.observe(headerElement);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', updateStickyOffset);
|
|
resizeObserver?.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isShareRoomListVisible) {
|
|
setShareRoomListLayerStyle(null);
|
|
return;
|
|
}
|
|
|
|
const updateLayerPosition = () => {
|
|
const triggerRect = roomListTriggerButtonRef.current?.getBoundingClientRect() ?? null;
|
|
const headerRect = conversationHeaderRef.current?.getBoundingClientRect() ?? null;
|
|
const toolbarRect = conversationToolbarRef.current?.getBoundingClientRect() ?? null;
|
|
const anchorRect = triggerRect ?? headerRect ?? toolbarRect;
|
|
|
|
if (!anchorRect) {
|
|
return;
|
|
}
|
|
|
|
const viewportPadding = 8;
|
|
const availableWidth = Math.max(280, window.innerWidth - (viewportPadding * 2));
|
|
const preferredWidth = toolbarRect
|
|
? Math.min(Math.max(toolbarRect.width, 280), 420)
|
|
: headerRect
|
|
? Math.min(Math.max(headerRect.width, 280), 420)
|
|
: Math.min(360, availableWidth);
|
|
const width = Math.min(preferredWidth, availableWidth);
|
|
const preferredRight = triggerRect?.right ?? toolbarRect?.right ?? headerRect?.right ?? (viewportPadding + width);
|
|
const minLeft = viewportPadding;
|
|
const maxLeft = Math.max(viewportPadding, window.innerWidth - viewportPadding - width);
|
|
const left = Math.min(Math.max(preferredRight - width, minLeft), maxLeft);
|
|
const top = anchorRect.bottom + 8;
|
|
const maxHeight = Math.max(220, window.innerHeight - top - viewportPadding);
|
|
|
|
setShareRoomListLayerStyle({
|
|
top: `${Math.round(top)}px`,
|
|
left: `${Math.round(left)}px`,
|
|
width: `${Math.round(width)}px`,
|
|
maxHeight: `${Math.round(maxHeight)}px`,
|
|
});
|
|
};
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') {
|
|
setIsShareRoomListVisible(false);
|
|
}
|
|
};
|
|
|
|
const handleOutsidePointerDown = (event: PointerEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Node)) {
|
|
return;
|
|
}
|
|
|
|
if (roomListPanelRef.current?.contains(target) || roomListTriggerButtonRef.current?.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
setIsShareRoomListVisible(false);
|
|
};
|
|
|
|
const handleViewportChange = () => {
|
|
updateLayerPosition();
|
|
};
|
|
|
|
updateLayerPosition();
|
|
window.addEventListener('resize', handleViewportChange);
|
|
window.addEventListener('scroll', handleViewportChange, true);
|
|
document.addEventListener('pointerdown', handleOutsidePointerDown, true);
|
|
document.addEventListener('keydown', handleEscape);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleViewportChange);
|
|
window.removeEventListener('scroll', handleViewportChange, true);
|
|
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
|
|
document.removeEventListener('keydown', handleEscape);
|
|
};
|
|
}, [isShareRoomListVisible]);
|
|
|
|
const handleProcessInspectorPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
if (isShareInteractivePointerTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
const captureTarget = event.currentTarget;
|
|
processInspectorDragStateRef.current = {
|
|
pointerId: event.pointerId,
|
|
lastX: event.clientX,
|
|
lastY: event.clientY,
|
|
captureTarget,
|
|
};
|
|
captureTarget.setPointerCapture(event.pointerId);
|
|
event.preventDefault();
|
|
}, []);
|
|
|
|
const openProcessInspector = useCallback((requestId: string) => {
|
|
if (!requestId.trim()) {
|
|
return;
|
|
}
|
|
|
|
setActiveProcessInspectorRequestId(requestId);
|
|
setProcessInspectorMode('default');
|
|
setProcessInspectorExpandedSection(null);
|
|
}, []);
|
|
|
|
const closeProcessInspector = useCallback(() => {
|
|
setActiveProcessInspectorRequestId('');
|
|
setProcessInspectorExpandedSection(null);
|
|
}, []);
|
|
|
|
const handleToggleProcessInspectorSummary = useCallback(() => {
|
|
setProcessInspectorExpandedSection((current) => (current === 'summary' ? null : 'summary'));
|
|
}, []);
|
|
|
|
const handleToggleProcessInspectorNarratives = useCallback(() => {
|
|
setProcessInspectorExpandedSection((current) => (current === 'narratives' ? null : 'narratives'));
|
|
}, []);
|
|
|
|
const handleProgramMinimizedPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
return;
|
|
}
|
|
|
|
const targetKey = event.currentTarget.dataset.programKey?.trim() ?? '';
|
|
|
|
if (!targetKey) {
|
|
return;
|
|
}
|
|
|
|
const captureTarget = event.currentTarget;
|
|
programMinimizedDragStateRef.current = {
|
|
key: targetKey,
|
|
pointerId: event.pointerId,
|
|
lastX: event.clientX,
|
|
lastY: event.clientY,
|
|
captureTarget,
|
|
};
|
|
programMinimizedMovedRef.current = false;
|
|
captureTarget.setPointerCapture(event.pointerId);
|
|
}, []);
|
|
|
|
const handleRestoreProgram = useCallback((targetKey: string) => {
|
|
let restoredTarget: ShareProgramTarget | null = null;
|
|
|
|
setMinimizedPrograms((current) => current.filter((item) => {
|
|
if (item.target.key === targetKey) {
|
|
restoredTarget = item.target;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}));
|
|
|
|
if (restoredTarget) {
|
|
setProgramReloadKey(0);
|
|
setProgramTarget(restoredTarget);
|
|
}
|
|
}, []);
|
|
|
|
const handleReloadPage = useCallback(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
void forceReloadApp();
|
|
}, []);
|
|
|
|
const handleReloadProgram = useCallback(() => {
|
|
setProgramReloadKey((current) => current + 1);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setAccessPinInput('');
|
|
setAccessPinSubmitError('');
|
|
setRequiresAccessPin(false);
|
|
}, [normalizedToken]);
|
|
|
|
const handleCloseMinimizedProgram = useCallback((targetKey: string) => {
|
|
setMinimizedPrograms((current) => current.filter((item) => item.target.key !== targetKey));
|
|
}, []);
|
|
|
|
const minimizedProgramCards = minimizedPrograms.map((item, index) => (
|
|
<div
|
|
key={item.target.key}
|
|
ref={(element) => {
|
|
if (element) {
|
|
programMinimizedCardRefs.current.set(item.target.key, element);
|
|
return;
|
|
}
|
|
|
|
programMinimizedCardRefs.current.delete(item.target.key);
|
|
}}
|
|
className="chat-share-page__program-minimized"
|
|
style={{
|
|
transform: `translate3d(${item.position.x}px, ${item.position.y}px, 0)`,
|
|
zIndex: SHARE_PROGRAM_MINIMIZED_Z_INDEX + index,
|
|
}}
|
|
>
|
|
<div
|
|
className="chat-share-page__program-minimized-drag"
|
|
data-program-key={item.target.key}
|
|
onPointerDown={handleProgramMinimizedPointerDown}
|
|
>
|
|
<span className="chat-share-page__program-minimized-drag-grip" aria-hidden="true" />
|
|
<span className="chat-share-page__program-minimized-title">{item.target.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(item.target.key);
|
|
}}
|
|
>
|
|
열기
|
|
</Button>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__program-minimized-icon chat-share-page__program-minimized-close"
|
|
icon={<CloseOutlined />}
|
|
aria-label="프로그램 닫기"
|
|
onClick={() => {
|
|
handleCloseMinimizedProgram(item.target.key);
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
));
|
|
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 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]);
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const urlRoomSessionId = readShareRoomSessionIdFromLocation();
|
|
const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken);
|
|
const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, restoredRoomSessionId);
|
|
|
|
requestedRoomSessionIdRef.current = restoredRoomSessionId;
|
|
deferredSnapshotRef.current = null;
|
|
setSnapshot(cachedSnapshot);
|
|
setIsLoading(cachedSnapshot == null);
|
|
setIsRoomSwitching(false);
|
|
setRequestedRoomSessionId(restoredRoomSessionId);
|
|
}, [normalizedToken]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
|
|
const handlePopState = () => {
|
|
const nextRoomSessionId = readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken);
|
|
const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, nextRoomSessionId);
|
|
requestedRoomSessionIdRef.current = nextRoomSessionId;
|
|
deferredSnapshotRef.current = null;
|
|
if (cachedSnapshot) {
|
|
setSnapshot(cachedSnapshot);
|
|
}
|
|
setIsRoomSwitching(Boolean(nextRoomSessionId));
|
|
setRequestedRoomSessionId(nextRoomSessionId);
|
|
setIsShareRoomListVisible(false);
|
|
};
|
|
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => {
|
|
window.removeEventListener('popstate', handlePopState);
|
|
};
|
|
}, [normalizedToken]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedToken || !hasSnapshotRef.current) {
|
|
return;
|
|
}
|
|
|
|
void refreshSnapshot({ silent: true });
|
|
}, [normalizedToken, refreshSnapshot, requestedRoomSessionId]);
|
|
useEffect(() => {
|
|
if (!normalizedToken || !snapshot || snapshot.detailLevel === 'initial') {
|
|
return;
|
|
}
|
|
|
|
writeStoredShareRoomSnapshot(normalizedToken, snapshot);
|
|
}, [normalizedToken, snapshot]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedToken || requestedRoomSessionIdRef.current.trim()) {
|
|
return;
|
|
}
|
|
|
|
const stabilizedRoomSessionId = activeShareRoomSessionId.trim();
|
|
|
|
if (!stabilizedRoomSessionId || !shareRooms.some((room) => room.sessionId === stabilizedRoomSessionId)) {
|
|
return;
|
|
}
|
|
|
|
requestedRoomSessionIdRef.current = stabilizedRoomSessionId;
|
|
writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId);
|
|
writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace');
|
|
setRequestedRoomSessionId(stabilizedRoomSessionId);
|
|
}, [activeShareRoomSessionId, normalizedToken, shareRooms]);
|
|
|
|
useEffect(() => {
|
|
if (!requestedRoomSessionId) {
|
|
return;
|
|
}
|
|
|
|
if (shareRooms.some((room) => room.sessionId === requestedRoomSessionId)) {
|
|
return;
|
|
}
|
|
|
|
const fallbackRoomSessionId =
|
|
shareRooms.find((room) => room.sessionId === activeShareRoomSessionId)?.sessionId ?? '';
|
|
|
|
requestedRoomSessionIdRef.current = fallbackRoomSessionId;
|
|
writeStoredShareLastRoomSessionId(normalizedToken, fallbackRoomSessionId || null);
|
|
setRequestedRoomSessionId(fallbackRoomSessionId);
|
|
}, [activeShareRoomSessionId, normalizedToken, requestedRoomSessionId, shareRooms]);
|
|
useEffect(() => {
|
|
if (!normalizedToken) {
|
|
return;
|
|
}
|
|
|
|
const persistedRoomSessionId = selectedShareRoomSessionId.trim();
|
|
|
|
if (!persistedRoomSessionId) {
|
|
writeStoredShareLastRoomSessionId(normalizedToken, null);
|
|
return;
|
|
}
|
|
|
|
if (!shareRooms.some((room) => room.sessionId === persistedRoomSessionId)) {
|
|
return;
|
|
}
|
|
|
|
writeStoredShareLastRoomSessionId(normalizedToken, persistedRoomSessionId);
|
|
}, [normalizedToken, selectedShareRoomSessionId, shareRooms]);
|
|
useEffect(() => {
|
|
const roomSessionId = shareRooms.some((room) => room.sessionId === requestedRoomSessionId)
|
|
? requestedRoomSessionId
|
|
: activeShareRoomSessionId;
|
|
writeShareRoomSessionIdToLocation(roomSessionId || null, 'replace');
|
|
}, [activeShareRoomSessionId, requestedRoomSessionId, shareRooms]);
|
|
|
|
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 = selectedShareRoomSessionId;
|
|
|
|
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);
|
|
});
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
if (isDisposed || typeof event.data !== 'string') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(event.data) as ChatServerEvent;
|
|
|
|
if (payload.sessionId !== sessionId) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
payload.type === 'chat:init' ||
|
|
payload.type === 'chat:status' ||
|
|
payload.type === 'chat:runtime' ||
|
|
payload.type === 'chat:runtime:detail' ||
|
|
payload.type === 'notification:messages-updated'
|
|
) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// 공유 화면은 세부 이벤트를 직접 반영하지 않고 스냅샷만 다시 조회한다.
|
|
}
|
|
|
|
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, selectedShareRoomSessionId]);
|
|
|
|
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, {
|
|
sessionId: activeShareRoomSessionId,
|
|
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, {
|
|
sessionId: activeShareRoomSessionId,
|
|
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, {
|
|
sessionId: activeShareRoomSessionId,
|
|
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, {
|
|
sessionId: activeShareRoomSessionId,
|
|
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, {
|
|
sessionId: activeShareRoomSessionId,
|
|
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 handleSelectShareRoom = useCallback((sessionId: string) => {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
if (normalizedSessionId === selectedShareRoomSessionId) {
|
|
setIsShareRoomListVisible(false);
|
|
return;
|
|
}
|
|
|
|
const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, normalizedSessionId);
|
|
|
|
deferredSnapshotRef.current = null;
|
|
if (cachedSnapshot) {
|
|
setSnapshot(cachedSnapshot);
|
|
}
|
|
setIsRoomSwitching(true);
|
|
requestedRoomSessionIdRef.current = normalizedSessionId;
|
|
writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId);
|
|
writeShareRoomSessionIdToLocation(normalizedSessionId, 'push');
|
|
setRequestedRoomSessionId(normalizedSessionId);
|
|
setIsShareRoomListVisible(false);
|
|
}, [normalizedToken, selectedShareRoomSessionId]);
|
|
|
|
const handleCreateShareRoom = useCallback(async () => {
|
|
if (!normalizedToken || isCreatingRoom) {
|
|
return;
|
|
}
|
|
|
|
const nextChatType = enabledChatTypes.find((item) => item.id === creatingRoomChatTypeId) ?? null;
|
|
const normalizedTitle = creatingRoomTitle.trim();
|
|
const normalizedSeedMessage = creatingRoomSeedMessage.trim();
|
|
const linkedConversation = creatingRoomLinkedSessionId
|
|
? conversationCandidateBySessionId.get(creatingRoomLinkedSessionId) ?? null
|
|
: null;
|
|
|
|
if (!nextChatType) {
|
|
message.warning('새 방에 사용할 채팅유형을 먼저 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
if (!normalizedTitle) {
|
|
message.warning('새 채팅방 이름을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
if (!normalizedSeedMessage) {
|
|
message.warning('새 채팅방 시작 문구를 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
setIsCreatingRoom(true);
|
|
|
|
try {
|
|
const linkedConversationDetail = linkedConversation
|
|
? await fetchChatConversationDetail(linkedConversation.sessionId, { limit: 20 })
|
|
: null;
|
|
const linkedRequest = linkedConversationDetail?.requests[linkedConversationDetail.requests.length - 1] ?? null;
|
|
|
|
if (linkedConversation && !linkedRequest) {
|
|
message.warning('선택한 추천 세션에서 연결할 요청을 찾지 못했습니다.');
|
|
setIsCreatingRoom(false);
|
|
return;
|
|
}
|
|
|
|
const createdRoom = await createChatShareRoom(normalizedToken, {
|
|
chatTypeId: nextChatType.id,
|
|
chatTypeLabel: nextChatType.name,
|
|
title: normalizedTitle,
|
|
requestBadgeLabel: creatingRoomRequestBadgeLabel.trim() || null,
|
|
seedMessage: normalizedSeedMessage,
|
|
linkedSessionId: linkedConversation?.sessionId ?? null,
|
|
linkedRequestId: linkedRequest?.requestId ?? null,
|
|
linkedTitle: linkedConversation?.title ?? null,
|
|
linkedRequestPreview:
|
|
linkedRequest?.userText
|
|
|| linkedConversation?.lastRequestPreview
|
|
|| linkedConversation?.lastMessagePreview
|
|
|| null,
|
|
linkedChatTypeLabel: linkedRequest?.chatTypeLabel ?? linkedConversation?.contextLabel ?? null,
|
|
});
|
|
|
|
setIsCreateRoomOpen(false);
|
|
setOptimisticShareRooms((current) => (
|
|
current.some((room) => room.sessionId === createdRoom.sessionId)
|
|
? current
|
|
: [...current, createdRoom]
|
|
));
|
|
setIsRoomSwitching(true);
|
|
requestedRoomSessionIdRef.current = createdRoom.sessionId;
|
|
writeStoredShareLastRoomSessionId(normalizedToken, createdRoom.sessionId);
|
|
writeShareRoomSessionIdToLocation(createdRoom.sessionId, 'push');
|
|
setRequestedRoomSessionId(createdRoom.sessionId);
|
|
setDraftText('');
|
|
setComposerAttachments([]);
|
|
setReplyReferenceRequestId('');
|
|
setCreatingRoomLinkedSessionId('');
|
|
message.success('새 공유 채팅방을 추가했습니다.');
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '새 공유 채팅방을 추가하지 못했습니다.');
|
|
} finally {
|
|
setIsCreatingRoom(false);
|
|
}
|
|
}, [
|
|
creatingRoomChatTypeId,
|
|
creatingRoomLinkedSessionId,
|
|
creatingRoomRequestBadgeLabel,
|
|
creatingRoomSeedMessage,
|
|
creatingRoomTitle,
|
|
conversationCandidateBySessionId,
|
|
enabledChatTypes,
|
|
isCreatingRoom,
|
|
message,
|
|
normalizedToken,
|
|
]);
|
|
|
|
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, {
|
|
sessionId: selectedShareRoomSessionId,
|
|
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,
|
|
selectedShareRoomSessionId,
|
|
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,
|
|
);
|
|
}, []);
|
|
|
|
const restoreShareViewportAfterResume = useCallback(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
scheduleViewportRecoverySync();
|
|
|
|
window.requestAnimationFrame(() => {
|
|
syncComposerViewportCompactedState();
|
|
|
|
const activeElement = document.activeElement;
|
|
const resumedFocusTarget =
|
|
isMobileShareInputTarget(activeElement) && scrollContainerRef.current?.contains(activeElement)
|
|
? activeElement
|
|
: focusedMobileInputRef.current;
|
|
|
|
if (!resumedFocusTarget || !scrollContainerRef.current?.contains(resumedFocusTarget)) {
|
|
focusedMobileInputRef.current = null;
|
|
clearComposerViewportSyncTimers();
|
|
return;
|
|
}
|
|
|
|
focusedMobileInputRef.current = resumedFocusTarget;
|
|
scheduleFocusedInputViewportSync(resumedFocusTarget, 'auto', { includeRetryTimers: true });
|
|
});
|
|
}, [clearComposerViewportSyncTimers, scheduleFocusedInputViewportSync, syncComposerViewportCompactedState]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearComposerViewportSyncTimers();
|
|
};
|
|
}, [clearComposerViewportSyncTimers]);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return undefined;
|
|
}
|
|
|
|
const handlePageSuspend = () => {
|
|
clearComposerViewportSyncTimers();
|
|
setIsComposerViewportCompacted(false);
|
|
};
|
|
|
|
const handlePageResume = () => {
|
|
restoreShareViewportAfterResume();
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
handlePageSuspend();
|
|
return;
|
|
}
|
|
|
|
handlePageResume();
|
|
};
|
|
|
|
window.addEventListener('focus', handlePageResume);
|
|
window.addEventListener('pageshow', handlePageResume);
|
|
window.addEventListener('pagehide', handlePageSuspend);
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener('focus', handlePageResume);
|
|
window.removeEventListener('pageshow', handlePageResume);
|
|
window.removeEventListener('pagehide', handlePageSuspend);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, [clearComposerViewportSyncTimers, restoreShareViewportAfterResume]);
|
|
|
|
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 = selectedShareRoomSessionId;
|
|
|
|
if (!sessionId) {
|
|
throw new Error('공유 채팅 세션이 준비되지 않았습니다.');
|
|
}
|
|
|
|
return uploadChatShareComposerFile(normalizedToken, sessionId, file);
|
|
},
|
|
[normalizedToken, selectedShareRoomSessionId],
|
|
);
|
|
|
|
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 = selectedShareRoomSessionId;
|
|
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, sessionId);
|
|
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, selectedShareRoomSessionId, snapshot]);
|
|
const handleMoveToSourceSession = useCallback((group: ShareRoomSourceGroup) => {
|
|
if (typeof window === 'undefined' || !group.sourceSessionId) {
|
|
return;
|
|
}
|
|
|
|
window.open(`${buildChatPath('live')}?sessionId=${encodeURIComponent(group.sourceSessionId)}`, '_blank', 'noopener,noreferrer');
|
|
}, []);
|
|
const handleSubmitOriginReply = useCallback(async () => {
|
|
if (!normalizedToken || !originReplyTargetGroup?.sourceSessionId || !originReplyTargetGroup.sourceRequestId) {
|
|
return;
|
|
}
|
|
|
|
const outgoingText = originReplyDraftText.trim();
|
|
|
|
if (!outgoingText) {
|
|
message.warning('원 세션으로 보낼 답변 내용을 입력하세요.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmittingOriginReply(true);
|
|
|
|
try {
|
|
await submitChatShareOriginReply(normalizedToken, {
|
|
sessionId: selectedShareRoomSessionId,
|
|
sourceSessionId: originReplyTargetGroup.sourceSessionId,
|
|
sourceRequestId: originReplyTargetGroup.sourceRequestId,
|
|
text: outgoingText,
|
|
mode: 'queue',
|
|
});
|
|
setIsOriginReplyModalOpen(false);
|
|
setOriginReplyDraftText('');
|
|
setOriginReplyTargetGroupKey('');
|
|
message.success('원 세션에 답변 전송을 등록했습니다.');
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : '원 세션 답변 전송에 실패했습니다.');
|
|
} finally {
|
|
setIsSubmittingOriginReply(false);
|
|
}
|
|
}, [
|
|
message,
|
|
normalizedToken,
|
|
originReplyDraftText,
|
|
originReplyTargetGroup?.sourceRequestId,
|
|
originReplyTargetGroup?.sourceSessionId,
|
|
selectedShareRoomSessionId,
|
|
]);
|
|
|
|
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 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 shareRoomListFetchKey = useMemo(
|
|
() => shareRooms.map((room) => room.sessionId.trim()).filter(Boolean).join('|'),
|
|
[shareRooms],
|
|
);
|
|
const backgroundShareRoomSessionIds = useMemo(
|
|
() =>
|
|
shareRooms
|
|
.map((room) => room.sessionId.trim())
|
|
.filter((sessionId) => sessionId && sessionId !== selectedShareRoomSessionId.trim()),
|
|
[selectedShareRoomSessionId, shareRooms],
|
|
);
|
|
const visibleBackgroundShareRoomSessionIds = useMemo(
|
|
() => (isShareRoomListVisible ? backgroundShareRoomSessionIds : []),
|
|
[backgroundShareRoomSessionIds, isShareRoomListVisible],
|
|
);
|
|
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 creatingRoomLinkedConversation = useMemo(
|
|
() => (creatingRoomLinkedSessionId ? conversationCandidateBySessionId.get(creatingRoomLinkedSessionId) ?? null : null),
|
|
[conversationCandidateBySessionId, creatingRoomLinkedSessionId],
|
|
);
|
|
useEffect(() => {
|
|
if (!creatingRoomLinkedConversation) {
|
|
return;
|
|
}
|
|
|
|
setCreatingRoomTitle((current) => {
|
|
const normalizedCurrent = current.trim();
|
|
if (!normalizedCurrent || normalizedCurrent === '새 공유 채팅방' || normalizedCurrent.endsWith('작업방')) {
|
|
return buildLinkedRoomDraftTitle(creatingRoomLinkedConversation);
|
|
}
|
|
|
|
return current;
|
|
});
|
|
setCreatingRoomSeedMessage((current) => {
|
|
const normalizedCurrent = current.trim();
|
|
if (!normalizedCurrent || normalizedCurrent.startsWith('이 방에서 이어갈 작업 내용을')) {
|
|
return buildLinkedRoomDraftSeedMessage(creatingRoomLinkedConversation);
|
|
}
|
|
|
|
return current;
|
|
});
|
|
}, [creatingRoomLinkedConversation]);
|
|
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 previousQuestionModalTargetRequest = useMemo(
|
|
() => (previousQuestionModalRequestId.trim() ? requestById.get(previousQuestionModalRequestId.trim()) ?? null : null),
|
|
[previousQuestionModalRequestId, requestById],
|
|
);
|
|
const previousQuestionModalLineage = useMemo(
|
|
() => resolveShareRequestLineage(previousQuestionModalTargetRequest, requestById),
|
|
[previousQuestionModalTargetRequest, requestById],
|
|
);
|
|
const previousQuestionModalDirectParent = previousQuestionModalLineage.directParentRequest;
|
|
const previousQuestionModalTopParent =
|
|
previousQuestionModalLineage.topParentRequest
|
|
&& previousQuestionModalLineage.topParentRequest.requestId.trim() !== previousQuestionModalLineage.directParentRequest?.requestId.trim()
|
|
? previousQuestionModalLineage.topParentRequest
|
|
: null;
|
|
const previousQuestionModalDirectParentText = useMemo(
|
|
() => resolveShareRequestQuestionText(previousQuestionModalDirectParent),
|
|
[previousQuestionModalDirectParent],
|
|
);
|
|
const previousQuestionModalTopParentText = useMemo(
|
|
() => resolveShareRequestQuestionText(previousQuestionModalTopParent),
|
|
[previousQuestionModalTopParent],
|
|
);
|
|
const previousQuestionModalDirectParentPreviewItems = useMemo(
|
|
() => buildSharePreviewItemsFromText(previousQuestionModalDirectParent?.userText ?? '', normalizedToken),
|
|
[normalizedToken, previousQuestionModalDirectParent?.userText],
|
|
);
|
|
const previousQuestionModalTopParentPreviewItems = useMemo(
|
|
() => buildSharePreviewItemsFromText(previousQuestionModalTopParent?.userText ?? '', normalizedToken),
|
|
[normalizedToken, previousQuestionModalTopParent?.userText],
|
|
);
|
|
const activityLogByRequestId = useMemo(
|
|
() => new Map((snapshot?.activityLogs ?? []).map((item) => [item.requestId.trim(), item])),
|
|
[snapshot?.activityLogs],
|
|
);
|
|
const activeProcessInspectorRequest = useMemo(
|
|
() => (activeProcessInspectorRequestId.trim() ? requestById.get(activeProcessInspectorRequestId.trim()) ?? null : null),
|
|
[activeProcessInspectorRequestId, requestById],
|
|
);
|
|
const isProcessInspectorSummaryCollapsed = processInspectorExpandedSection !== 'summary';
|
|
const isProcessInspectorNarrativesCollapsed = processInspectorExpandedSection !== 'narratives';
|
|
const activeProcessInspectorPayload = useMemo(() => {
|
|
if (!activeProcessInspectorRequest) {
|
|
return null;
|
|
}
|
|
|
|
const runtimeItem = shareRuntimeItemByRequestId.get(activeProcessInspectorRequest.requestId.trim()) ?? null;
|
|
const activityLines = activityLogByRequestId.get(activeProcessInspectorRequest.requestId.trim())?.lines ?? [];
|
|
return buildShareProcessInspectorPayload(activeProcessInspectorRequest, activityLines, nowMs, runtimeItem);
|
|
}, [activeProcessInspectorRequest, activityLogByRequestId, nowMs, shareRuntimeItemByRequestId]);
|
|
const hasActiveProcessInspector = activeProcessInspectorRequestId.trim().length > 0 && activeProcessInspectorPayload != null;
|
|
useEffect(() => {
|
|
if (!hasActiveProcessInspector) {
|
|
processInspectorDragStateRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const clampPosition = (position: { x: number; y: number }) => {
|
|
const cardWidth = processInspectorCardRef.current?.offsetWidth ?? SHARE_PROCESS_INSPECTOR_DEFAULT_WIDTH;
|
|
const cardHeight = processInspectorCardRef.current?.offsetHeight ?? SHARE_PROCESS_INSPECTOR_DEFAULT_HEIGHT;
|
|
const maxX = Math.max(
|
|
SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING,
|
|
window.innerWidth - cardWidth - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING,
|
|
);
|
|
const maxY = Math.max(
|
|
SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING,
|
|
window.innerHeight - cardHeight - SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING,
|
|
);
|
|
|
|
return {
|
|
x: clampProgramMinimizedValue(position.x, SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, maxX),
|
|
y: clampProgramMinimizedValue(position.y, SHARE_PROCESS_INSPECTOR_VIEWPORT_PADDING, maxY),
|
|
};
|
|
};
|
|
|
|
const syncPosition = (position: { x: number; y: number }) => {
|
|
const nextPosition = clampPosition(position);
|
|
processInspectorPositionRef.current = nextPosition;
|
|
setProcessInspectorPosition(nextPosition);
|
|
};
|
|
|
|
const handleResize = () => {
|
|
syncPosition(processInspectorPositionRef.current);
|
|
};
|
|
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
const dragState = processInspectorDragStateRef.current;
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = event.clientX - dragState.lastX;
|
|
const deltaY = event.clientY - dragState.lastY;
|
|
dragState.lastX = event.clientX;
|
|
dragState.lastY = event.clientY;
|
|
|
|
syncPosition({
|
|
x: processInspectorPositionRef.current.x + deltaX,
|
|
y: processInspectorPositionRef.current.y + deltaY,
|
|
});
|
|
};
|
|
|
|
const finishPointerDrag = (event: PointerEvent) => {
|
|
const dragState = processInspectorDragStateRef.current;
|
|
|
|
if (!dragState || dragState.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (dragState.captureTarget.hasPointerCapture(event.pointerId)) {
|
|
dragState.captureTarget.releasePointerCapture(event.pointerId);
|
|
}
|
|
|
|
processInspectorDragStateRef.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);
|
|
};
|
|
}, [activeProcessInspectorRequestId, hasActiveProcessInspector, processInspectorMode]);
|
|
const processInspectorCard = activeProcessInspectorPayload ? (
|
|
<div
|
|
ref={processInspectorCardRef}
|
|
className={`chat-share-page__process-inspector chat-share-page__process-inspector--${processInspectorMode}`}
|
|
style={{
|
|
transform:
|
|
processInspectorMode === 'fullscreen'
|
|
? 'translate3d(0, 0, 0)'
|
|
: `translate3d(${processInspectorPosition.x}px, ${processInspectorPosition.y}px, 0)`,
|
|
zIndex: SHARE_PROCESS_INSPECTOR_Z_INDEX,
|
|
}}
|
|
>
|
|
<div className="chat-share-page__process-inspector-drag" onPointerDown={handleProcessInspectorPointerDown}>
|
|
<div className="chat-share-page__process-inspector-drag-copy">
|
|
<span className="chat-share-page__process-inspector-drag-grip" aria-hidden="true" />
|
|
<div className="chat-share-page__process-inspector-drag-text">
|
|
<Text strong>상세 과정</Text>
|
|
<Text type="secondary" className="chat-share-page__process-inspector-request-id">
|
|
{activeProcessInspectorPayload.requestId}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-window-actions">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-window-button"
|
|
icon={<MinusOutlined />}
|
|
aria-label="최소화"
|
|
onClick={() => {
|
|
setProcessInspectorMode('minimized');
|
|
}}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-window-button"
|
|
icon={processInspectorMode === 'fullscreen' ? <AppstoreOutlined /> : <FullscreenOutlined />}
|
|
aria-label={processInspectorMode === 'fullscreen' ? '일반 크기' : '전체화면'}
|
|
onClick={() => {
|
|
setProcessInspectorMode((current) => (current === 'fullscreen' ? 'default' : 'fullscreen'));
|
|
}}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-window-button"
|
|
icon={<CloseOutlined />}
|
|
aria-label="상세 과정 닫기"
|
|
onClick={closeProcessInspector}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{processInspectorMode === 'minimized' ? (
|
|
<div className="chat-share-page__process-inspector-minimized">
|
|
<div className="chat-share-page__process-inspector-minimized-copy">
|
|
<div className="chat-share-page__process-inspector-minimized-head">
|
|
<Tag color={activeProcessInspectorPayload.statusTag.color}>{activeProcessInspectorPayload.statusTag.label}</Tag>
|
|
<Text className="chat-share-page__process-inspector-minimized-time">{activeProcessInspectorPayload.elapsedLabel}</Text>
|
|
</div>
|
|
<Text className="chat-share-page__process-inspector-minimized-log">
|
|
{activeProcessInspectorPayload.latestActivityLine || '활동 로그 대기 중'}
|
|
</Text>
|
|
</div>
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-minimized-button"
|
|
onClick={() => {
|
|
setProcessInspectorMode('default');
|
|
}}
|
|
>
|
|
열기
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="chat-share-page__process-inspector-body">
|
|
<div className="chat-share-page__process-inspector-summary">
|
|
<div className="chat-share-page__process-inspector-summary-head">
|
|
<div className="chat-share-page__process-inspector-summary-head-main">
|
|
<Tag color={activeProcessInspectorPayload.statusTag.color}>{activeProcessInspectorPayload.statusTag.label}</Tag>
|
|
<Text type="secondary">{`처리 시간 ${activeProcessInspectorPayload.elapsedLabel}`}</Text>
|
|
</div>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-summary-toggle"
|
|
icon={isProcessInspectorSummaryCollapsed ? <DownOutlined /> : <UpOutlined />}
|
|
onClick={handleToggleProcessInspectorSummary}
|
|
>
|
|
{isProcessInspectorSummaryCollapsed ? '요청 정보 보기' : '요청 정보 접기'}
|
|
</Button>
|
|
</div>
|
|
{isProcessInspectorSummaryCollapsed ? null : (
|
|
<div className="chat-share-page__process-inspector-summary-table" role="table" aria-label="상세 과정 요약">
|
|
<div className="chat-share-page__process-inspector-table-row" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">요청</Text>
|
|
<Text strong className="chat-share-page__process-inspector-table-value">{activeProcessInspectorPayload.summary}</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-table-row" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">ID</Text>
|
|
<Text className="chat-share-page__process-inspector-table-value chat-share-page__process-inspector-table-value--mono">
|
|
{activeProcessInspectorPayload.requestId}
|
|
</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-table-row" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">시작</Text>
|
|
<Text className="chat-share-page__process-inspector-table-value">{activeProcessInspectorPayload.startedAtLabel}</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-table-row" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">업데이트</Text>
|
|
<Text className="chat-share-page__process-inspector-table-value">{activeProcessInspectorPayload.updatedAtLabel}</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-table-row" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">최근 로그</Text>
|
|
<Text className="chat-share-page__process-inspector-table-value">{activeProcessInspectorPayload.latestActivityLine || '아직 활동 로그가 없습니다.'}</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-sections">
|
|
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--checklist">
|
|
<div className="chat-share-page__process-inspector-section-head">
|
|
<Text strong>계획 체크리스트</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-checklist" role="table" aria-label="계획 체크리스트">
|
|
{activeProcessInspectorPayload.checklist.map((step) => (
|
|
<div key={step.key} className="chat-share-page__process-inspector-check-item" role="row">
|
|
<Text strong className="chat-share-page__process-inspector-check-title">{step.label}</Text>
|
|
<Tag
|
|
color={
|
|
step.status === 'completed'
|
|
? 'success'
|
|
: step.status === 'in_progress'
|
|
? 'processing'
|
|
: 'default'
|
|
}
|
|
>
|
|
{step.status === 'completed' ? '완료' : step.status === 'in_progress' ? '진행중' : '대기'}
|
|
</Tag>
|
|
<Text type="secondary" className="chat-share-page__process-inspector-check-note">{step.note}</Text>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--narratives">
|
|
<div className="chat-share-page__process-inspector-section-head">
|
|
<Text strong>추가 실행 설명</Text>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__process-inspector-summary-toggle"
|
|
icon={isProcessInspectorNarrativesCollapsed ? <DownOutlined /> : <UpOutlined />}
|
|
onClick={handleToggleProcessInspectorNarratives}
|
|
>
|
|
{isProcessInspectorNarrativesCollapsed ? '보기' : '접기'}
|
|
</Button>
|
|
</div>
|
|
{isProcessInspectorNarrativesCollapsed ? null : (
|
|
<div className="chat-share-page__process-inspector-narratives" role="table" aria-label="추가 실행 설명">
|
|
{activeProcessInspectorPayload.narratives.map((item, index) => (
|
|
<div key={item} className="chat-share-page__process-inspector-narrative" role="row">
|
|
<Text type="secondary" className="chat-share-page__process-inspector-table-label">{String(index + 1).padStart(2, '0')}</Text>
|
|
<Text className="chat-share-page__process-inspector-table-value">{item}</Text>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
<section className="chat-share-page__process-inspector-section chat-share-page__process-inspector-section--log">
|
|
<div className="chat-share-page__process-inspector-section-head">
|
|
<Text strong>활동 로그</Text>
|
|
<Text type="secondary">{activeProcessInspectorPayload.activityLines.length}줄</Text>
|
|
</div>
|
|
<div className="chat-share-page__process-inspector-log" role="table" aria-label="활동 로그">
|
|
{activeProcessInspectorPayload.activityLines.length > 0 ? (
|
|
activeProcessInspectorPayload.activityLines.map((line, index) => (
|
|
<div key={`${activeProcessInspectorPayload.requestId}:${index}`} className="chat-share-page__process-inspector-log-line" role="row">
|
|
<span className="chat-share-page__process-inspector-log-index">{String(index + 1).padStart(2, '0')}</span>
|
|
<span className="chat-share-page__process-inspector-log-text">{line}</span>
|
|
</div>
|
|
))
|
|
) : (
|
|
<Text type="secondary">활동 로그가 아직 기록되지 않았습니다.</Text>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
const shareChatTypeLabel = useMemo(() => {
|
|
const candidates = [
|
|
currentRequest?.chatTypeLabel,
|
|
snapshot?.targetRequest?.chatTypeLabel,
|
|
...sortedRequests.map((request) => request.chatTypeLabel),
|
|
];
|
|
|
|
return candidates.find((value) => value?.trim())?.trim() || 'Codex Live';
|
|
}, [currentRequest?.chatTypeLabel, snapshot?.targetRequest?.chatTypeLabel, sortedRequests]);
|
|
const shareMenuLabel = useMemo(() => {
|
|
const candidates = [
|
|
snapshot?.conversation.requestBadgeLabel,
|
|
snapshot?.conversation.title,
|
|
headerInquiryRequest?.chatTypeLabel,
|
|
currentRequest?.chatTypeLabel,
|
|
];
|
|
|
|
return candidates.find((value) => value?.trim())?.trim() || '';
|
|
}, [currentRequest?.chatTypeLabel, headerInquiryRequest?.chatTypeLabel, snapshot?.conversation.requestBadgeLabel, snapshot?.conversation.title]);
|
|
const collapsedActivitySummary = useMemo(() => {
|
|
if (expandMode !== 'latest' || !currentRequest || !isRequestProcessing(currentRequest.status)) {
|
|
return [] as string[];
|
|
}
|
|
|
|
const activityLog = activityLogByRequestId.get(currentRequest.requestId.trim());
|
|
return summarizeActivityLogLines(activityLog?.lines ?? []);
|
|
}, [activityLogByRequestId, currentRequest, expandMode]);
|
|
const headerSummaryLabel = useMemo(
|
|
() =>
|
|
`처리 시간 ${aggregateStatusTag?.elapsedLabel || '-'} · 처리중 ${pendingProcessingCount}건 · 미확인 ${pendingUnansweredCount}건`,
|
|
[aggregateStatusTag?.elapsedLabel, pendingProcessingCount, pendingUnansweredCount],
|
|
);
|
|
|
|
useEffect(() => {
|
|
const sessionId = selectedShareRoomSessionId.trim();
|
|
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
setShareRoomPendingCountsBySessionId((current) => {
|
|
const previousCounts = current[sessionId];
|
|
|
|
if (
|
|
previousCounts?.processingCount === pendingProcessingCount
|
|
&& previousCounts?.unansweredCount === pendingUnansweredCount
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
[sessionId]: {
|
|
processingCount: pendingProcessingCount,
|
|
unansweredCount: pendingUnansweredCount,
|
|
},
|
|
};
|
|
});
|
|
}, [pendingProcessingCount, pendingUnansweredCount, selectedShareRoomSessionId]);
|
|
|
|
const refreshShareRoomPendingCount = useCallback(async (sessionId: string) => {
|
|
const normalizedSessionId = sessionId.trim();
|
|
|
|
if (!normalizedToken || !normalizedSessionId) {
|
|
return;
|
|
}
|
|
|
|
const inFlightTask = shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId];
|
|
|
|
if (inFlightTask) {
|
|
shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = true;
|
|
await inFlightTask;
|
|
return;
|
|
}
|
|
|
|
const sharePin = getStoredChatShareAccessPin(normalizedToken) || undefined;
|
|
const refreshTask = (async () => {
|
|
try {
|
|
const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, {
|
|
sessionId: normalizedSessionId,
|
|
sharePin,
|
|
});
|
|
|
|
setShareRoomPendingCountsBySessionId((current) => {
|
|
const nextCounts = resolveShareRoomPendingCounts(roomSnapshot);
|
|
const previousCounts = current[normalizedSessionId];
|
|
|
|
if (
|
|
previousCounts?.processingCount === nextCounts.processingCount
|
|
&& previousCounts?.unansweredCount === nextCounts.unansweredCount
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
[normalizedSessionId]: nextCounts,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('failed to refresh share room pending counts', normalizedSessionId, error);
|
|
} finally {
|
|
shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = null;
|
|
|
|
if (shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId]) {
|
|
shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = false;
|
|
window.setTimeout(() => {
|
|
void refreshShareRoomPendingCount(normalizedSessionId);
|
|
}, 0);
|
|
}
|
|
}
|
|
})();
|
|
|
|
shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = refreshTask;
|
|
await refreshTask;
|
|
}, [normalizedToken]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedToken || !shareRoomListFetchKey) {
|
|
return;
|
|
}
|
|
|
|
const targetRoomSessionIds = visibleBackgroundShareRoomSessionIds;
|
|
|
|
if (targetRoomSessionIds.length === 0) {
|
|
setIsLoadingShareRoomPendingCounts(false);
|
|
return;
|
|
}
|
|
|
|
const fetchSequence = shareRoomPendingCountFetchSequenceRef.current + 1;
|
|
shareRoomPendingCountFetchSequenceRef.current = fetchSequence;
|
|
let cancelled = false;
|
|
setIsLoadingShareRoomPendingCounts(true);
|
|
|
|
void Promise.allSettled(
|
|
targetRoomSessionIds.map(async (sessionId) => {
|
|
await refreshShareRoomPendingCount(sessionId);
|
|
}),
|
|
)
|
|
.finally(() => {
|
|
if (cancelled || shareRoomPendingCountFetchSequenceRef.current !== fetchSequence) {
|
|
return;
|
|
}
|
|
|
|
setIsLoadingShareRoomPendingCounts(false);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [normalizedToken, refreshShareRoomPendingCount, shareRoomListFetchKey, visibleBackgroundShareRoomSessionIds]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedToken || requiresAccessPin || typeof window === 'undefined' || visibleBackgroundShareRoomSessionIds.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
let isDisposed = false;
|
|
const sockets = new Map<string, WebSocket>();
|
|
const reconnectTimerIds = new Map<string, number>();
|
|
const refreshTimerIds = new Map<string, number>();
|
|
|
|
const clearRefreshTimer = (sessionId: string) => {
|
|
const timerId = refreshTimerIds.get(sessionId);
|
|
if (timerId != null) {
|
|
window.clearTimeout(timerId);
|
|
refreshTimerIds.delete(sessionId);
|
|
}
|
|
};
|
|
|
|
const scheduleRoomCountRefresh = (sessionId: string) => {
|
|
if (isDisposed || refreshTimerIds.has(sessionId)) {
|
|
return;
|
|
}
|
|
|
|
const timerId = window.setTimeout(() => {
|
|
refreshTimerIds.delete(sessionId);
|
|
void refreshShareRoomPendingCount(sessionId);
|
|
}, 150);
|
|
|
|
refreshTimerIds.set(sessionId, timerId);
|
|
};
|
|
|
|
const connectRoomSocket = (sessionId: string) => {
|
|
if (isDisposed) {
|
|
return;
|
|
}
|
|
|
|
const websocketUrl = resolveChatWebSocketUrl(sessionId, undefined, undefined, normalizedToken);
|
|
|
|
if (!websocketUrl) {
|
|
return;
|
|
}
|
|
|
|
const socket = new WebSocket(websocketUrl);
|
|
sockets.set(sessionId, socket);
|
|
|
|
socket.addEventListener('message', (event) => {
|
|
if (isDisposed || typeof event.data !== 'string') {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(event.data) as ChatServerEvent;
|
|
|
|
if (payload.sessionId !== sessionId) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
payload.type === 'chat:init'
|
|
|| payload.type === 'chat:status'
|
|
|| payload.type === 'chat:runtime'
|
|
|| payload.type === 'chat:runtime:detail'
|
|
|| payload.type === 'notification:messages-updated'
|
|
) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// payload 해석 실패 시에도 해당 방 건수만 다시 확인한다.
|
|
}
|
|
|
|
scheduleRoomCountRefresh(sessionId);
|
|
});
|
|
|
|
socket.addEventListener('error', () => {
|
|
if (isDisposed) {
|
|
return;
|
|
}
|
|
|
|
socket.close();
|
|
});
|
|
|
|
socket.addEventListener('close', () => {
|
|
sockets.delete(sessionId);
|
|
clearRefreshTimer(sessionId);
|
|
|
|
if (isDisposed) {
|
|
return;
|
|
}
|
|
|
|
const reconnectTimerId = window.setTimeout(() => {
|
|
reconnectTimerIds.delete(sessionId);
|
|
connectRoomSocket(sessionId);
|
|
}, 1500);
|
|
|
|
reconnectTimerIds.set(sessionId, reconnectTimerId);
|
|
});
|
|
};
|
|
|
|
visibleBackgroundShareRoomSessionIds.forEach((sessionId) => {
|
|
connectRoomSocket(sessionId);
|
|
});
|
|
|
|
return () => {
|
|
isDisposed = true;
|
|
|
|
reconnectTimerIds.forEach((timerId) => {
|
|
window.clearTimeout(timerId);
|
|
});
|
|
refreshTimerIds.forEach((timerId) => {
|
|
window.clearTimeout(timerId);
|
|
});
|
|
sockets.forEach((socket) => {
|
|
socket.close();
|
|
});
|
|
};
|
|
}, [normalizedToken, refreshShareRoomPendingCount, requiresAccessPin, visibleBackgroundShareRoomSessionIds]);
|
|
|
|
useEffect(() => {
|
|
if (!activeProcessInspectorRequestId.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (activeProcessInspectorRequest) {
|
|
return;
|
|
}
|
|
|
|
setActiveProcessInspectorRequestId('');
|
|
}, [activeProcessInspectorRequest, activeProcessInspectorRequestId]);
|
|
|
|
useEffect(() => {
|
|
if (!replyReferenceRequestId.trim()) {
|
|
return;
|
|
}
|
|
|
|
if (requestById.has(replyReferenceRequestId.trim())) {
|
|
return;
|
|
}
|
|
|
|
setReplyReferenceRequestId('');
|
|
}, [replyReferenceRequestId, requestById]);
|
|
|
|
useEffect(() => {
|
|
if (sortedRequests.length === 0) {
|
|
if (latestRequestId) {
|
|
setLatestRequestId('');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextLatestRequest = sortedRequests[sortedRequests.length - 1];
|
|
if (!nextLatestRequest) {
|
|
return;
|
|
}
|
|
|
|
if (latestRequestId && sortedRequests.some((request) => request.requestId === latestRequestId)) {
|
|
return;
|
|
}
|
|
|
|
if (latestRequestId !== nextLatestRequest.requestId) {
|
|
setLatestRequestId(nextLatestRequest.requestId);
|
|
}
|
|
}, [latestRequestId, sortedRequests]);
|
|
|
|
const handleMoveToPreviousRequest = () => {
|
|
if (!canMoveToPreviousRequest) {
|
|
return;
|
|
}
|
|
|
|
const previousRequest = sortedRequests[latestRequestIndex - 1];
|
|
if (previousRequest) {
|
|
setLatestRequestId(previousRequest.requestId);
|
|
}
|
|
};
|
|
|
|
const handleMoveToNextRequest = () => {
|
|
if (!canMoveToNextRequest) {
|
|
return;
|
|
}
|
|
|
|
const nextRequest = sortedRequests[latestRequestIndex + 1];
|
|
if (nextRequest) {
|
|
setLatestRequestId(nextRequest.requestId);
|
|
}
|
|
};
|
|
const hasActiveProcessingRequest = useMemo(
|
|
() => displayedRequests.some((request) => isRequestProcessing(request.status)),
|
|
[displayedRequests],
|
|
);
|
|
const contentLayoutClassName = canSendMessage
|
|
? 'chat-share-page__content-layout chat-share-page__content-layout--with-composer'
|
|
: 'chat-share-page__content-layout';
|
|
const canToggleShareRoomList = shareRooms.length > 1 || canCreateSharedRooms;
|
|
const captureProgramRestoreSnapshot = useCallback(
|
|
(): ShareProgramRestoreSnapshot => ({
|
|
roomSessionId: selectedShareRoomSessionId.trim(),
|
|
latestRequestId: latestRequestId.trim(),
|
|
expandMode,
|
|
scrollTop: Math.max(0, scrollContainerRef.current?.scrollTop ?? 0),
|
|
}),
|
|
[expandMode, latestRequestId, selectedShareRoomSessionId],
|
|
);
|
|
const restoreProgramReturnSnapshot = useCallback((restoreSnapshot?: ShareProgramRestoreSnapshot | null) => {
|
|
if (typeof window === 'undefined' || !restoreSnapshot) {
|
|
return;
|
|
}
|
|
|
|
const normalizedRoomSessionId = restoreSnapshot.roomSessionId.trim();
|
|
|
|
if (normalizedRoomSessionId && normalizedRoomSessionId !== selectedShareRoomSessionId) {
|
|
setIsRoomSwitching(true);
|
|
requestedRoomSessionIdRef.current = normalizedRoomSessionId;
|
|
writeStoredShareLastRoomSessionId(normalizedToken, normalizedRoomSessionId);
|
|
writeShareRoomSessionIdToLocation(normalizedRoomSessionId, 'replace');
|
|
setRequestedRoomSessionId(normalizedRoomSessionId);
|
|
}
|
|
|
|
setExpandMode(restoreSnapshot.expandMode);
|
|
setLatestRequestId(restoreSnapshot.latestRequestId.trim());
|
|
setIsSearchOpen(false);
|
|
setIsShareRoomListVisible(false);
|
|
|
|
const applyScrollPosition = () => {
|
|
const scrollContainer = scrollContainerRef.current;
|
|
|
|
if (!scrollContainer) {
|
|
return;
|
|
}
|
|
|
|
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
|
|
const nextScrollTop = Math.min(Math.max(0, restoreSnapshot.scrollTop), maxScrollTop);
|
|
|
|
scrollContainer.scrollTo({
|
|
top: nextScrollTop,
|
|
behavior: 'auto',
|
|
});
|
|
lastScrollTopRef.current = nextScrollTop;
|
|
queueScrollJumpVisibilitySync();
|
|
};
|
|
|
|
window.requestAnimationFrame(() => {
|
|
applyScrollPosition();
|
|
window.setTimeout(applyScrollPosition, 80);
|
|
});
|
|
}, [normalizedToken, queueScrollJumpVisibilitySync, selectedShareRoomSessionId]);
|
|
const handleCloseProgram = useCallback(() => {
|
|
restoreProgramReturnSnapshot(programTarget?.restoreSnapshot);
|
|
setProgramTarget(null);
|
|
}, [programTarget?.restoreSnapshot, restoreProgramReturnSnapshot]);
|
|
const canLaunchShareProgram = useCallback(
|
|
(appId?: ShareProgramTarget['appId']) => {
|
|
if (!appId) {
|
|
return true;
|
|
}
|
|
|
|
if (appId === SHARE_CURRENT_CHAT_APP_ID) {
|
|
return true;
|
|
}
|
|
|
|
return shareAllowedAppIdSet.has(appId);
|
|
},
|
|
[shareAllowedAppIdSet],
|
|
);
|
|
const recordShareAppLaunch = useCallback((appId?: string) => {
|
|
if (!appId) {
|
|
return;
|
|
}
|
|
|
|
setAppLaunchUsage((current) => {
|
|
const nextRecord = {
|
|
count: (current[appId]?.count ?? 0) + 1,
|
|
lastOpenedAt: Date.now(),
|
|
};
|
|
const nextValue = {
|
|
...current,
|
|
[appId]: nextRecord,
|
|
};
|
|
writeShareAppLaunchUsage(nextValue);
|
|
return nextValue;
|
|
});
|
|
}, []);
|
|
const openProgramTarget = useCallback((target: ShareProgramTarget) => {
|
|
if (!canLaunchShareProgram(target.appId)) {
|
|
message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.');
|
|
return;
|
|
}
|
|
|
|
recordShareAppLaunch(target.appId);
|
|
setProgramReloadKey(0);
|
|
setMinimizedPrograms((current) => current.filter((item) => item.target.key !== target.key));
|
|
setProgramTarget({
|
|
...target,
|
|
restoreSnapshot: target.restoreSnapshot ?? captureProgramRestoreSnapshot(),
|
|
});
|
|
}, [canLaunchShareProgram, captureProgramRestoreSnapshot, message, recordShareAppLaunch]);
|
|
const openAllowedPlayAppEnvironment = useCallback((entry: PlayAppEntry, environment: ShareAppEnvironment) => {
|
|
if (!shareAllowedAppIdSet.has(entry.id)) {
|
|
message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.');
|
|
return;
|
|
}
|
|
|
|
if (!isPlayAppSupportedInEnvironment(entry, environment)) {
|
|
const environmentLabel = SHARE_APP_ENVIRONMENT_OPTIONS.find((item) => item.key === environment)?.label ?? environment;
|
|
const supportedLabel = resolveSupportedEnvironmentSummary(entry);
|
|
message.warning(`${entry.name} 앱은 ${environmentLabel} 환경에서 아직 열 수 없습니다. 지원 환경: ${supportedLabel}`);
|
|
return;
|
|
}
|
|
|
|
setIsSearchOpen(false);
|
|
openProgramTarget(buildPlayAppEnvironmentTarget(entry.id, entry.name, environment));
|
|
}, [message, openProgramTarget, shareAllowedAppIdSet]);
|
|
const handleMinimizeProgram = useCallback(() => {
|
|
if (!programTarget) {
|
|
return;
|
|
}
|
|
|
|
setMinimizedPrograms((current) => {
|
|
const existingIndex = current.findIndex((item) => item.target.key === programTarget.key);
|
|
const rememberedPosition = minimizedProgramPositionByKeyRef.current[programTarget.key];
|
|
const nextPosition = existingIndex >= 0
|
|
? current[existingIndex].position
|
|
: rememberedPosition ?? getStackedProgramMinimizedPosition(current.length);
|
|
const nextItem: ShareMinimizedProgramItem = {
|
|
target: programTarget,
|
|
position: nextPosition,
|
|
};
|
|
minimizedProgramPositionByKeyRef.current[programTarget.key] = nextPosition;
|
|
|
|
if (existingIndex >= 0) {
|
|
return current.map((item, index) => (index === existingIndex ? nextItem : item));
|
|
}
|
|
|
|
return [...current, nextItem];
|
|
});
|
|
setProgramTarget(null);
|
|
restoreProgramReturnSnapshot(programTarget.restoreSnapshot);
|
|
}, [programTarget, restoreProgramReturnSnapshot]);
|
|
const handleSearchResultSelect = useCallback((result: ShareSearchResult) => {
|
|
if (result.appEntry) {
|
|
openAllowedPlayAppEnvironment(result.appEntry, selectedAppEnvironment);
|
|
return;
|
|
}
|
|
|
|
if (result.resource && !result.requestId && !result.scrollTarget) {
|
|
setIsSearchOpen(false);
|
|
openProgramTarget(result.resource);
|
|
return;
|
|
}
|
|
|
|
setIsSearchOpen(false);
|
|
|
|
if (result.requestId) {
|
|
setLatestRequestId(result.requestId);
|
|
setExpandMode('all');
|
|
}
|
|
|
|
const scrollTarget = result.scrollTarget;
|
|
|
|
if (!scrollTarget) {
|
|
return;
|
|
}
|
|
|
|
window.requestAnimationFrame(() => {
|
|
window.setTimeout(() => {
|
|
if (scrollTarget.type === 'request') {
|
|
if (scrollToShareAnchorElement(requestAnchorRefs.current.get(scrollTarget.value))) {
|
|
return;
|
|
}
|
|
} else if (scrollTarget.type === 'response') {
|
|
const messageId = Number(scrollTarget.value);
|
|
if (Number.isFinite(messageId) && scrollToShareAnchorElement(responseAnchorRefs.current.get(messageId))) {
|
|
return;
|
|
}
|
|
} else if (scrollTarget.type === 'prompt') {
|
|
if (scrollToShareAnchorElement(promptAnchorRefs.current.get(scrollTarget.value))) {
|
|
return;
|
|
}
|
|
} else if (scrollToShareAnchorElement(document.getElementById(scrollTarget.value))) {
|
|
return;
|
|
}
|
|
|
|
const selector =
|
|
scrollTarget.type === 'request'
|
|
? `#chat-share-request-${CSS.escape(scrollTarget.value)}`
|
|
: `#${CSS.escape(scrollTarget.value)}`;
|
|
document.querySelector<HTMLElement>(selector)?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: scrollTarget.type === 'activity' ? 'center' : 'start',
|
|
});
|
|
}, 80);
|
|
});
|
|
}, [openAllowedPlayAppEnvironment, openProgramTarget, selectedAppEnvironment]);
|
|
const closeProgramTarget = useCallback(() => {
|
|
setProgramTarget(null);
|
|
}, []);
|
|
const sharedServerCommandAccess = useMemo(
|
|
() => ({
|
|
shareToken: normalizedToken,
|
|
allowedKeys: ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const,
|
|
}),
|
|
[normalizedToken],
|
|
);
|
|
const shouldInlineProgramTarget = shouldRenderSharePlayAppInline(programTarget);
|
|
const embeddedPlayAppContent = shouldInlineProgramTarget && programTarget
|
|
? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken)
|
|
: null;
|
|
const isServerCommandDrawerOpen = programTarget?.appId === 'server-command';
|
|
const searchResults = useMemo<ShareSearchResult[]>(() => {
|
|
const keyword = normalizeSearchKeyword(searchKeyword);
|
|
const results: ShareSearchResult[] = [];
|
|
|
|
if (searchPanelMode === 'apps') {
|
|
const photoprismLauncher = buildPhotoPrismProgramTarget();
|
|
|
|
if (
|
|
matchesSearchKeyword(
|
|
keyword,
|
|
currentShareChatTarget.label,
|
|
currentShareChatTarget.appId,
|
|
'공유채팅',
|
|
'현재 토큰',
|
|
'현재 공유토큰 열기',
|
|
...APPS_LAUNCHER_SEARCH_TERMS,
|
|
)
|
|
) {
|
|
results.push({
|
|
key: `management-app:${SHARE_CURRENT_CHAT_APP_ID}`,
|
|
title: currentShareChatTarget.label,
|
|
description: `현재 공유토큰을 ${selectedAppEnvironment} 환경에서 다시 엽니다.`,
|
|
category: 'resource',
|
|
icon: <CommentOutlined />,
|
|
usageBadge: resolveShareAppUsageBadge(appLaunchUsage[SHARE_CURRENT_CHAT_APP_ID]),
|
|
resource: currentShareChatTarget,
|
|
});
|
|
}
|
|
|
|
sortedAllowedManagementApps.forEach((item) => {
|
|
if (!matchesSearchKeyword(keyword, item.value, item.label, item.description, ...APPS_LAUNCHER_SEARCH_TERMS)) {
|
|
return;
|
|
}
|
|
|
|
results.push({
|
|
key: `management-app:${item.value}`,
|
|
title: item.label,
|
|
description: item.description,
|
|
category: 'resource',
|
|
icon: item.icon,
|
|
usageBadge: resolveShareAppUsageBadge(appLaunchUsage[item.value]),
|
|
resource: buildShareManagementProgramTarget(item.value, item.label),
|
|
});
|
|
});
|
|
|
|
sortedAllowedPlayAppEntries.forEach((entry) => {
|
|
if (
|
|
!matchesSearchKeyword(
|
|
keyword,
|
|
entry.id,
|
|
entry.name,
|
|
entry.searchDescription,
|
|
...APPS_LAUNCHER_SEARCH_TERMS,
|
|
...(entry.searchKeywords ?? []),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
results.push({
|
|
key: `app:${entry.id}`,
|
|
title: entry.name,
|
|
description: resolveSupportedEnvironmentSummary(entry),
|
|
category: 'resource',
|
|
icon: entry.icon,
|
|
usageBadge: resolveShareAppUsageBadge(appLaunchUsage[entry.id]),
|
|
appEntry: entry,
|
|
resource:
|
|
entry.id === 'photoprism'
|
|
? photoprismLauncher
|
|
: buildPlayAppProgramTarget(entry.id, entry.name),
|
|
});
|
|
});
|
|
|
|
return results;
|
|
}
|
|
|
|
if (!keyword) {
|
|
return results;
|
|
}
|
|
|
|
sortedRequests.forEach((request) => {
|
|
const requestText = buildShareVisibleText(request.userText);
|
|
|
|
if (matchesSearchKeyword(keyword, requestText, request.statusMessage, request.requestId)) {
|
|
results.push({
|
|
key: `request:${request.requestId}`,
|
|
title: requestText || '질문',
|
|
description: `질문 · ${formatTimeLabel(request.createdAt)}`,
|
|
category: 'request',
|
|
requestId: request.requestId,
|
|
scrollTarget: {
|
|
type: 'request',
|
|
value: request.requestId,
|
|
},
|
|
});
|
|
}
|
|
|
|
buildSharePreviewItemsFromText(request.userText, normalizedToken).forEach((item) => {
|
|
if (!matchesSearchKeyword(keyword, item.label, item.url, item.kind)) {
|
|
return;
|
|
}
|
|
|
|
const scopedUrl = resolveShareScopedResourceUrl(item.url, normalizedToken);
|
|
results.push({
|
|
key: `request-resource:${request.requestId}:${item.id}`,
|
|
title: item.label,
|
|
description: `질문 리소스 · ${item.kind}`,
|
|
category: 'resource',
|
|
requestId: request.requestId,
|
|
resource: {
|
|
key: `request-resource:${request.requestId}:${item.id}`,
|
|
label: item.label,
|
|
url: scopedUrl,
|
|
kind: item.kind,
|
|
meta: `${item.kind} resource`,
|
|
},
|
|
scrollTarget: {
|
|
type: 'request',
|
|
value: request.requestId,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
sortedMessages.forEach((entry) => {
|
|
const payload = messageRenderPayloadById.get(entry.id);
|
|
const visibleText = buildVisibleMessageText(entry, payload);
|
|
const requestId = entry.clientRequestId?.trim() || snapshot?.rootRequestId?.trim() || '';
|
|
|
|
if (matchesSearchKeyword(keyword, visibleText, entry.author, requestId)) {
|
|
results.push({
|
|
key: `response:${entry.id}`,
|
|
title: visibleText || '응답',
|
|
description: `${entry.author === 'user' ? '질문 메시지' : '응답'} · ${formatTimeLabel(entry.timestamp)}`,
|
|
category: 'response',
|
|
requestId: requestId || undefined,
|
|
scrollTarget: {
|
|
type: 'response',
|
|
value: String(entry.id),
|
|
},
|
|
});
|
|
}
|
|
|
|
(payload?.promptParts ?? []).forEach((prompt, promptIndex) => {
|
|
if (!matchesSearchKeyword(keyword, buildSharePromptSearchText(prompt), requestId, entry.id)) {
|
|
return;
|
|
}
|
|
|
|
results.push({
|
|
key: `prompt:${entry.id}:${promptIndex}`,
|
|
title: prompt.title || 'prompt',
|
|
description: `prompt · ${formatTimeLabel(entry.timestamp)}`,
|
|
category: 'response',
|
|
requestId: requestId || undefined,
|
|
scrollTarget: {
|
|
type: 'prompt',
|
|
value: buildSharePromptAnchorKey(entry.id, promptIndex),
|
|
},
|
|
});
|
|
});
|
|
|
|
(payload?.previewItems ?? []).forEach((item) => {
|
|
if (!requestId || !matchesSearchKeyword(keyword, item.label, item.url, item.kind)) {
|
|
return;
|
|
}
|
|
|
|
const scopedUrl = resolveShareScopedResourceUrl(item.url, normalizedToken);
|
|
results.push({
|
|
key: `response-resource:${entry.id}:${item.id}`,
|
|
title: item.label,
|
|
description: `응답 리소스 · ${item.kind}`,
|
|
category: 'resource',
|
|
requestId,
|
|
resource: {
|
|
key: `response-resource:${entry.id}:${item.id}`,
|
|
label: item.label,
|
|
url: scopedUrl,
|
|
kind: item.kind,
|
|
meta: `${item.kind} resource`,
|
|
},
|
|
scrollTarget: {
|
|
type: 'response',
|
|
value: String(entry.id),
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
(snapshot?.activityLogs ?? []).forEach((activity, index) => {
|
|
const summary = summarizeActivityLogLines(activity.lines ?? []).join(' ');
|
|
|
|
if (!matchesSearchKeyword(keyword, activity.requestId, summary)) {
|
|
return;
|
|
}
|
|
|
|
results.push({
|
|
key: `activity:${activity.requestId}:${index}`,
|
|
title: summary || `활동 로그 ${index + 1}`,
|
|
description: `활동 로그 · ${activity.requestId}`,
|
|
category: 'activity',
|
|
requestId: activity.requestId,
|
|
scrollTarget: {
|
|
type: 'activity',
|
|
value: 'chat-share-activity-panel',
|
|
},
|
|
});
|
|
});
|
|
|
|
return results
|
|
.filter((item) => !item.scrollTarget || item.scrollTarget.value.trim())
|
|
.slice(0, 40);
|
|
}, [appLaunchUsage, currentShareChatTarget, messageRenderPayloadById, normalizedToken, searchKeyword, searchPanelMode, selectedAppEnvironment, snapshot?.activityLogs, snapshot?.rootRequestId, sortedAllowedManagementApps, sortedAllowedPlayAppEntries, sortedMessages, sortedRequests]);
|
|
const selectedTokenUsageSetting = shareTokenSetting;
|
|
const tokenUsageSummaryByPeriod = useMemo(
|
|
() =>
|
|
Object.fromEntries(
|
|
TOKEN_USAGE_PERIODS.map((period) => [
|
|
period.key,
|
|
resolveTokenUsageWindowSummary(
|
|
sortedRequests,
|
|
period.key,
|
|
nowMs,
|
|
resolveTokenUsageLimitForPeriod(selectedTokenUsageSetting, period.key),
|
|
),
|
|
]),
|
|
) as Record<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">서비스워커와 캐시를 정리한 뒤 현재 공유채팅방 화면을 다시 불러옵니다.</span>
|
|
</span>
|
|
),
|
|
icon: <ReloadOutlined />,
|
|
},
|
|
...(normalizedToken || 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 />,
|
|
},
|
|
]
|
|
: []),
|
|
...(canCreateSharedRooms
|
|
? [
|
|
{
|
|
key: 'conversation-room-create',
|
|
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: <PlusOutlined />,
|
|
},
|
|
]
|
|
: []),
|
|
...(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 || !selectedShareRoomSessionId,
|
|
},
|
|
],
|
|
[
|
|
allowedManagementApps.length,
|
|
allowedPlayAppEntries.length,
|
|
canSendMessage,
|
|
canOpenSharedRoomSettings,
|
|
canCreateSharedRooms,
|
|
hasWorkServerCommandApp,
|
|
isClearingConversation,
|
|
normalizedToken,
|
|
selectedTokenUsageSetting,
|
|
shareWorkServerCommand,
|
|
snapshot?.conversation.title,
|
|
selectedShareRoomSessionId,
|
|
tokenUsageFiveHourSummary.percentage,
|
|
tokenUsageOverview.currentAvailableLabel,
|
|
tokenUsageOverview.fiveHourCountdownLabel,
|
|
],
|
|
);
|
|
const handleShareHeaderSettingsClick = useCallback<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-room-create') {
|
|
openCreateRoomDialog();
|
|
return;
|
|
}
|
|
|
|
if (key === 'conversation-work-server-command') {
|
|
openProgramTarget(buildShareManagementProgramTarget('server-command', '서버관리'));
|
|
return;
|
|
}
|
|
|
|
if (key === 'conversation-clear') {
|
|
void handleClearConversation();
|
|
}
|
|
},
|
|
[handleClearConversation, handleReloadPage, openCreateRoomDialog, openProgramTarget, openSharedRoomSettings],
|
|
);
|
|
const shareExpandModeMenuItems = useMemo<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(selectedShareRoomSessionId)}
|
|
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(selectedShareRoomSessionId)}
|
|
onUploadAttachment={handleUploadPromptAttachment}
|
|
onSetResponseAnchor={setResponseAnchorRef}
|
|
onCopyMessage={handleCopyShareMessageText}
|
|
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(selectedShareRoomSessionId)}
|
|
onUploadAttachment={handleUploadPromptAttachment}
|
|
onSetRequestAnchor={setRequestAnchorRef}
|
|
onSetResponseAnchor={setResponseAnchorRef}
|
|
onSetPromptAnchor={setPromptAnchorRef}
|
|
onOpenProcessInspector={openProcessInspector}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className={contentLayoutClassName}>
|
|
<section className="chat-share-page__panel chat-share-page__conversation-panel">
|
|
<div ref={conversationHeaderRef} className="chat-share-page__section-head">
|
|
<div className="chat-share-page__section-copy">
|
|
<div className="chat-share-page__section-title-row">
|
|
<Title
|
|
level={5}
|
|
className="chat-share-page__conversation-title"
|
|
ellipsis={{ rows: 1, tooltip: headerTitleText.trim() || '채팅' }}
|
|
>
|
|
{headerTitleText.trim() || '채팅'}
|
|
</Title>
|
|
{canOpenSharedRoomSettings ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__section-action chat-share-page__section-action--tool chat-share-page__section-action--inline chat-share-page__section-action--title-edit"
|
|
aria-label="채팅방 이름 및 설정 편집"
|
|
title="채팅방 이름 및 설정 편집"
|
|
icon={<EditOutlined />}
|
|
onClick={() => {
|
|
openSharedRoomSettings();
|
|
}}
|
|
/>
|
|
) : null}
|
|
<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 ? (
|
|
<span
|
|
className={`chat-share-page__status-badge chat-share-page__status-badge--${aggregateStatusTag.color}`}
|
|
aria-label={aggregateStatusTag.label}
|
|
title={aggregateStatusTag.label}
|
|
>
|
|
<span className="chat-share-page__status-badge-label">{aggregateStatusTag.label}</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<Text type="secondary" className="chat-share-page__header-summary">
|
|
{headerSummaryLabel}
|
|
</Text>
|
|
</div>
|
|
<div className="chat-share-page__section-actions">
|
|
{canToggleShareRoomList ? (
|
|
<Button
|
|
ref={roomListTriggerButtonRef}
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__section-action chat-share-page__section-action--icon"
|
|
icon={isShareRoomListVisible ? <EyeInvisibleOutlined /> : <AppstoreOutlined />}
|
|
aria-label={isShareRoomListVisible ? '채팅방 목록 숨기기' : '채팅방 목록 보기'}
|
|
title={isShareRoomListVisible ? '채팅방 목록 숨기기' : '채팅방 목록 보기'}
|
|
onClick={() => {
|
|
setIsShareRoomListVisible((current) => !current);
|
|
}}
|
|
/>
|
|
) : null}
|
|
<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
|
|
ref={conversationToolbarRef}
|
|
className="chat-share-page__conversation-toolbar"
|
|
style={
|
|
{
|
|
'--chat-share-page-conversation-toolbar-top': `${conversationToolbarStickyTop}px`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<div className="chat-share-page__conversation-toolbar-group" aria-label="요청 이동 및 필터">
|
|
<div className="chat-share-page__request-nav" aria-label="요청 이동">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__section-action chat-share-page__section-action--tool chat-share-page__conversation-toolbar-button"
|
|
icon={<LeftOutlined />}
|
|
disabled={!canMoveToPreviousRequest}
|
|
onClick={handleMoveToPreviousRequest}
|
|
>
|
|
<span className="chat-share-page__tool-button-label">이전</span>
|
|
</Button>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__section-action chat-share-page__section-action--tool chat-share-page__conversation-toolbar-button"
|
|
icon={<RightOutlined />}
|
|
iconPosition="end"
|
|
disabled={!canMoveToNextRequest}
|
|
onClick={handleMoveToNextRequest}
|
|
>
|
|
<span className="chat-share-page__tool-button-label">다음</span>
|
|
</Button>
|
|
</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__conversation-toolbar-button 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>
|
|
</div>
|
|
{showRoomSwitchingSkeleton ? (
|
|
<div className="chat-share-page__conversation-loading-block" role="status" aria-live="polite">
|
|
<Spin size="large" />
|
|
<Text type="secondary">채팅방 내용을 불러오는 중입니다.</Text>
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`chat-share-page__message-list${showRoomSwitchingOverlay ? ' chat-share-page__message-list--switching' : ''}`}
|
|
aria-busy={showRoomSwitchingOverlay}
|
|
>
|
|
{showRoomSwitchingOverlay ? (
|
|
<div className="chat-share-page__panel-switching-indicator" role="status" aria-live="polite">
|
|
<Spin size="small" />
|
|
<Text type="secondary">{`${roomSwitchingStatusLabel} 불러오는 중`}</Text>
|
|
</div>
|
|
) : 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(selectedShareRoomSessionId)}
|
|
onUploadAttachment={handleUploadPromptAttachment}
|
|
onSetRequestAnchor={setRequestAnchorRef}
|
|
onSetResponseAnchor={setResponseAnchorRef}
|
|
onSetPromptAnchor={setPromptAnchorRef}
|
|
onOpenPreviousQuestion={(requestId) => {
|
|
setPreviousQuestionModalRequestId(requestId.trim());
|
|
}}
|
|
onCopyMessage={handleCopyShareMessageText}
|
|
onCancelActiveRequest={handleCancelActiveShareRequest}
|
|
isActiveRequestCancellationSaving={pendingShareRuntimeRequestIds.includes(request.requestId)}
|
|
onResubmitRequestDirect={handleResubmitQueuedRequestDirect}
|
|
isDirectResubmitSaving={pendingShareRuntimeRequestIds.includes(request.requestId) || isSending}
|
|
onOpenProcessInspector={openProcessInspector}
|
|
/>
|
|
))}
|
|
{expandMode === 'latest' && hiddenAfterCount > 0 ? (
|
|
<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${showRoomSwitchingOverlay ? ' chat-share-page__panel--switching' : ''}`}
|
|
aria-label="현재 진행 상황"
|
|
aria-busy={showRoomSwitchingOverlay}
|
|
>
|
|
{showRoomSwitchingOverlay ? (
|
|
<div className="chat-share-page__panel-switching-indicator chat-share-page__panel-switching-indicator--compact" role="status" aria-live="polite">
|
|
<Spin size="small" />
|
|
<Text type="secondary">새 방 상태 반영 중</Text>
|
|
</div>
|
|
) : null}
|
|
<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">
|
|
{showRoomSwitchingSkeleton ? (
|
|
<div className="chat-share-page__composer-loading-block" role="status" aria-live="polite">
|
|
<Spin size="large" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`chat-share-page__composer-shell app-chat-panel__composer${showRoomSwitchingOverlay ? ' chat-share-page__composer-shell--switching' : ''}`}
|
|
aria-busy={showRoomSwitchingOverlay}
|
|
>
|
|
{showRoomSwitchingOverlay ? (
|
|
<div className="chat-share-page__panel-switching-indicator chat-share-page__panel-switching-indicator--composer" role="status" aria-live="polite">
|
|
<Spin size="small" />
|
|
<Text type="secondary">전환이 끝나면 입력할 수 있습니다.</Text>
|
|
</div>
|
|
) : null}
|
|
<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>
|
|
{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(pendingDeleteRoom)}
|
|
title="공유 채팅방을 삭제할까요?"
|
|
okText="삭제"
|
|
cancelText="취소"
|
|
okButtonProps={{ danger: true }}
|
|
centered
|
|
onCancel={() => {
|
|
setPendingDeleteRoomSessionId('');
|
|
}}
|
|
onOk={async () => {
|
|
if (!pendingDeleteRoom) {
|
|
return;
|
|
}
|
|
|
|
await handleDeleteShareRoom(pendingDeleteRoom);
|
|
}}
|
|
>
|
|
<Text>
|
|
{pendingDeleteRoom
|
|
? `"${pendingDeleteRoom.title}" 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.`
|
|
: '선택한 공유 채팅방과 이 방의 요청·메시지 기록이 삭제됩니다.'}
|
|
</Text>
|
|
</Modal>
|
|
<Modal
|
|
open={Boolean(previousQuestionModalDirectParent || previousQuestionModalTopParent)}
|
|
title="부모 질의"
|
|
footer={null}
|
|
className="chat-share-page__previous-question-modal-dialog"
|
|
onCancel={() => {
|
|
setPreviousQuestionModalRequestId('');
|
|
}}
|
|
>
|
|
<div className="chat-share-page__previous-question-modal">
|
|
{previousQuestionModalDirectParent ? (
|
|
<div className="chat-share-page__previous-question-modal-section">
|
|
<div className="chat-share-page__previous-question-modal-head">
|
|
<Text strong>바로 상위 부모</Text>
|
|
<Text type="secondary">
|
|
{formatTimeLabel(previousQuestionModalDirectParent.createdAt) || '요청 시각 없음'}
|
|
</Text>
|
|
</div>
|
|
<Paragraph className="chat-share-page__previous-question-modal-text">
|
|
{previousQuestionModalDirectParentText || '부모 질의 내용을 찾지 못했습니다.'}
|
|
</Paragraph>
|
|
{previousQuestionModalDirectParentPreviewItems.length > 0 ? (
|
|
<div className="chat-share-page__resource-list">
|
|
{previousQuestionModalDirectParentPreviewItems.map((item) => (
|
|
<ShareResourcePreviewCard
|
|
key={`previous-question-direct-${item.id}`}
|
|
item={item}
|
|
shareToken={normalizedToken}
|
|
onOpenProgram={openProgramTarget}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{previousQuestionModalTopParent ? (
|
|
<div className="chat-share-page__previous-question-modal-section">
|
|
<div className="chat-share-page__previous-question-modal-head">
|
|
<Text strong>연결된 최상위 부모</Text>
|
|
<Text type="secondary">
|
|
{formatTimeLabel(previousQuestionModalTopParent.createdAt) || '요청 시각 없음'}
|
|
</Text>
|
|
</div>
|
|
<Paragraph className="chat-share-page__previous-question-modal-text">
|
|
{previousQuestionModalTopParentText || '최상위 부모 질의 내용을 찾지 못했습니다.'}
|
|
</Paragraph>
|
|
{previousQuestionModalTopParentPreviewItems.length > 0 ? (
|
|
<div className="chat-share-page__resource-list">
|
|
{previousQuestionModalTopParentPreviewItems.map((item) => (
|
|
<ShareResourcePreviewCard
|
|
key={`previous-question-top-${item.id}`}
|
|
item={item}
|
|
shareToken={normalizedToken}
|
|
onOpenProgram={openProgramTarget}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{!previousQuestionModalDirectParent && !previousQuestionModalTopParent ? (
|
|
<div className="chat-share-page__previous-question-modal-section">
|
|
<Paragraph className="chat-share-page__previous-question-modal-text">
|
|
부모 질의 내용을 찾지 못했습니다.
|
|
</Paragraph>
|
|
</div>
|
|
) : null}
|
|
</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>
|
|
),
|
|
},
|
|
{
|
|
key: 'runtime',
|
|
label: `처리중 세션${shareRuntimeRunningItems.length + shareRuntimeQueuedItems.length > 0 ? ` (${shareRuntimeRunningItems.length + shareRuntimeQueuedItems.length})` : ''}`,
|
|
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>
|
|
<div className="chat-share-page__room-settings-card chat-share-page__room-settings-card--status">
|
|
<div>
|
|
<Text strong>{shareRuntimeRunningItems.length}건</Text>
|
|
<Text type="secondary">실행중</Text>
|
|
</div>
|
|
<div>
|
|
<Text strong>{shareRuntimeQueuedItems.length}건</Text>
|
|
<Text type="secondary">대기중</Text>
|
|
</div>
|
|
<div>
|
|
<Text strong>{shareRuntimeRecentItems.length}건</Text>
|
|
<Text type="secondary">최근 종료</Text>
|
|
</div>
|
|
<div>
|
|
<Text strong>{formatShareRuntimeTimestamp(shareRuntimeSnapshot?.generatedAt)}</Text>
|
|
<Text type="secondary">마지막 확인</Text>
|
|
</div>
|
|
</div>
|
|
<div className="chat-share-page__room-settings-inline-actions">
|
|
<Button
|
|
size="small"
|
|
icon={<ReloadOutlined />}
|
|
loading={isShareRuntimeLoading}
|
|
onClick={() => {
|
|
void refreshShareRuntime();
|
|
}}
|
|
>
|
|
상태 새로고침
|
|
</Button>
|
|
</div>
|
|
{shareRuntimeRunningItems.length === 0 && shareRuntimeQueuedItems.length === 0 ? (
|
|
<Alert showIcon type="info" message="현재 이 방에서 처리 중이거나 대기 중인 요청이 없습니다." />
|
|
) : null}
|
|
{shareRuntimeRunningItems.length > 0 ? (
|
|
<div className="chat-share-page__room-settings-panel">
|
|
<div className="chat-share-page__room-settings-panel-head">
|
|
<Text strong>실행중</Text>
|
|
</div>
|
|
<div className="chat-share-page__room-settings-runtime-list">
|
|
{shareRuntimeRunningItems.map((item) => {
|
|
const statusTag = resolveShareRuntimeStatusTag(item);
|
|
const isPending = pendingShareRuntimeRequestIds.includes(item.requestId);
|
|
|
|
return (
|
|
<div key={item.requestId} className="chat-share-page__room-settings-runtime-card">
|
|
<div className="chat-share-page__room-settings-runtime-head">
|
|
<Tag color={statusTag.color}>{statusTag.label}</Tag>
|
|
<Text type="secondary">{formatElapsedDuration(item.startedAt ?? item.enqueuedAt, nowMs) || '-'}</Text>
|
|
</div>
|
|
<Text strong>{item.summary || '요약 정보 없음'}</Text>
|
|
<Text type="secondary">{`요청 ${item.requestId}`}</Text>
|
|
<Text type="secondary">{`시작 ${formatShareRuntimeTimestamp(item.startedAt ?? item.enqueuedAt)}`}</Text>
|
|
<div className="chat-share-page__room-settings-inline-actions">
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => {
|
|
openProcessInspector(item.requestId);
|
|
}}
|
|
>
|
|
상세 과정
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
danger
|
|
loading={isPending}
|
|
disabled={!canManageSharedRoomSettings}
|
|
onClick={() => handleCancelShareRuntimeRequest(item)}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{shareRuntimeQueuedItems.length > 0 ? (
|
|
<div className="chat-share-page__room-settings-panel">
|
|
<div className="chat-share-page__room-settings-panel-head">
|
|
<Text strong>대기중</Text>
|
|
</div>
|
|
<div className="chat-share-page__room-settings-runtime-list">
|
|
{shareRuntimeQueuedItems.map((item) => {
|
|
const statusTag = resolveShareRuntimeStatusTag(item);
|
|
const isPending = pendingShareRuntimeRequestIds.includes(item.requestId);
|
|
|
|
return (
|
|
<div key={item.requestId} className="chat-share-page__room-settings-runtime-card">
|
|
<div className="chat-share-page__room-settings-runtime-head">
|
|
<Tag color={statusTag.color}>{statusTag.label}</Tag>
|
|
<Text type="secondary">{formatElapsedDuration(item.enqueuedAt, nowMs) || '-'}</Text>
|
|
</div>
|
|
<Text strong>{item.summary || '요약 정보 없음'}</Text>
|
|
<Text type="secondary">{`요청 ${item.requestId}`}</Text>
|
|
<Text type="secondary">{`대기 시작 ${formatShareRuntimeTimestamp(item.enqueuedAt)}`}</Text>
|
|
<div className="chat-share-page__room-settings-inline-actions">
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={() => {
|
|
openProcessInspector(item.requestId);
|
|
}}
|
|
>
|
|
상세 과정
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
danger
|
|
loading={isPending}
|
|
disabled={!canManageSharedRoomSettings}
|
|
onClick={() => handleCancelShareRuntimeRequest(item)}
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{shareRuntimeRecentItems.length > 0 ? (
|
|
<div className="chat-share-page__room-settings-panel">
|
|
<div className="chat-share-page__room-settings-panel-head">
|
|
<Text strong>최근 종료</Text>
|
|
</div>
|
|
<div className="chat-share-page__room-settings-runtime-list">
|
|
{shareRuntimeRecentItems.map((item) => {
|
|
const terminalTag = resolveShareRuntimeTerminalTag(item.terminalStatus);
|
|
|
|
return (
|
|
<div key={`${item.requestId}-${item.lastUpdatedAt}`} className="chat-share-page__room-settings-runtime-card">
|
|
<div className="chat-share-page__room-settings-runtime-head">
|
|
<Tag color={terminalTag.color}>{terminalTag.label}</Tag>
|
|
<Text type="secondary">{formatShareRuntimeTimestamp(item.lastUpdatedAt)}</Text>
|
|
</div>
|
|
<Text strong>{item.summary || '요약 정보 없음'}</Text>
|
|
<Text type="secondary">{`요청 ${item.requestId}`}</Text>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{!canManageSharedRoomSettings ? (
|
|
<Alert showIcon type="info" message="현재 공유 링크는 실행 상태 조회만 가능하고 취소는 관리 권한이 있을 때만 허용됩니다." />
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</div>
|
|
</Drawer>
|
|
<Modal
|
|
open={isCreateRoomOpen}
|
|
title="공유 채팅방 추가"
|
|
okText="추가"
|
|
cancelText="취소"
|
|
confirmLoading={isCreatingRoom}
|
|
onOk={() => {
|
|
void handleCreateShareRoom();
|
|
}}
|
|
onCancel={() => {
|
|
if (isCreatingRoom) {
|
|
return;
|
|
}
|
|
setIsCreateRoomOpen(false);
|
|
}}
|
|
>
|
|
<div className="chat-share-page__create-room-form">
|
|
<label className="chat-share-page__create-room-field">
|
|
<span>추천 세션 연결</span>
|
|
<Select
|
|
allowClear
|
|
showSearch
|
|
loading={isLoadingConversationCandidates}
|
|
value={creatingRoomLinkedSessionId || undefined}
|
|
placeholder="기존 세션을 고르면 연결된 작업방으로 만듭니다."
|
|
optionFilterProp="label"
|
|
options={conversationCandidates.map((item) => ({
|
|
value: item.sessionId,
|
|
label: `${item.title || item.contextLabel || item.sessionId} · ${item.lastRequestPreview || item.lastMessagePreview || '미리보기 없음'}`,
|
|
}))}
|
|
onChange={(value) => {
|
|
setCreatingRoomLinkedSessionId(value ?? '');
|
|
}}
|
|
/>
|
|
{creatingRoomLinkedConversation ? (
|
|
<Text type="secondary" className="chat-share-page__create-room-hint">
|
|
{creatingRoomLinkedConversation.lastRequestPreview || creatingRoomLinkedConversation.lastMessagePreview || '원 세션 미리보기 없음'}
|
|
</Text>
|
|
) : (
|
|
<Text type="secondary" className="chat-share-page__create-room-hint">
|
|
선택하지 않으면 독립 공유채팅방으로 생성합니다.
|
|
</Text>
|
|
)}
|
|
</label>
|
|
<label className="chat-share-page__create-room-field">
|
|
<span>채팅방 이름</span>
|
|
<Input
|
|
value={creatingRoomTitle}
|
|
maxLength={200}
|
|
placeholder="예: 문구 검토 2번방"
|
|
onChange={(event) => setCreatingRoomTitle(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="chat-share-page__create-room-field">
|
|
<span>채팅유형</span>
|
|
<Select
|
|
value={creatingRoomChatTypeId ?? undefined}
|
|
options={enabledChatTypes.map((item) => ({
|
|
value: item.id,
|
|
label: item.name,
|
|
}))}
|
|
onChange={(value) => setCreatingRoomChatTypeId(value)}
|
|
/>
|
|
</label>
|
|
<label className="chat-share-page__create-room-field">
|
|
<span>요청 뱃지</span>
|
|
<Input
|
|
value={creatingRoomRequestBadgeLabel}
|
|
maxLength={120}
|
|
placeholder="선택 입력"
|
|
onChange={(event) => setCreatingRoomRequestBadgeLabel(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="chat-share-page__create-room-field">
|
|
<span>시작 문구</span>
|
|
<Input.TextArea
|
|
rows={5}
|
|
value={creatingRoomSeedMessage}
|
|
placeholder="이 방에 처음 남길 질문이나 안내를 입력하세요."
|
|
onChange={(event) => setCreatingRoomSeedMessage(event.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</Modal>
|
|
<Modal
|
|
open={Boolean(sourceGroupDetail)}
|
|
title="연결 세션 상세"
|
|
footer={null}
|
|
onCancel={() => setSourceGroupDetailKey('')}
|
|
>
|
|
{sourceGroupDetail ? (
|
|
<div className="chat-share-page__create-room-form">
|
|
<div className="chat-share-page__source-detail-card">
|
|
<Text strong>{sourceGroupDetail.title}</Text>
|
|
<Text type="secondary">{sourceGroupDetail.chatTypeLabel || '연결된 원 세션'}</Text>
|
|
<Paragraph className="chat-share-page__source-detail-preview">
|
|
{sourceGroupDetail.requestPreview || '등록된 원 요청 미리보기가 없습니다.'}
|
|
</Paragraph>
|
|
<div className="chat-share-page__room-group-actions">
|
|
<Button size="small" onClick={() => handleMoveToSourceSession(sourceGroupDetail)}>원 세션 이동</Button>
|
|
{sourceGroupDetail.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? (
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
onClick={() => {
|
|
setSourceGroupDetailKey('');
|
|
setOriginReplyTargetGroupKey(sourceGroupDetail.key);
|
|
setOriginReplyDraftText(buildShareVisibleText(lastResponseMessage.text));
|
|
setIsOriginReplyModalOpen(true);
|
|
}}
|
|
>
|
|
답변하기
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="chat-share-page__source-room-chip-list">
|
|
{sourceGroupDetail.rooms.map((room) => (
|
|
<Button
|
|
key={room.sessionId}
|
|
size="small"
|
|
className="chat-share-page__source-room-chip"
|
|
type={room.sessionId === selectedShareRoomSessionId ? 'primary' : 'default'}
|
|
onClick={() => {
|
|
setSourceGroupDetailKey('');
|
|
handleSelectShareRoom(room.sessionId);
|
|
}}
|
|
>
|
|
{room.title}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
<Modal
|
|
open={isOriginReplyModalOpen}
|
|
title="원 세션으로 답변 전송"
|
|
okText="전송"
|
|
cancelText="취소"
|
|
confirmLoading={isSubmittingOriginReply}
|
|
onOk={() => {
|
|
void handleSubmitOriginReply();
|
|
}}
|
|
onCancel={() => {
|
|
if (isSubmittingOriginReply) {
|
|
return;
|
|
}
|
|
setIsOriginReplyModalOpen(false);
|
|
setOriginReplyTargetGroupKey('');
|
|
}}
|
|
>
|
|
<div className="chat-share-page__create-room-form">
|
|
<Text type="secondary">
|
|
{originReplyTargetGroup?.title || '선택된 원 세션'} 기준으로 현재 작업 답변을 전달합니다.
|
|
</Text>
|
|
<Input.TextArea
|
|
rows={7}
|
|
value={originReplyDraftText}
|
|
placeholder="원 세션에 보낼 답변을 입력하세요."
|
|
onChange={(event) => setOriginReplyDraftText(event.target.value)}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
<Modal
|
|
open={isSearchOpen}
|
|
footer={null}
|
|
title={searchPanelMode === 'apps' ? '공유채팅방 Apps' : '공유채팅방 통합검색'}
|
|
className="chat-share-page__search-modal"
|
|
onCancel={() => setIsSearchOpen(false)}
|
|
>
|
|
<div className="chat-share-page__search-modal-body">
|
|
<Input
|
|
autoFocus
|
|
allowClear
|
|
size="large"
|
|
prefix={<SearchOutlined />}
|
|
placeholder={searchPanelMode === 'apps' ? '허용된 Apps 검색' : '질문, 답변, 리소스, 활동 로그 검색'}
|
|
value={searchKeyword}
|
|
onChange={(event) => setSearchKeyword(event.target.value)}
|
|
/>
|
|
<div className="chat-share-page__search-summary">
|
|
<Text type="secondary">
|
|
{searchKeyword.trim()
|
|
? `검색 결과 ${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} 실행`
|
|
: '환경 미지원'
|
|
: result.resource?.appId === SHARE_CURRENT_CHAT_APP_ID
|
|
? `${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>
|
|
<Drawer
|
|
open={isServerCommandDrawerOpen}
|
|
title={programTarget?.label ?? '서버관리'}
|
|
rootClassName="chat-share-page__server-command-drawer-shell"
|
|
className="chat-share-page__server-command-drawer"
|
|
placement={isServerCommandDrawerMobile ? 'bottom' : 'right'}
|
|
width={isServerCommandDrawerMobile ? undefined : '100vw'}
|
|
height={isServerCommandDrawerMobile ? '100dvh' : undefined}
|
|
maskClosable={false}
|
|
destroyOnHidden={false}
|
|
extra={(
|
|
<Button
|
|
type="text"
|
|
className="fullscreen-preview-modal__icon-button"
|
|
aria-label="서버관리 새로고침"
|
|
title="서버관리 새로고침"
|
|
icon={<ReloadOutlined />}
|
|
onClick={handleReloadProgram}
|
|
/>
|
|
)}
|
|
onClose={closeProgramTarget}
|
|
>
|
|
{isServerCommandDrawerOpen ? (
|
|
<div
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
className="chat-share-page__server-command-drawer-body"
|
|
>
|
|
<ServerCommandPage sharedAccess={sharedServerCommandAccess} />
|
|
</div>
|
|
) : null}
|
|
</Drawer>
|
|
<FullscreenPreviewModal
|
|
open={Boolean(programTarget) && !isServerCommandDrawerOpen}
|
|
title={programTarget?.label ?? '공유 프로그램'}
|
|
meta={programTarget?.meta ?? '공유 토큰 실행'}
|
|
actions={
|
|
programTarget ? (
|
|
<Button
|
|
type="text"
|
|
className="fullscreen-preview-modal__icon-button"
|
|
aria-label="앱 새로고침"
|
|
title="앱 새로고침"
|
|
icon={<ReloadOutlined />}
|
|
onClick={handleReloadProgram}
|
|
/>
|
|
) : null
|
|
}
|
|
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 key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
|
|
{embeddedPlayAppContent}
|
|
</div>
|
|
) : programTarget.appId && findReadyPlayAppEntryById(programTarget.appId) ? (
|
|
<iframe
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
title={programTarget.label}
|
|
src={programTarget.url}
|
|
className="app-chat-panel__preview-frame"
|
|
/>
|
|
) : programTarget.appId === SHARE_CURRENT_CHAT_APP_ID ? (
|
|
<iframe
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
title={programTarget.label}
|
|
src={programTarget.url}
|
|
className="app-chat-panel__preview-frame"
|
|
/>
|
|
) : programTarget.appId === 'text-memo-widget' ? (
|
|
<div
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
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
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
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
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
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
|
|
key={`${programTarget.key}:${programReloadKey}`}
|
|
className="chat-share-page__program-app-shell chat-share-page__program-app-shell--surface"
|
|
>
|
|
<SharedAppSettingsPage shareToken={normalizedToken} />
|
|
</div>
|
|
) : (
|
|
<div key={`${programTarget.key}:${programReloadKey}`} className="chat-share-page__program-app-shell">
|
|
<ChatPreviewBody
|
|
target={{
|
|
label: programTarget.label,
|
|
url: programTarget.url,
|
|
kind: programTarget.kind,
|
|
}}
|
|
previewText=""
|
|
isPreviewLoading={false}
|
|
previewError=""
|
|
renderHtmlAsFrame
|
|
fullscreen
|
|
/>
|
|
</div>
|
|
)
|
|
) : null}
|
|
</FullscreenPreviewModal>
|
|
{typeof document !== 'undefined' && minimizedProgramCards.length > 0
|
|
? createPortal(<>{minimizedProgramCards}</>, document.body)
|
|
: minimizedProgramCards}
|
|
{typeof document !== 'undefined' && processInspectorCard
|
|
? createPortal(processInspectorCard, document.body)
|
|
: processInspectorCard}
|
|
{typeof document !== 'undefined' && canToggleShareRoomList && isShareRoomListVisible && shareRoomListLayerStyle
|
|
? createPortal(
|
|
<section
|
|
ref={roomListPanelRef}
|
|
className="chat-share-page__panel chat-share-page__room-list-panel chat-share-page__room-list-panel--floating"
|
|
style={shareRoomListLayerStyle}
|
|
>
|
|
<Input
|
|
allowClear
|
|
value={shareRoomFilterKeyword}
|
|
onChange={(event) => {
|
|
setShareRoomFilterKeyword(event.target.value);
|
|
}}
|
|
className="chat-share-page__room-filter-input"
|
|
placeholder="채팅방 필터"
|
|
prefix={<SearchOutlined />}
|
|
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>
|
|
<Text type="secondary">
|
|
{activeShareRoom ? `${activeShareRoom.title} 사용 중` : '공유 토큰에 연결된 방 목록'}
|
|
</Text>
|
|
</div>
|
|
{canCreateSharedRooms ? (
|
|
<Button size="small" icon={<PlusOutlined />} onClick={openCreateRoomDialog}>
|
|
추가
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<div className="chat-share-page__room-list">
|
|
{filteredShareRoomGroups.map((group) => (
|
|
<div
|
|
key={group.key}
|
|
className={`chat-share-page__room-group${group.linkContext ? '' : ' chat-share-page__room-group--standalone'}`}
|
|
>
|
|
{group.linkContext ? (
|
|
<div className="chat-share-page__room-group-head">
|
|
<div className="chat-share-page__room-group-copy">
|
|
<span className="chat-share-page__room-group-title">{group.title}</span>
|
|
<span className="chat-share-page__room-group-meta">
|
|
{group.chatTypeLabel || '연결된 원 세션'}
|
|
</span>
|
|
{group.requestPreview ? (
|
|
<span className="chat-share-page__room-group-preview">{group.requestPreview}</span>
|
|
) : null}
|
|
</div>
|
|
<div className="chat-share-page__room-group-actions">
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__room-group-action"
|
|
onClick={() => setSourceGroupDetailKey(group.key)}
|
|
>
|
|
상세
|
|
</Button>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__room-group-action"
|
|
onClick={() => handleMoveToSourceSession(group)}
|
|
>
|
|
이동
|
|
</Button>
|
|
{group.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="chat-share-page__room-group-action"
|
|
onClick={() => {
|
|
setOriginReplyTargetGroupKey(group.key);
|
|
setOriginReplyDraftText(buildShareVisibleText(lastResponseMessage.text));
|
|
setIsOriginReplyModalOpen(true);
|
|
}}
|
|
>
|
|
답변하기
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="chat-share-page__room-group-list">
|
|
{group.rooms.map((room) => {
|
|
const isActive = room.sessionId === selectedShareRoomSessionId;
|
|
const canDeleteRoom = canDeleteShareRoom(room, shareRooms);
|
|
const isDeletingTarget = isDeletingRoom && pendingDeleteRoomSessionId === room.sessionId;
|
|
const pendingCounts = shareRoomPendingCountsBySessionId[room.sessionId] ?? null;
|
|
|
|
return (
|
|
<div
|
|
key={room.sessionId}
|
|
className={`chat-share-page__room-item${canDeleteRoom ? '' : ' is-delete-locked'}`}
|
|
>
|
|
{canDeleteRoom ? (
|
|
<button
|
|
type="button"
|
|
className="chat-share-page__room-delete-action"
|
|
aria-label={`${room.title} 채팅방 삭제`}
|
|
title={`${room.title} 채팅방 삭제`}
|
|
disabled={isDeletingTarget}
|
|
onClick={() => {
|
|
setIsShareRoomListVisible(true);
|
|
setPendingDeleteRoomSessionId(room.sessionId);
|
|
}}
|
|
>
|
|
<DeleteOutlined />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className={`chat-share-page__room-card${isActive ? ' chat-share-page__room-card--active' : ''}${
|
|
room.isDefault ? ' chat-share-page__room-card--default' : ''
|
|
}`}
|
|
onClick={() => {
|
|
handleSelectShareRoom(room.sessionId);
|
|
}}
|
|
>
|
|
<span className="chat-share-page__room-card-head">
|
|
<span className="chat-share-page__room-card-title">{room.title}</span>
|
|
{room.isDefault ? <Tag color="cyan">기본 채팅</Tag> : null}
|
|
</span>
|
|
<span className="chat-share-page__room-card-meta">
|
|
{room.isDefault
|
|
? (room.contextLabel?.trim() || room.requestBadgeLabel?.trim() || '삭제할 수 없는 기본 채팅방')
|
|
: (room.contextLabel?.trim() || room.requestBadgeLabel?.trim() || '공유 채팅방')}
|
|
</span>
|
|
<span className="chat-share-page__room-card-counts" aria-label="채팅방 처리 상태">
|
|
{pendingCounts ? (
|
|
<>
|
|
<span className="chat-share-page__room-card-count chat-share-page__room-card-count--processing">
|
|
처리중 {pendingCounts.processingCount}
|
|
</span>
|
|
<span className="chat-share-page__room-card-count chat-share-page__room-card-count--unanswered">
|
|
미확인 {pendingCounts.unansweredCount}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="chat-share-page__room-card-count chat-share-page__room-card-count--idle">
|
|
{isLoadingShareRoomPendingCounts ? '건수 확인 중' : '건수 준비 중'}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{!group.linkContext && group.requestPreview ? (
|
|
<span className="chat-share-page__room-card-preview">{group.requestPreview}</span>
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{filteredShareRoomGroups.length === 0 ? (
|
|
<div className="chat-share-page__room-list-empty">
|
|
<Text type="secondary">조건에 맞는 채팅방이 없습니다.</Text>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</>
|
|
);
|
|
}
|