8292 lines
298 KiB
TypeScript
8292 lines
298 KiB
TypeScript
import {
|
||
AppstoreOutlined,
|
||
ArrowLeftOutlined,
|
||
CheckOutlined,
|
||
CloseOutlined,
|
||
ControlOutlined,
|
||
CopyOutlined,
|
||
DeleteOutlined,
|
||
DisconnectOutlined,
|
||
DownloadOutlined,
|
||
DownOutlined,
|
||
ExclamationOutlined,
|
||
ExclamationCircleOutlined,
|
||
FilterOutlined,
|
||
FullscreenExitOutlined,
|
||
FullscreenOutlined,
|
||
LeftOutlined,
|
||
RightOutlined,
|
||
NodeIndexOutlined,
|
||
PaperClipOutlined,
|
||
PlusOutlined,
|
||
ProfileOutlined,
|
||
RedoOutlined,
|
||
SendOutlined,
|
||
ShareAltOutlined,
|
||
SettingOutlined,
|
||
MinusOutlined,
|
||
SyncOutlined,
|
||
ThunderboltOutlined,
|
||
UpOutlined,
|
||
} from '@ant-design/icons';
|
||
import { Alert, Button, Checkbox, Dropdown, Input, Modal, Segmented, Select, Spin, Tag, message } from 'antd';
|
||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||
import type { MenuProps } from 'antd';
|
||
import {
|
||
Fragment,
|
||
memo,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type CSSProperties,
|
||
type ChangeEvent,
|
||
type ClipboardEvent,
|
||
type ReactNode,
|
||
type RefObject,
|
||
type TouchEvent,
|
||
type UIEvent,
|
||
} from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { InlineImage } from '../../../components/common/InlineImage';
|
||
import { CodexDiffBlock, parseCodexDiffSections } from '../../../components/previewer';
|
||
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
|
||
import {
|
||
ChatPreviewBody,
|
||
resolveChatPreviewGlyph,
|
||
resolveChatPreviewKindLabel,
|
||
type ChatPreviewKind,
|
||
} from './ChatPreviewBody';
|
||
import { triggerResourceDownload } from './downloadUtils';
|
||
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
|
||
import { normalizeChatResourceUrl } from './chatResourceUrl';
|
||
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, sharePreviewLink } from './chatUtils';
|
||
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
|
||
import { normalizeCodexModel } from '../codexModelOptions';
|
||
import { ChatActivityChecklist, buildChatActivityChecklistEntries } from './ChatActivityChecklist';
|
||
import { describeExecutorCommand } from './executorActivitySummary';
|
||
import { buildComposerFilePickKey } from './composerFilePickKey';
|
||
import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from './ChatPromptCard';
|
||
import { ChatStructuredPreviewCard } from './ChatStructuredPreviewCard';
|
||
import { openChatExternalLink } from './linkNavigation';
|
||
import { classifyPreviewKind } from './previewKind';
|
||
import { isPromptResolved } from './promptState';
|
||
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
|
||
import { extractAttachmentPreviewUrls, extractChatMessageParts } from './messageParts';
|
||
import type {
|
||
ChatComposerAttachment,
|
||
ChatConversationRequest,
|
||
ChatConversationRequestStatus,
|
||
ChatMessage,
|
||
ChatMessagePart,
|
||
} from './types';
|
||
|
||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
|
||
timeZone: KST_TIME_ZONE,
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false,
|
||
});
|
||
type ChatTypeOption = {
|
||
value: string;
|
||
label: string;
|
||
description: string;
|
||
disabled?: boolean;
|
||
};
|
||
|
||
type CodexModelOption = {
|
||
value: string;
|
||
label: string;
|
||
description: string;
|
||
};
|
||
|
||
type ChatConversationRequestWithModel = ChatConversationRequest & {
|
||
codexModel?: string | null;
|
||
};
|
||
|
||
function resolveRequestModelTokenUsage(request: ChatConversationRequest) {
|
||
return Math.max(
|
||
0,
|
||
Math.round(
|
||
Number(
|
||
request.usageSnapshot?.tokenTotals?.total ?? request.usageSnapshot?.totalTokens ?? request.totalTokens ?? 0,
|
||
) || 0,
|
||
),
|
||
);
|
||
}
|
||
|
||
function resolveRequestCodexModel(request: ChatConversationRequest) {
|
||
const requestWithModel = request as ChatConversationRequestWithModel;
|
||
return normalizeCodexModel(requestWithModel.codexModel?.trim() || '');
|
||
}
|
||
|
||
type PreviewOption = {
|
||
id: string;
|
||
label: string;
|
||
url: string;
|
||
kind: string;
|
||
};
|
||
|
||
type QueuedRequestOption = {
|
||
requestId: string;
|
||
order: number;
|
||
text: string;
|
||
};
|
||
|
||
type ComposerAssistAction = {
|
||
key: string;
|
||
label: string;
|
||
description?: string | null;
|
||
resolveText: () => string | Promise<string>;
|
||
};
|
||
|
||
function resolveComposerAssistIcon(actionKey: string) {
|
||
if (actionKey === 'menu') {
|
||
return <AppstoreOutlined />;
|
||
}
|
||
|
||
if (actionKey === 'focus') {
|
||
return <ControlOutlined />;
|
||
}
|
||
|
||
if (actionKey === 'selection') {
|
||
return <NodeIndexOutlined />;
|
||
}
|
||
|
||
if (actionKey === 'errors') {
|
||
return <ExclamationOutlined />;
|
||
}
|
||
|
||
return <ProfileOutlined />;
|
||
}
|
||
|
||
function buildComposerAssistDropdownLabel(title?: string | null) {
|
||
const normalizedTitle = title?.trim();
|
||
return normalizedTitle ? `${normalizedTitle} 작업환경 구성` : '작업환경 구성';
|
||
}
|
||
|
||
type InlinePreviewKind = ChatPreviewKind;
|
||
|
||
type InlinePreviewTarget = {
|
||
url: string;
|
||
label: string;
|
||
kind: InlinePreviewKind;
|
||
};
|
||
|
||
type OpenPreviewTarget =
|
||
| string
|
||
| {
|
||
id: string;
|
||
label: string;
|
||
url: string;
|
||
kind: InlinePreviewKind;
|
||
source?: 'message' | 'context';
|
||
};
|
||
|
||
type PendingComposerUpload = {
|
||
attemptId: string;
|
||
key: string;
|
||
name: string;
|
||
status: 'uploading' | 'failed';
|
||
reason?: string;
|
||
};
|
||
|
||
type ComposerSendResult = 'sent' | 'pending' | 'blocked';
|
||
|
||
type SystemExecutionFilter = 'all' | 'active' | 'attention' | 'active-attention';
|
||
type SystemExecutionSort = 'latest' | 'answered' | 'status';
|
||
|
||
type PendingPromptSelection = PromptDraftSelection & {
|
||
status: 'draft' | 'submitted';
|
||
promptTitle: string;
|
||
target: Extract<ChatMessagePart, { type: 'prompt' }>;
|
||
requestId: string | null;
|
||
};
|
||
|
||
type StoredPromptSelection = Pick<
|
||
PendingPromptSelection,
|
||
'selectedValues' | 'freeText' | 'stepSelections' | 'summaryText' | 'status' | 'promptTitle' | 'requestId'
|
||
>;
|
||
|
||
type SystemExecutionDisplayRequest = {
|
||
request: ChatConversationRequest;
|
||
depth: number;
|
||
};
|
||
|
||
type SystemExecutionDisplayMode = 'expanded' | 'collapsed' | 'hidden';
|
||
type RoomShareExpandMode = 'latest' | 'pending' | 'all';
|
||
|
||
type SystemExecutionAttentionState = {
|
||
activityLines: string[];
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
promptSubmittedCount: number;
|
||
isPromptManuallyCompleted: boolean;
|
||
hasVerificationTarget: boolean;
|
||
hasConfirmedVerificationTarget: boolean;
|
||
isVerificationManuallyCompleted: boolean;
|
||
hasPendingPromptBadge: boolean;
|
||
hasPendingVerificationBadge: boolean;
|
||
hasOwnAttentionState: boolean;
|
||
};
|
||
|
||
type SystemExecutionActivityOverview = {
|
||
lines: string[];
|
||
planTitle: string | null;
|
||
executors: Array<{
|
||
key: string;
|
||
title: string;
|
||
line: string;
|
||
focus: string;
|
||
command: string | null;
|
||
commandSummary: string | null;
|
||
updateCount: number;
|
||
}>;
|
||
};
|
||
|
||
const ROOM_SHARE_EXPAND_MODE_LABELS: Record<RoomShareExpandMode, string> = {
|
||
latest: '마지막건',
|
||
pending: '처리중·미확인',
|
||
all: '전체',
|
||
};
|
||
|
||
function summarizeRoomShareActivityLines(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 ? [`현재 작업을 진행하고 있어요. `] : ['현재 작업을 진행하고 있어요.'];
|
||
}
|
||
|
||
|
||
type SystemExecutionBadgeTone =
|
||
| 'queued'
|
||
| 'started'
|
||
| 'completed'
|
||
| 'failed'
|
||
| 'cancelled'
|
||
| 'neutral'
|
||
| 'attention'
|
||
| 'prompt'
|
||
| 'unread';
|
||
|
||
function buildPendingPromptSelectionKey(
|
||
messageId: number,
|
||
promptIndex: number,
|
||
targetTitle: string,
|
||
target: Extract<ChatMessagePart, { type: 'prompt' }>,
|
||
) {
|
||
return `${messageId}:${promptIndex}:${targetTitle}:${buildPromptTargetSignature(target)}`;
|
||
}
|
||
|
||
function buildPromptSelectionStorageKey(sessionId: string) {
|
||
const normalizedSessionId = sessionId.trim();
|
||
return normalizedSessionId ? `main-chat-panel:prompt-selections:${normalizedSessionId}` : '';
|
||
}
|
||
|
||
function readStoredPromptSelections(sessionId: string) {
|
||
if (typeof window === 'undefined') {
|
||
return {} as Record<string, StoredPromptSelection>;
|
||
}
|
||
|
||
const storageKey = buildPromptSelectionStorageKey(sessionId);
|
||
|
||
if (!storageKey) {
|
||
return {} as Record<string, StoredPromptSelection>;
|
||
}
|
||
|
||
try {
|
||
const rawValue = window.localStorage.getItem(storageKey);
|
||
|
||
if (!rawValue) {
|
||
return {} as Record<string, StoredPromptSelection>;
|
||
}
|
||
|
||
const parsed = JSON.parse(rawValue);
|
||
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||
return {} as Record<string, StoredPromptSelection>;
|
||
}
|
||
|
||
return Object.fromEntries(
|
||
Object.entries(parsed as Record<string, unknown>).flatMap(([key, value]) => {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
return [];
|
||
}
|
||
|
||
const record = value as Record<string, unknown>;
|
||
const status = record.status === 'submitted' || record.status === 'draft' ? record.status : null;
|
||
|
||
if (!status) {
|
||
return [];
|
||
}
|
||
|
||
const selectedValues = Array.isArray(record.selectedValues)
|
||
? record.selectedValues.map((item) => String(item ?? '').trim()).filter(Boolean)
|
||
: [];
|
||
const stepSelections = Array.isArray(record.stepSelections)
|
||
? record.stepSelections.flatMap((item) => {
|
||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||
return [];
|
||
}
|
||
|
||
const stepRecord = item as Record<string, unknown>;
|
||
const stepKey = String(stepRecord.stepKey ?? '').trim();
|
||
const stepTitle = String(stepRecord.stepTitle ?? '').trim();
|
||
|
||
if (!stepKey || !stepTitle) {
|
||
return [];
|
||
}
|
||
|
||
return [{
|
||
stepKey,
|
||
stepTitle,
|
||
selectedValues: Array.isArray(stepRecord.selectedValues)
|
||
? stepRecord.selectedValues.map((entry) => String(entry ?? '').trim()).filter(Boolean)
|
||
: [],
|
||
freeText: String(stepRecord.freeText ?? ''),
|
||
skipped: stepRecord.skipped === true,
|
||
}];
|
||
})
|
||
: undefined;
|
||
|
||
return [[
|
||
key,
|
||
{
|
||
status,
|
||
selectedValues,
|
||
freeText: String(record.freeText ?? ''),
|
||
stepSelections,
|
||
summaryText: String(record.summaryText ?? '').trim() || null,
|
||
promptTitle: String(record.promptTitle ?? '').trim(),
|
||
requestId: String(record.requestId ?? '').trim() || null,
|
||
} satisfies StoredPromptSelection,
|
||
]];
|
||
}),
|
||
);
|
||
} catch {
|
||
return {} as Record<string, StoredPromptSelection>;
|
||
}
|
||
}
|
||
|
||
function writeStoredPromptSelections(
|
||
sessionId: string,
|
||
selections: Record<string, PendingPromptSelection>,
|
||
) {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const storageKey = buildPromptSelectionStorageKey(sessionId);
|
||
|
||
if (!storageKey) {
|
||
return;
|
||
}
|
||
|
||
const nextValue = Object.fromEntries(
|
||
Object.entries(selections)
|
||
.map(([key, selection]) => [
|
||
key,
|
||
{
|
||
status: selection.status,
|
||
selectedValues: selection.selectedValues,
|
||
freeText: selection.freeText,
|
||
stepSelections: selection.stepSelections,
|
||
summaryText: selection.summaryText ?? null,
|
||
promptTitle: selection.promptTitle,
|
||
requestId: selection.requestId,
|
||
} satisfies StoredPromptSelection,
|
||
]),
|
||
);
|
||
|
||
try {
|
||
if (Object.keys(nextValue).length === 0) {
|
||
window.localStorage.removeItem(storageKey);
|
||
return;
|
||
}
|
||
|
||
window.localStorage.setItem(storageKey, JSON.stringify(nextValue));
|
||
} catch {
|
||
// Ignore storage quota and serialization failures. Prompt state should remain usable in-memory.
|
||
}
|
||
}
|
||
|
||
type SystemExecutionBadge = {
|
||
label: string;
|
||
shortLabel: string;
|
||
tone: SystemExecutionBadgeTone;
|
||
};
|
||
|
||
type SystemExecutionBadgeDisplay = SystemExecutionBadge & {
|
||
hideOnMobile?: boolean;
|
||
};
|
||
|
||
type AggregatedRequestStatusSummary = {
|
||
label: string | null;
|
||
tone: SystemExecutionBadgeTone;
|
||
};
|
||
|
||
type SystemExecutionFilterOption = {
|
||
value: SystemExecutionFilter;
|
||
label: string;
|
||
compactLabel: string;
|
||
ariaLabel: string;
|
||
};
|
||
|
||
type ConversationMessageEntry =
|
||
| {
|
||
kind: 'single';
|
||
key: string;
|
||
message: ChatMessage;
|
||
}
|
||
| {
|
||
kind: 'group';
|
||
key: string;
|
||
groupId: string;
|
||
request: ChatConversationRequest | undefined;
|
||
requestIds: string[];
|
||
messages: ChatMessage[];
|
||
};
|
||
|
||
type PreviewFetchError = Error & {
|
||
status?: number;
|
||
};
|
||
|
||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||
const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]';
|
||
const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]';
|
||
const VERIFICATION_REQUEST_PATTERN = /(검증|확인|점검|테스트|캡처|스크린샷|실화면|재확인)/iu;
|
||
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
||
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
||
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
||
const IMMEDIATE_SEND_TOGGLE_HOLD_MS = 2000;
|
||
const SYSTEM_EXECUTION_JUMP_MAX_RETRIES = 4;
|
||
const MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH = 767;
|
||
const DEFAULT_QUEUE_SUMMARY_MAX_LENGTH = 32;
|
||
const TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 88;
|
||
const DESKTOP_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH = 120;
|
||
|
||
type MessageRenderPayload = {
|
||
previewSourceText: string;
|
||
visibleText: string;
|
||
diffBlocks: string[];
|
||
rankedLinkTargets: RankedLinkPreviewTarget[];
|
||
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
|
||
previewCardTargets: Extract<ChatMessagePart, { type: 'preview_card' }>[];
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
};
|
||
|
||
const RANK_LINE_PATTERN = /(?:\b(?:rank|score)\b|랭크|점수)\s*[:=]?\s*[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?\b/i;
|
||
const TITLE_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:title|제목)\s*[:=-]\s*(.+)$/i;
|
||
const LINK_VALUE_PATTERN = /^(?:[-*]\s*)?(?:\d+\.\s*)?(?:link|url|href|링크)\s*[:=-]\s*(https?:\/\/\S+|\/\S+)$/i;
|
||
|
||
function normalizeInlinePreviewUrl(value: string) {
|
||
return normalizeChatResourceUrl(value);
|
||
}
|
||
|
||
function resolveConversationRootRequestId(
|
||
requestId: string,
|
||
requestStateMap: Map<string, ChatConversationRequest>,
|
||
) {
|
||
let currentRequestId = requestId.trim();
|
||
|
||
if (!currentRequestId) {
|
||
return '';
|
||
}
|
||
|
||
const visitedRequestIds = new Set<string>([currentRequestId]);
|
||
let currentRequest = requestStateMap.get(currentRequestId);
|
||
|
||
while (currentRequest) {
|
||
const parentRequestId = currentRequest.parentRequestId?.trim() || '';
|
||
|
||
if (!parentRequestId || visitedRequestIds.has(parentRequestId)) {
|
||
break;
|
||
}
|
||
|
||
const parentRequest = requestStateMap.get(parentRequestId);
|
||
|
||
if (!parentRequest) {
|
||
break;
|
||
}
|
||
|
||
currentRequestId = parentRequest.requestId;
|
||
currentRequest = parentRequest;
|
||
visitedRequestIds.add(parentRequestId);
|
||
}
|
||
|
||
return currentRequestId;
|
||
}
|
||
|
||
function collectRequestLineageIds(
|
||
requestId: string,
|
||
requestStateMap: Map<string, ChatConversationRequest>,
|
||
) {
|
||
const normalizedRequestId = requestId.trim();
|
||
|
||
if (!normalizedRequestId) {
|
||
return [];
|
||
}
|
||
|
||
const lineageIds: string[] = [];
|
||
const visitedRequestIds = new Set<string>();
|
||
let currentRequestId = normalizedRequestId;
|
||
|
||
while (currentRequestId && !visitedRequestIds.has(currentRequestId)) {
|
||
lineageIds.push(currentRequestId);
|
||
visitedRequestIds.add(currentRequestId);
|
||
|
||
const currentRequest = requestStateMap.get(currentRequestId);
|
||
const parentRequestId = currentRequest?.parentRequestId?.trim() || '';
|
||
|
||
if (!parentRequestId) {
|
||
break;
|
||
}
|
||
|
||
currentRequestId = parentRequestId;
|
||
}
|
||
|
||
return lineageIds;
|
||
}
|
||
|
||
function resolveChildComposerParentRequestId(
|
||
request: ChatConversationRequest | null | undefined,
|
||
requestStateMap: Map<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 = requestStateMap.get(parentRequestId) ?? null;
|
||
}
|
||
|
||
return request?.requestId?.trim() || '';
|
||
}
|
||
|
||
function resolvePromptSubmissionParentRequestId(
|
||
requestId: string | null | undefined,
|
||
requestStateMap: Map<string, ChatConversationRequest>,
|
||
) {
|
||
const normalizedRequestId = requestId?.trim() || '';
|
||
|
||
if (!normalizedRequestId) {
|
||
return '';
|
||
}
|
||
|
||
const request = requestStateMap.get(normalizedRequestId) ?? null;
|
||
return resolveChildComposerParentRequestId(request, requestStateMap) || normalizedRequestId;
|
||
}
|
||
|
||
function isPromptFollowupRequest(request: ChatConversationRequest) {
|
||
return request.requestOrigin === 'prompt';
|
||
}
|
||
|
||
function resolveConversationMessageGroupRequestId(
|
||
requestId: string | null | undefined,
|
||
requestStateMap: Map<string, ChatConversationRequest>,
|
||
) {
|
||
const normalizedRequestId = requestId?.trim() || '';
|
||
|
||
if (!normalizedRequestId) {
|
||
return '';
|
||
}
|
||
|
||
return resolveConversationRootRequestId(normalizedRequestId, requestStateMap) || normalizedRequestId;
|
||
}
|
||
|
||
function getConversationRequestDepth(
|
||
request: ChatConversationRequest | null | undefined,
|
||
requestStateMap: Map<string, ChatConversationRequest>,
|
||
) {
|
||
if (!request) {
|
||
return 0;
|
||
}
|
||
|
||
return Math.max(0, collectRequestLineageIds(request.requestId, requestStateMap).length - 1);
|
||
}
|
||
|
||
export function classifyInlinePreviewKind(url: string): InlinePreviewKind {
|
||
return classifyPreviewKind(url);
|
||
}
|
||
|
||
function isHtmlPreviewUrl(url: string) {
|
||
const pathname = url.toLowerCase().split('?')[0] ?? '';
|
||
return pathname.endsWith('.html') || pathname.endsWith('.htm');
|
||
}
|
||
|
||
function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||
if (typeof document === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const objectUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = objectUrl;
|
||
link.download = fileName;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
|
||
function buildInlinePreviewLabel(url: string) {
|
||
try {
|
||
const parsed = new URL(url);
|
||
return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname;
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
function buildPreviewFileName(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||
try {
|
||
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
|
||
const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim();
|
||
return fileName || item.label.trim() || item.url;
|
||
} catch {
|
||
return item.label.trim() || item.url;
|
||
}
|
||
}
|
||
|
||
function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) {
|
||
const targetRect = target.getBoundingClientRect();
|
||
const containerRect = container.getBoundingClientRect();
|
||
return targetRect.top - containerRect.top + container.scrollTop;
|
||
}
|
||
|
||
function getContainerScrollPaddingTop(container: HTMLElement) {
|
||
if (typeof window === 'undefined') {
|
||
return 0;
|
||
}
|
||
|
||
const paddingTop = Number.parseFloat(window.getComputedStyle(container).paddingTop || '0');
|
||
return Number.isFinite(paddingTop) ? Math.max(0, paddingTop) : 0;
|
||
}
|
||
|
||
function resolveScrollableAnchorContainer(target: HTMLElement, preferredContainer?: HTMLElement | null) {
|
||
const candidates: Array<HTMLElement | null | undefined> = [preferredContainer, target.parentElement];
|
||
|
||
let currentAncestor = target.parentElement;
|
||
while (currentAncestor) {
|
||
candidates.push(currentAncestor);
|
||
currentAncestor = currentAncestor.parentElement;
|
||
}
|
||
|
||
for (const candidate of candidates) {
|
||
if (!(candidate instanceof HTMLElement)) {
|
||
continue;
|
||
}
|
||
|
||
const style = window.getComputedStyle(candidate);
|
||
const overflowY = style.overflowY;
|
||
const isScrollableOverflow = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
||
|
||
if (!isScrollableOverflow) {
|
||
continue;
|
||
}
|
||
|
||
if (candidate.scrollHeight <= candidate.clientHeight + 1) {
|
||
continue;
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function isPhoneLikeViewport() {
|
||
if (typeof window === 'undefined') {
|
||
return false;
|
||
}
|
||
|
||
const isNarrowViewport =
|
||
window.matchMedia?.(`(max-width: ${MOBILE_SYSTEM_EXECUTION_AUTO_HIDE_MAX_WIDTH}px)`).matches === true;
|
||
const hasCoarsePointer = window.matchMedia?.('(pointer: coarse)').matches === true;
|
||
const hasTouchPoints = navigator.maxTouchPoints > 0;
|
||
|
||
return isNarrowViewport && (hasCoarsePointer || hasTouchPoints);
|
||
}
|
||
|
||
function getSystemExecutionJumpTargetPriority(
|
||
target: ReturnType<typeof resolveSystemExecutionJumpTarget>,
|
||
) {
|
||
if (!target) {
|
||
return -1;
|
||
}
|
||
|
||
switch (target.kind) {
|
||
case 'prompt':
|
||
return 3;
|
||
case 'response':
|
||
return 2;
|
||
case 'request':
|
||
return 1;
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function resolvePreviewFileExtension(item: Pick<PreviewOption, 'url' | 'label'>) {
|
||
const fileName = buildPreviewFileName(item).toLowerCase();
|
||
const match = fileName.match(/\.([a-z0-9]{1,16})$/i);
|
||
return match?.[1] ?? '';
|
||
}
|
||
|
||
function renderSystemExecutionFilterIcon(filter: SystemExecutionFilter) {
|
||
if (filter === 'active') {
|
||
return <ThunderboltOutlined />;
|
||
}
|
||
|
||
if (filter === 'attention') {
|
||
return <ExclamationCircleOutlined />;
|
||
}
|
||
|
||
return <FilterOutlined />;
|
||
}
|
||
|
||
function buildResourceChipMeta(item: Pick<PreviewOption, 'url' | 'label' | 'kind'>) {
|
||
const extension = resolvePreviewFileExtension(item);
|
||
|
||
if (extension) {
|
||
return extension.toUpperCase();
|
||
}
|
||
|
||
return resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)
|
||
.replace(/\s+preview$/i, '')
|
||
.replace(/\s+download$/i, '')
|
||
.toUpperCase();
|
||
}
|
||
|
||
function normalizeRankedLinkTitle(value: string) {
|
||
return value
|
||
.replace(/^\[(.+)\]\([^)]+\)$/u, '$1')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function extractRankedLinkTargets(text: string) {
|
||
const lines = String(text ?? '').split('\n');
|
||
const keptLines: string[] = [];
|
||
const rankedLinkTargets: RankedLinkPreviewTarget[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
const pushRankedLink = (title: string, url: string) => {
|
||
const normalizedUrl = normalizeInlinePreviewUrl(url.trim());
|
||
const normalizedTitle = normalizeRankedLinkTitle(title) || buildInlinePreviewLabel(normalizedUrl);
|
||
const key = `${normalizedTitle}::${normalizedUrl}`;
|
||
|
||
if (seen.has(key)) {
|
||
return;
|
||
}
|
||
|
||
seen.add(key);
|
||
rankedLinkTargets.push({
|
||
title: normalizedTitle,
|
||
url: normalizedUrl,
|
||
});
|
||
};
|
||
|
||
for (let index = 0; index < lines.length; index += 1) {
|
||
const line = lines[index] ?? '';
|
||
const trimmedLine = line.trim();
|
||
|
||
if (!trimmedLine) {
|
||
keptLines.push(line);
|
||
continue;
|
||
}
|
||
|
||
const markdownMatches = [...trimmedLine.matchAll(MARKDOWN_LINK_PATTERN)];
|
||
if (markdownMatches.length > 0 && RANK_LINE_PATTERN.test(trimmedLine)) {
|
||
markdownMatches.forEach((match) => {
|
||
const [, label, href] = match;
|
||
if (href?.trim()) {
|
||
pushRankedLink(label?.trim() || href.trim(), href);
|
||
}
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const titleMatch = trimmedLine.match(TITLE_VALUE_PATTERN);
|
||
if (!titleMatch) {
|
||
keptLines.push(line);
|
||
continue;
|
||
}
|
||
|
||
const collectedLines = [line];
|
||
const title = titleMatch[1]?.trim() ?? '';
|
||
let url = '';
|
||
let hasRank = RANK_LINE_PATTERN.test(trimmedLine);
|
||
let cursor = index + 1;
|
||
|
||
while (cursor < lines.length) {
|
||
const candidate = lines[cursor] ?? '';
|
||
const trimmedCandidate = candidate.trim();
|
||
|
||
if (!trimmedCandidate) {
|
||
collectedLines.push(candidate);
|
||
cursor += 1;
|
||
continue;
|
||
}
|
||
|
||
if (trimmedCandidate.match(TITLE_VALUE_PATTERN) && cursor !== index + 1) {
|
||
break;
|
||
}
|
||
|
||
const linkMatch = trimmedCandidate.match(LINK_VALUE_PATTERN);
|
||
if (linkMatch) {
|
||
url = linkMatch[1]?.trim() ?? url;
|
||
collectedLines.push(candidate);
|
||
hasRank ||= RANK_LINE_PATTERN.test(trimmedCandidate);
|
||
cursor += 1;
|
||
continue;
|
||
}
|
||
|
||
if (RANK_LINE_PATTERN.test(trimmedCandidate)) {
|
||
hasRank = true;
|
||
collectedLines.push(candidate);
|
||
cursor += 1;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (title && url && hasRank) {
|
||
pushRankedLink(title, url);
|
||
index = cursor - 1;
|
||
continue;
|
||
}
|
||
|
||
keptLines.push(...collectedLines);
|
||
index = cursor - 1;
|
||
}
|
||
|
||
return {
|
||
strippedText: keptLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(),
|
||
rankedLinkTargets,
|
||
};
|
||
}
|
||
|
||
function isClipboardImageFile(file: File) {
|
||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||
|
||
if (normalizedType.startsWith('image/')) {
|
||
return true;
|
||
}
|
||
|
||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||
return /\.(png|jpe?g|gif|webp|bmp|heic|heif)$/i.test(normalizedName);
|
||
}
|
||
|
||
function isGeneratedClipboardImageName(file: File) {
|
||
const normalizedName = String(file.name ?? '').trim().toLowerCase();
|
||
|
||
if (!normalizedName) {
|
||
return true;
|
||
}
|
||
|
||
return /^(image|clipboard|pasted image)(?:[-\s]?\d+)?(?: \(\d+\))?\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif)$/i.test(
|
||
normalizedName,
|
||
);
|
||
}
|
||
|
||
function getClipboardImageMimeRank(file: File) {
|
||
const normalizedType = String(file.type ?? '').trim().toLowerCase();
|
||
|
||
switch (normalizedType) {
|
||
case 'image/png':
|
||
return 0;
|
||
case 'image/jpeg':
|
||
return 1;
|
||
case 'image/webp':
|
||
return 2;
|
||
case 'image/gif':
|
||
return 3;
|
||
case 'image/bmp':
|
||
return 4;
|
||
case 'image/heic':
|
||
case 'image/heif':
|
||
return 5;
|
||
case 'image/tiff':
|
||
case 'image/tif':
|
||
return 6;
|
||
default:
|
||
return 7;
|
||
}
|
||
}
|
||
|
||
function resolvePreferredClipboardImageFiles(files: File[]) {
|
||
if (files.length <= 1) {
|
||
return files;
|
||
}
|
||
const sortedFiles = [...files]
|
||
.sort((left, right) => {
|
||
const rankDifference = getClipboardImageMimeRank(left) - getClipboardImageMimeRank(right);
|
||
|
||
if (rankDifference !== 0) {
|
||
return rankDifference;
|
||
}
|
||
|
||
return right.size - left.size;
|
||
})
|
||
.slice(0, 1);
|
||
|
||
if (files.every(isGeneratedClipboardImageName)) {
|
||
return sortedFiles;
|
||
}
|
||
|
||
return sortedFiles;
|
||
}
|
||
|
||
function resolveComposerPasteFiles(clipboardData: DataTransfer) {
|
||
const clipboardItemFiles = Array.from(clipboardData.items ?? [])
|
||
.filter((item) => item.kind === 'file')
|
||
.map((item) => item.getAsFile())
|
||
.filter((file): file is File => file instanceof File)
|
||
.filter((file) => file.size > 0);
|
||
const clipboardFiles = Array.from(clipboardData.files ?? []).filter((file) => file.size > 0);
|
||
const candidateFiles = clipboardItemFiles.length > 0 ? clipboardItemFiles : clipboardFiles;
|
||
const imageFiles = candidateFiles.filter(isClipboardImageFile);
|
||
const filesToUse = imageFiles.length > 0 ? resolvePreferredClipboardImageFiles(imageFiles) : candidateFiles;
|
||
|
||
return Array.from(new Map(filesToUse.map((file) => [buildComposerFilePickKey(file), file])).values());
|
||
}
|
||
|
||
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
|
||
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 PreviewFetchError;
|
||
error.status = response.status;
|
||
return error;
|
||
}
|
||
|
||
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
|
||
const seen = new Set<string>();
|
||
const targets: InlinePreviewTarget[] = [];
|
||
const pushTarget = (matchedUrl: string, options?: { allowHtml?: boolean }) => {
|
||
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
|
||
const kind = classifyInlinePreviewKind(normalizedUrl);
|
||
|
||
if (kind === 'file') {
|
||
return;
|
||
}
|
||
|
||
// Plain HTML artifact paths should stay as text unless the reply explicitly opts into preview rendering.
|
||
if (!options?.allowHtml && isHtmlPreviewUrl(normalizedUrl)) {
|
||
return;
|
||
}
|
||
|
||
if (seen.has(normalizedUrl)) {
|
||
return;
|
||
}
|
||
|
||
seen.add(normalizedUrl);
|
||
targets.push({
|
||
url: normalizedUrl,
|
||
label: buildInlinePreviewLabel(normalizedUrl),
|
||
kind,
|
||
});
|
||
};
|
||
|
||
extractHiddenPreviewUrls(text).forEach((matchedUrl) => {
|
||
pushTarget(matchedUrl, { allowHtml: true });
|
||
});
|
||
|
||
return targets;
|
||
}
|
||
|
||
function renderMessageInlineParts(line: string): ReactNode[] {
|
||
const renderedParts: ReactNode[] = [];
|
||
let cursor = 0;
|
||
|
||
for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) {
|
||
const [fullMatch, label, rawHref] = match;
|
||
const start = match.index ?? 0;
|
||
|
||
if (start > cursor) {
|
||
renderedParts.push(line.slice(cursor, start));
|
||
}
|
||
|
||
const href = normalizeInlinePreviewUrl(rawHref.trim());
|
||
renderedParts.push(
|
||
<a
|
||
key={`${href}-${start}`}
|
||
href={href}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
onClick={(event) => {
|
||
openChatExternalLink(href, event);
|
||
}}
|
||
>
|
||
{label.trim() || href}
|
||
</a>,
|
||
);
|
||
cursor = start + fullMatch.length;
|
||
}
|
||
|
||
if (cursor < line.length) {
|
||
renderedParts.push(line.slice(cursor));
|
||
}
|
||
|
||
return renderedParts.length > 0 ? renderedParts : [line];
|
||
}
|
||
|
||
function renderMessageBody(text: string) {
|
||
const lines = text.split('\n');
|
||
|
||
return lines.map((line, index) => {
|
||
const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN);
|
||
|
||
if (imageMatch) {
|
||
const [, alt, rawSrc] = imageMatch;
|
||
const src = normalizeInlinePreviewUrl(rawSrc.trim());
|
||
|
||
return (
|
||
<div key={`img-${index}`} className="app-chat-message__block app-chat-message__block--image">
|
||
<InlineImage
|
||
src={src}
|
||
alt={alt.trim() || 'chat image'}
|
||
className="app-chat-message__inline-image markdown-preview__image"
|
||
fallbackText="이미지 preview를 불러오지 못했습니다."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!line.length) {
|
||
return <div key={`space-${index}`} className="app-chat-message__block app-chat-message__block--spacer" aria-hidden="true" />;
|
||
}
|
||
|
||
return (
|
||
<div key={`line-${index}`} className="app-chat-message__block">
|
||
{renderMessageInlineParts(line)}
|
||
</div>
|
||
);
|
||
});
|
||
}
|
||
|
||
const EXECUTOR_HEADER_LINE_PATTERN = /^\[[^\]\n]+ · [^\]\n]+\]$/u;
|
||
|
||
function collapseDuplicatedLeadingExecutorHeaders(text: string) {
|
||
const lines = text.split('\n');
|
||
const collapsedLines: string[] = [];
|
||
let previousHeaderLine: string | null = null;
|
||
let isLeadingHeaderSection = true;
|
||
|
||
lines.forEach((line) => {
|
||
const normalizedLine = line.trim();
|
||
const isExecutorHeaderLine = EXECUTOR_HEADER_LINE_PATTERN.test(normalizedLine);
|
||
|
||
if (isLeadingHeaderSection && isExecutorHeaderLine) {
|
||
if (previousHeaderLine !== normalizedLine) {
|
||
collapsedLines.push(line);
|
||
previousHeaderLine = normalizedLine;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (normalizedLine.length > 0) {
|
||
isLeadingHeaderSection = false;
|
||
}
|
||
|
||
previousHeaderLine = null;
|
||
collapsedLines.push(line);
|
||
});
|
||
|
||
return collapsedLines.join('\n');
|
||
}
|
||
|
||
function isRequestInFlightStatus(status: ChatConversationRequestStatus | null | undefined) {
|
||
return status === 'accepted' || status === 'queued' || status === 'started';
|
||
}
|
||
|
||
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
|
||
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
|
||
const attachmentExtraction = extractAttachmentPreviewUrls(message.text);
|
||
const extractedMessageParts = extractChatMessageParts(attachmentExtraction.strippedText);
|
||
const text = extractedMessageParts.strippedText;
|
||
const linkCardTargets = [
|
||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'link_card' }> => part.type === 'link_card'),
|
||
].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index);
|
||
const previewCardTargets = [
|
||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'preview_card' }> => part.type === 'preview_card'),
|
||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'preview_card' }> => part.type === 'preview_card'),
|
||
].filter(
|
||
(part, index, collection) =>
|
||
collection.findIndex(
|
||
(candidate) =>
|
||
`${candidate.title}:${candidate.preview.type}:${candidate.preview.url ?? ''}:${candidate.preview.content ?? ''}` ===
|
||
`${part.title}:${part.preview.type}:${part.preview.url ?? ''}:${part.preview.content ?? ''}`,
|
||
) === index,
|
||
);
|
||
const promptTargets = (() => {
|
||
const promptParts = [
|
||
...structuredParts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||
...extractedMessageParts.parts.filter((part): part is Extract<ChatMessagePart, { type: 'prompt' }> => part.type === 'prompt'),
|
||
];
|
||
const promptByKey = new Map<string, Extract<ChatMessagePart, { type: 'prompt' }>>();
|
||
const getPromptKey = (part: Extract<ChatMessagePart, { type: 'prompt' }>) =>
|
||
[
|
||
part.title,
|
||
part.options.map((option) => `${option.value}:${option.label}`).join(','),
|
||
(part.steps ?? [])
|
||
.map((step) => `${step.key}:${step.title}:${step.options.map((option) => `${option.value}:${option.label}`).join(',')}`)
|
||
.join(';'),
|
||
].join('::');
|
||
const getPromptRichness = (part: Extract<ChatMessagePart, { type: 'prompt' }>) =>
|
||
[
|
||
part.selectedValues?.length ?? 0,
|
||
(part.steps ?? []).reduce((count, step) => count + (step.selectedValues?.length ?? 0), 0),
|
||
part.resultText?.trim() ? 1 : 0,
|
||
part.resolvedBy ? 1 : 0,
|
||
part.readOnly === true ? 1 : 0,
|
||
].reduce((sum, value) => sum + value, 0);
|
||
|
||
promptParts.forEach((part) => {
|
||
const key = getPromptKey(part);
|
||
const existing = promptByKey.get(key);
|
||
|
||
if (!existing || getPromptRichness(part) >= getPromptRichness(existing)) {
|
||
promptByKey.set(key, part);
|
||
}
|
||
});
|
||
|
||
return [...promptByKey.values()];
|
||
})();
|
||
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
|
||
.map((match) => match[1]?.trim())
|
||
.filter((value): value is string => Boolean(value));
|
||
|
||
const diffStrippedText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
||
const { strippedText: previewSourceText, rankedLinkTargets } = extractRankedLinkTargets(diffStrippedText);
|
||
const visibleText = collapseDuplicatedLeadingExecutorHeaders(stripHiddenPreviewTags(previewSourceText));
|
||
return {
|
||
previewSourceText: [previewSourceText, ...attachmentExtraction.urls.map((url) => `[[preview:${url}]]`)].filter(Boolean).join('\n'),
|
||
visibleText,
|
||
diffBlocks,
|
||
rankedLinkTargets,
|
||
linkCardTargets,
|
||
previewCardTargets,
|
||
promptTargets,
|
||
};
|
||
}
|
||
|
||
function extractPromptPreviewUrls(promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[]) {
|
||
const urls = new Set<string>();
|
||
|
||
promptTargets.forEach((target) => {
|
||
target.options.forEach((option) => {
|
||
const previewUrl = option.preview?.url?.trim();
|
||
|
||
if (previewUrl) {
|
||
urls.add(previewUrl);
|
||
}
|
||
});
|
||
|
||
(target.steps ?? []).forEach((step) => {
|
||
step.options.forEach((option) => {
|
||
const previewUrl = option.preview?.url?.trim();
|
||
|
||
if (previewUrl) {
|
||
urls.add(previewUrl);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return [...urls];
|
||
}
|
||
|
||
function hasPromptPreviewArtifact(promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[]) {
|
||
return promptTargets.some((target) => {
|
||
const hasPreviewOption = target.options.some((option) => option.preview != null);
|
||
const hasPreviewStepOption = (target.steps ?? []).some((step) => step.options.some((option) => option.preview != null));
|
||
return hasPreviewOption || hasPreviewStepOption;
|
||
});
|
||
}
|
||
|
||
function combineCodexDiffBlocks(diffBlocks: string[]) {
|
||
const normalizedBlocks = diffBlocks.map((block) => block.trim()).filter(Boolean);
|
||
const diffText = normalizedBlocks.join('\n\n');
|
||
const fileCount = normalizedBlocks.reduce((count, block) => count + parseCodexDiffSections(block).length, 0);
|
||
|
||
return {
|
||
diffText,
|
||
fileCount,
|
||
};
|
||
}
|
||
|
||
function summarizeQueuedText(text: string, maxLength = DEFAULT_QUEUE_SUMMARY_MAX_LENGTH) {
|
||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||
return normalized.length > maxLength ? `${normalized.slice(0, maxLength).trimEnd()}...` : normalized;
|
||
}
|
||
|
||
function resolvePromptParentQuestionText(request: ChatConversationRequest | null | undefined) {
|
||
return request?.userText?.replace(/\s+/g, ' ').trim() || '';
|
||
}
|
||
|
||
function resolveSystemExecutionSummaryMaxLength() {
|
||
if (typeof window === 'undefined') {
|
||
return DEFAULT_QUEUE_SUMMARY_MAX_LENGTH;
|
||
}
|
||
|
||
if (window.matchMedia?.('(min-width: 1367px)').matches) {
|
||
return DESKTOP_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH;
|
||
}
|
||
|
||
if (window.matchMedia?.('(min-width: 820px)').matches) {
|
||
return TABLET_SYSTEM_EXECUTION_SUMMARY_MAX_LENGTH;
|
||
}
|
||
|
||
return DEFAULT_QUEUE_SUMMARY_MAX_LENGTH;
|
||
}
|
||
|
||
function normalizeAttachmentName(value: string) {
|
||
return String(value ?? '').trim().toLowerCase();
|
||
}
|
||
|
||
function isActivityLogMessage(message: ChatMessage) {
|
||
return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`);
|
||
}
|
||
|
||
function getMissingRequestMessageText(message: ChatMessage) {
|
||
if (!isMissingRequestMessage(message)) {
|
||
return '';
|
||
}
|
||
|
||
return message.text.slice(`${CHAT_MISSING_REQUEST_MESSAGE_PREFIX}\n`.length).trim();
|
||
}
|
||
|
||
function getExecutionFailureMessageText(message: ChatMessage) {
|
||
if (!isExecutionFailureMessage(message)) {
|
||
return '';
|
||
}
|
||
|
||
return message.text.slice(`${CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX}\n`.length).trim();
|
||
}
|
||
|
||
function isLikelyCollapsibleMessage(text: string) {
|
||
const normalizedText = String(text ?? '').trim();
|
||
|
||
if (!normalizedText) {
|
||
return false;
|
||
}
|
||
|
||
if (normalizedText.length > COLLAPSIBLE_MESSAGE_CHAR_COUNT) {
|
||
return true;
|
||
}
|
||
|
||
const visualLines = normalizedText
|
||
.split('\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
|
||
return visualLines.length > COLLAPSIBLE_MESSAGE_LINE_COUNT;
|
||
}
|
||
|
||
function isTerminalRequestStatus(status: ChatConversationRequestStatus | undefined) {
|
||
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed';
|
||
}
|
||
|
||
function isRenderableConversationMessage(message: ChatMessage) {
|
||
return !isActivityLogMessage(message);
|
||
}
|
||
|
||
function resolveCollapsedGroupSummary(
|
||
request: ChatConversationRequest | undefined,
|
||
groupedRequests: ChatConversationRequest[],
|
||
messages: ChatMessage[],
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
options?: {
|
||
preferredMessageId?: number | null;
|
||
},
|
||
) {
|
||
const renderableMessages = messages.filter(isRenderableConversationMessage);
|
||
const normalizedRequests =
|
||
groupedRequests.length > 0
|
||
? groupedRequests
|
||
: request
|
||
? [request]
|
||
: [];
|
||
|
||
if (!request || !isTerminalRequestStatus(request.status) || renderableMessages.length <= 1) {
|
||
return {
|
||
shouldCollapseByDefault: false,
|
||
displayedMessages: renderableMessages,
|
||
hiddenMessageCount: 0,
|
||
};
|
||
}
|
||
|
||
const preferredMessageId = options?.preferredMessageId ?? null;
|
||
const responseMessageId = preferredMessageId ?? request.responseMessageId;
|
||
let finalMessage =
|
||
(responseMessageId != null ? renderableMessages.find((message) => message.id === responseMessageId) : null) ?? null;
|
||
|
||
if (!finalMessage) {
|
||
finalMessage =
|
||
[...renderableMessages].reverse().find((message) => message.author === 'codex' || message.author === 'system') ??
|
||
renderableMessages[renderableMessages.length - 1] ??
|
||
null;
|
||
}
|
||
|
||
if (!finalMessage) {
|
||
return {
|
||
shouldCollapseByDefault: false,
|
||
displayedMessages: renderableMessages,
|
||
hiddenMessageCount: 0,
|
||
};
|
||
}
|
||
|
||
const unresolvedRequestIdSet = new Set(
|
||
normalizedRequests
|
||
.filter((groupedRequest) =>
|
||
!isRequestUserFinalized(groupedRequest, attentionStateByRequestId.get(groupedRequest.requestId)),
|
||
)
|
||
.map((groupedRequest) => groupedRequest.requestId),
|
||
);
|
||
const firstUserMessage = renderableMessages.find((message) => message.author === 'user') ?? null;
|
||
|
||
if (unresolvedRequestIdSet.size === 0) {
|
||
return {
|
||
shouldCollapseByDefault: true,
|
||
displayedMessages: [finalMessage],
|
||
hiddenMessageCount: Math.max(0, renderableMessages.length - 1),
|
||
};
|
||
}
|
||
|
||
const displayedMessages: ChatMessage[] = [];
|
||
const displayedMessageIdSet = new Set<number>();
|
||
const pushDisplayedMessage = (message: ChatMessage | null) => {
|
||
if (!message || displayedMessageIdSet.has(message.id)) {
|
||
return;
|
||
}
|
||
|
||
displayedMessages.push(message);
|
||
displayedMessageIdSet.add(message.id);
|
||
};
|
||
|
||
pushDisplayedMessage(firstUserMessage);
|
||
renderableMessages.forEach((message) => {
|
||
const messageRequestId = message.clientRequestId?.trim() || '';
|
||
|
||
if (!messageRequestId || !unresolvedRequestIdSet.has(messageRequestId)) {
|
||
return;
|
||
}
|
||
|
||
pushDisplayedMessage(message);
|
||
});
|
||
|
||
if (displayedMessages.length === 0) {
|
||
pushDisplayedMessage(finalMessage);
|
||
}
|
||
|
||
return {
|
||
shouldCollapseByDefault: displayedMessages.length < renderableMessages.length,
|
||
displayedMessages,
|
||
hiddenMessageCount: Math.max(0, renderableMessages.length - displayedMessages.length),
|
||
};
|
||
}
|
||
|
||
function formatChatTimestamp(timestamp: string) {
|
||
const normalized = String(timestamp ?? '').trim();
|
||
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
|
||
if (KST_TIMESTAMP_PATTERN.test(normalized)) {
|
||
return normalized;
|
||
}
|
||
|
||
const parsed = new Date(normalized);
|
||
|
||
if (Number.isNaN(parsed.getTime())) {
|
||
return normalized;
|
||
}
|
||
|
||
return KST_DATE_TIME_FORMATTER.format(parsed).replace(',', '');
|
||
}
|
||
|
||
function resolveSystemExecutionElapsedEndTime(request: ChatConversationRequest) {
|
||
const terminalAt = String(request.terminalAt ?? '').trim();
|
||
|
||
if (terminalAt) {
|
||
return terminalAt;
|
||
}
|
||
|
||
const answeredAt = String(request.answeredAt ?? '').trim();
|
||
|
||
if (answeredAt && (request.status === 'completed' || request.hasResponse)) {
|
||
return answeredAt;
|
||
}
|
||
|
||
if (request.status === 'failed' || request.status === 'cancelled' || request.status === 'removed') {
|
||
const updatedAt = String(request.updatedAt ?? '').trim();
|
||
return updatedAt || null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function formatSystemExecutionElapsedLabel(request: ChatConversationRequest) {
|
||
const startedAt = new Date(String(request.createdAt ?? '').trim()).getTime();
|
||
|
||
if (!Number.isFinite(startedAt)) {
|
||
return '';
|
||
}
|
||
|
||
const endTimeSource = resolveSystemExecutionElapsedEndTime(request);
|
||
|
||
if (!endTimeSource) {
|
||
return '';
|
||
}
|
||
|
||
const endedAt = new Date(endTimeSource).getTime();
|
||
|
||
if (!Number.isFinite(endedAt)) {
|
||
return '';
|
||
}
|
||
|
||
const elapsedMs = Math.max(0, endedAt - startedAt);
|
||
const totalSeconds = Math.floor(elapsedMs / 1000);
|
||
|
||
if (totalSeconds < 60) {
|
||
return `실행 ${Math.max(totalSeconds, 1)}초`;
|
||
}
|
||
|
||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||
|
||
if (totalMinutes < 60) {
|
||
return `실행 ${totalMinutes}분`;
|
||
}
|
||
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
|
||
if (minutes === 0) {
|
||
return `실행 ${hours}시간`;
|
||
}
|
||
|
||
return `실행 ${hours}시간 ${minutes}분`;
|
||
}
|
||
|
||
function formatOngoingElapsedLabel(startedAt: string | null | undefined, nowMs: number) {
|
||
const normalized = String(startedAt ?? '').trim();
|
||
|
||
if (!normalized) {
|
||
return '';
|
||
}
|
||
|
||
const startedMs = new Date(normalized).getTime();
|
||
|
||
if (!Number.isFinite(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 hasAnsweredRequest(request: ChatConversationRequest | undefined) {
|
||
if (!request || request.hasResponse !== true) {
|
||
return false;
|
||
}
|
||
|
||
return Boolean(
|
||
request.responseMessageId != null ||
|
||
String(request.responseText ?? '').trim() ||
|
||
String(request.answeredAt ?? '').trim(),
|
||
);
|
||
}
|
||
|
||
function isRequestTerminalFailureStatus(status: ChatConversationRequestStatus | undefined) {
|
||
return status === 'failed' || status === 'cancelled' || status === 'removed';
|
||
}
|
||
|
||
function isRequestQueueStatus(status: ChatConversationRequestStatus | undefined) {
|
||
return status === 'accepted' || status === 'queued';
|
||
}
|
||
|
||
function isRequestRunningStatus(status: ChatConversationRequestStatus | undefined) {
|
||
return status === 'started';
|
||
}
|
||
|
||
function isDisconnectedRequestNeedingAttention(request: ChatConversationRequest | undefined) {
|
||
if (!request || request.hasResponse) {
|
||
return false;
|
||
}
|
||
|
||
if (request.status !== 'failed') {
|
||
return false;
|
||
}
|
||
|
||
return (request.statusMessage?.trim() ?? '') === '중단된 오래된 요청';
|
||
}
|
||
|
||
function isRequestUserFinalized(
|
||
request: ChatConversationRequest,
|
||
attentionState?: SystemExecutionAttentionState,
|
||
) {
|
||
if (request.status !== 'completed') {
|
||
return false;
|
||
}
|
||
|
||
if (!attentionState) {
|
||
return true;
|
||
}
|
||
|
||
return !attentionState.hasPendingPromptBadge && !attentionState.hasPendingVerificationBadge;
|
||
}
|
||
|
||
function resolveAggregatedRequestStatusSummary(
|
||
requests: ChatConversationRequest[],
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
): AggregatedRequestStatusSummary {
|
||
if (requests.length === 0) {
|
||
return {
|
||
label: null,
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
if (requests.some((request) => request.status === 'failed')) {
|
||
return {
|
||
label: '실패',
|
||
tone: 'failed',
|
||
};
|
||
}
|
||
|
||
if (requests.some((request) => request.status === 'cancelled' || request.status === 'removed')) {
|
||
return {
|
||
label: '중단됨',
|
||
tone: 'cancelled',
|
||
};
|
||
}
|
||
|
||
const allCompleted = requests.every((request) => request.status === 'completed');
|
||
const allAnswered = requests.every((request) => hasAnsweredRequest(request));
|
||
const allUserFinalized = requests.every((request) =>
|
||
isRequestUserFinalized(request, attentionStateByRequestId.get(request.requestId)),
|
||
);
|
||
|
||
if (allCompleted && allUserFinalized) {
|
||
return {
|
||
label: '완료',
|
||
tone: 'completed',
|
||
};
|
||
}
|
||
|
||
if (allCompleted && !allUserFinalized) {
|
||
return {
|
||
label: '확인대기',
|
||
tone: 'attention',
|
||
};
|
||
}
|
||
|
||
if (requests.some((request) => isRequestRunningStatus(request.status))) {
|
||
return {
|
||
label: '처리중',
|
||
tone: 'started',
|
||
};
|
||
}
|
||
|
||
if (requests.some((request) => isRequestQueueStatus(request.status))) {
|
||
return {
|
||
label: '대기중',
|
||
tone: 'queued',
|
||
};
|
||
}
|
||
|
||
if (requests.some((request) => isRequestTerminalFailureStatus(request.status))) {
|
||
return {
|
||
label: '확인필요',
|
||
tone: 'failed',
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: null,
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
function resolveActiveSystemExecutionActionTargetRequest(
|
||
requests: ChatConversationRequest[],
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
) {
|
||
const activeRequests = requests.filter(
|
||
(request) => isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status),
|
||
);
|
||
|
||
if (activeRequests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return resolveRepresentativeSystemExecutionRequest(activeRequests, attentionStateByRequestId) ?? activeRequests[0]!;
|
||
}
|
||
|
||
function getRepresentativeAttentionPriority(
|
||
request: ChatConversationRequest,
|
||
attentionState: SystemExecutionAttentionState | undefined,
|
||
) {
|
||
if (attentionState?.hasPendingPromptBadge) {
|
||
return 0;
|
||
}
|
||
|
||
if (attentionState?.hasPendingVerificationBadge) {
|
||
return 1;
|
||
}
|
||
|
||
if (request.status === 'failed') {
|
||
return 2;
|
||
}
|
||
|
||
if (request.status === 'started') {
|
||
return 3;
|
||
}
|
||
|
||
if (request.status === 'accepted' || request.status === 'queued') {
|
||
return 4;
|
||
}
|
||
|
||
if (request.hasResponse) {
|
||
return 5;
|
||
}
|
||
|
||
return 6;
|
||
}
|
||
|
||
function compareRepresentativeRequests(
|
||
left: ChatConversationRequest,
|
||
right: ChatConversationRequest,
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
) {
|
||
const leftPriority = getRepresentativeAttentionPriority(left, attentionStateByRequestId.get(left.requestId));
|
||
const rightPriority = getRepresentativeAttentionPriority(right, attentionStateByRequestId.get(right.requestId));
|
||
|
||
if (leftPriority !== rightPriority) {
|
||
return leftPriority - rightPriority;
|
||
}
|
||
|
||
const leftTimestamp = left.answeredAt || left.terminalAt || left.updatedAt || left.createdAt;
|
||
const rightTimestamp = right.answeredAt || right.terminalAt || right.updatedAt || right.createdAt;
|
||
|
||
return (
|
||
rightTimestamp.localeCompare(leftTimestamp) ||
|
||
right.updatedAt.localeCompare(left.updatedAt) ||
|
||
right.createdAt.localeCompare(left.createdAt) ||
|
||
right.requestId.localeCompare(left.requestId)
|
||
);
|
||
}
|
||
|
||
function resolveRepresentativeSystemExecutionRequest(
|
||
requests: ChatConversationRequest[],
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
) {
|
||
if (requests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return [...requests].sort((left, right) =>
|
||
compareRepresentativeRequests(left, right, attentionStateByRequestId),
|
||
)[0]!;
|
||
}
|
||
|
||
function formatRequestStatusLabel(
|
||
request: ChatConversationRequest | undefined,
|
||
attentionState?: SystemExecutionAttentionState,
|
||
options?: {
|
||
hideFinalizedLabel?: boolean;
|
||
},
|
||
) {
|
||
const hideFinalizedLabel = options?.hideFinalizedLabel === true;
|
||
const retryCount = Math.max(0, Number(request?.retryCount ?? 0) || 0);
|
||
const appendRetryLabel = (label: string | null) =>
|
||
label && retryCount > 0 ? `${label} · 재처리 ${retryCount}회` : label;
|
||
|
||
if (hasAnsweredRequest(request)) {
|
||
if (request?.status === "completed") {
|
||
if (hideFinalizedLabel) {
|
||
return null;
|
||
}
|
||
|
||
return appendRetryLabel(
|
||
attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료",
|
||
);
|
||
}
|
||
|
||
return appendRetryLabel(hideFinalizedLabel ? null : "답변도착");
|
||
}
|
||
|
||
switch (request?.status) {
|
||
case 'accepted':
|
||
return appendRetryLabel('접수됨');
|
||
case 'queued':
|
||
return appendRetryLabel('대기중');
|
||
case 'started':
|
||
return appendRetryLabel(request.hasResponse ? '응답작성중' : '처리중');
|
||
case 'completed':
|
||
return appendRetryLabel('완료');
|
||
case 'failed':
|
||
return appendRetryLabel('실패');
|
||
case 'cancelled':
|
||
return appendRetryLabel('취소됨');
|
||
case 'removed':
|
||
return appendRetryLabel('삭제됨');
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveProcessingStageBadgeLabel(stageKey: string | undefined) {
|
||
switch (stageKey) {
|
||
case 'execution':
|
||
return '구현중';
|
||
case 'result':
|
||
return '검증중';
|
||
case 'analysis':
|
||
case 'inspection':
|
||
case 'confirmation':
|
||
default:
|
||
return '분석중';
|
||
}
|
||
}
|
||
|
||
function hasSourceAppliedResult(request: ChatConversationRequest | undefined, lines: string[]) {
|
||
if (!request) {
|
||
return false;
|
||
}
|
||
|
||
const responseText = String(request.responseText ?? '').trim();
|
||
const activityText = lines.join('\n');
|
||
|
||
return (
|
||
/```diff\b/i.test(responseText) ||
|
||
/변경 파일\s*:/u.test(responseText) ||
|
||
/\bapply_patch\b/i.test(activityText)
|
||
);
|
||
}
|
||
|
||
function normalizeActivityPlanTitle(planTitle: string) {
|
||
const normalizedTitle = planTitle.trim();
|
||
|
||
if (!normalizedTitle) {
|
||
return '최초 작업 계획 체크리스트';
|
||
}
|
||
|
||
if (normalizedTitle === '관리자 계획') {
|
||
return '최초 작업 계획 체크리스트';
|
||
}
|
||
|
||
return normalizedTitle.endsWith('체크리스트') ? normalizedTitle : `${normalizedTitle} 체크리스트`;
|
||
}
|
||
|
||
function splitExecutorActivityLine(line: string) {
|
||
const normalizedLine = line.trim();
|
||
const separatorIndex = normalizedLine.indexOf(' $ ');
|
||
|
||
if (separatorIndex < 0) {
|
||
return {
|
||
focus: normalizedLine || '대기 중',
|
||
command: null,
|
||
commandSummary: null,
|
||
};
|
||
}
|
||
|
||
const focus = normalizedLine.slice(0, separatorIndex).trim() || '진행 항목';
|
||
const command = normalizedLine.slice(separatorIndex + 3).trim() || null;
|
||
const descriptor = describeExecutorCommand(command, focus);
|
||
|
||
return {
|
||
focus,
|
||
command,
|
||
commandSummary: descriptor.message,
|
||
};
|
||
}
|
||
|
||
function extractSystemExecutionActivityOverview(lines: string[]): SystemExecutionActivityOverview {
|
||
const normalizedLines = lines.map((line) => line.trim()).filter(Boolean);
|
||
const planTitleLine = normalizedLines.find((line) => line.startsWith('# 계획:')) ?? '';
|
||
const planTitle = planTitleLine ? planTitleLine.slice('# 계획:'.length).trim() : '';
|
||
const executorMap = new Map<
|
||
string,
|
||
{
|
||
key: string;
|
||
title: string;
|
||
line: string;
|
||
focus: string;
|
||
command: string | null;
|
||
commandSummary: string | null;
|
||
updateCount: number;
|
||
}
|
||
>();
|
||
const fallbackExecutorLines: string[] = [];
|
||
|
||
normalizedLines.forEach((line, index) => {
|
||
if (!line.startsWith('실행기:')) {
|
||
if (line.startsWith('# 진행:') && line.includes(' $ ')) {
|
||
fallbackExecutorLines.push(line.slice('# 진행:'.length).trim());
|
||
}
|
||
return;
|
||
}
|
||
|
||
const executorPayload = line.slice('실행기:'.length).trim();
|
||
const separatorIndex = executorPayload.indexOf(' · ');
|
||
const title = (separatorIndex >= 0 ? executorPayload.slice(0, separatorIndex) : executorPayload).trim() || '실행기';
|
||
const message = (separatorIndex >= 0 ? executorPayload.slice(separatorIndex + 3) : '').trim() || '대기 중';
|
||
const details = splitExecutorActivityLine(message);
|
||
const existing = Array.from(executorMap.values()).find((entry) => entry.title === title);
|
||
|
||
if (existing) {
|
||
existing.line = message;
|
||
existing.focus = details.focus;
|
||
existing.command = details.command;
|
||
existing.commandSummary = details.commandSummary;
|
||
existing.updateCount += 1;
|
||
return;
|
||
}
|
||
|
||
executorMap.set(`${title}:${index}`, {
|
||
key: `${title}:${index}`,
|
||
title,
|
||
line: message,
|
||
focus: details.focus,
|
||
command: details.command,
|
||
commandSummary: details.commandSummary,
|
||
updateCount: 1,
|
||
});
|
||
});
|
||
|
||
if (executorMap.size === 0 && fallbackExecutorLines.length > 0) {
|
||
const latestExecutorLine = fallbackExecutorLines[fallbackExecutorLines.length - 1] ?? '';
|
||
const details = splitExecutorActivityLine(latestExecutorLine);
|
||
const message = details.command ?? details.focus;
|
||
|
||
executorMap.set('generic-executor-log', {
|
||
key: 'generic-executor-log',
|
||
title: '실행기 로그',
|
||
line: message,
|
||
focus: details.focus,
|
||
command: details.command,
|
||
commandSummary: details.commandSummary,
|
||
updateCount: fallbackExecutorLines.length,
|
||
});
|
||
}
|
||
|
||
return {
|
||
lines: normalizedLines.filter((line) => !line.startsWith('# 계획:') && !line.startsWith('# 실행기:')),
|
||
planTitle: normalizeActivityPlanTitle(planTitle),
|
||
executors: Array.from(executorMap.values()),
|
||
};
|
||
}
|
||
|
||
function hasVisibleActivityOverviewContent(
|
||
activityOverview: SystemExecutionActivityOverview | null | undefined,
|
||
request?: ChatConversationRequest,
|
||
attentionState?: SystemExecutionAttentionState,
|
||
) {
|
||
if (!activityOverview) {
|
||
return false;
|
||
}
|
||
|
||
const isUserFinalized = request ? isRequestUserFinalized(request, attentionState) : false;
|
||
const isInProgress =
|
||
request == null ||
|
||
request.status === 'accepted' ||
|
||
request.status === 'queued' ||
|
||
request.status === 'started';
|
||
const hasPendingAttention = attentionState?.hasOwnAttentionState === true;
|
||
const hasChecklistEntries = buildChatActivityChecklistEntries(activityOverview.lines, request).length > 0;
|
||
const hasExecutorEntries = activityOverview.executors.length > 0;
|
||
|
||
if (isUserFinalized || (!isInProgress && !hasPendingAttention)) {
|
||
return false;
|
||
}
|
||
|
||
return hasChecklistEntries || hasExecutorEntries;
|
||
}
|
||
|
||
function resolveVisibleActivityOverviewTargetRequest(
|
||
requests: ChatConversationRequest[],
|
||
activityOverviewByRequestId: Map<string, SystemExecutionActivityOverview>,
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
) {
|
||
const visibleRequests = resolveVisibleActivityOverviewRequests(
|
||
requests,
|
||
activityOverviewByRequestId,
|
||
attentionStateByRequestId,
|
||
);
|
||
|
||
if (visibleRequests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return resolveRepresentativeSystemExecutionRequest(visibleRequests, attentionStateByRequestId) ?? visibleRequests[0]!;
|
||
}
|
||
|
||
function resolveVisibleActivityOverviewRequests(
|
||
requests: ChatConversationRequest[],
|
||
activityOverviewByRequestId: Map<string, SystemExecutionActivityOverview>,
|
||
attentionStateByRequestId: Map<string, SystemExecutionAttentionState>,
|
||
) {
|
||
return requests.filter((request) =>
|
||
hasVisibleActivityOverviewContent(
|
||
activityOverviewByRequestId.get(request.requestId),
|
||
request,
|
||
attentionStateByRequestId.get(request.requestId),
|
||
),
|
||
);
|
||
}
|
||
|
||
function renderActivityOverviewBody(
|
||
activityOverview: SystemExecutionActivityOverview,
|
||
messageId: number,
|
||
request?: ChatConversationRequest,
|
||
options?: {
|
||
className?: string;
|
||
attentionState?: SystemExecutionAttentionState;
|
||
requestTree?: ReactNode;
|
||
},
|
||
) {
|
||
const isUserFinalized = request ? isRequestUserFinalized(request, options?.attentionState) : false;
|
||
const showChecklist =
|
||
buildChatActivityChecklistEntries(activityOverview.lines, request).length > 0 && !isUserFinalized;
|
||
const showExecutors = activityOverview.executors.length > 0 && !isUserFinalized;
|
||
const primaryExecutorSummary = activityOverview.executors.find((executor) => executor.commandSummary)?.commandSummary ?? '';
|
||
const primaryExecutor = activityOverview.executors.find((executor) => executor.commandSummary);
|
||
const primaryExecutorDescriptor = primaryExecutor
|
||
? describeExecutorCommand(primaryExecutor.command, primaryExecutor.focus)
|
||
: null;
|
||
const checklistStatusLabel = primaryExecutorDescriptor
|
||
? `현재 작업: ${primaryExecutorDescriptor.focusLabel} · ${primaryExecutorDescriptor.kindLabel} 단계에서 ${primaryExecutorDescriptor.detailLabel}`
|
||
: primaryExecutorSummary
|
||
? `현재 작업: ${primaryExecutorSummary}`
|
||
: undefined;
|
||
|
||
if (!showChecklist && !showExecutors && !options?.requestTree) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={['app-chat-activity-checklist-stack', options?.className].filter(Boolean).join(' ')}
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
{showChecklist ? (
|
||
<ChatActivityChecklist
|
||
lines={activityOverview.lines}
|
||
request={request}
|
||
title={activityOverview.planTitle || '최초 작업 계획 체크리스트'}
|
||
statusLabel={checklistStatusLabel}
|
||
/>
|
||
) : null}
|
||
{showExecutors
|
||
? activityOverview.executors.map((executor) => {
|
||
const descriptor = describeExecutorCommand(executor.command, executor.focus);
|
||
|
||
return (
|
||
<section
|
||
key={`${messageId}:${executor.key}`}
|
||
className="app-chat-activity-checklist app-chat-activity-checklist--ticker"
|
||
aria-label={`${executor.title} 상태`}
|
||
>
|
||
<div className="app-chat-activity-checklist__header">
|
||
<div className="app-chat-activity-checklist__title-group">
|
||
<span className="app-chat-activity-checklist__title">{executor.title} 실행기</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="app-chat-activity-ticker"
|
||
title={[descriptor.focusLabel, descriptor.message].filter(Boolean).join(' · ')}
|
||
>
|
||
<div className="app-chat-activity-ticker__section">
|
||
<span className="app-chat-activity-ticker__label">대상 작업</span>
|
||
<p className="app-chat-activity-ticker__body">{descriptor.focusLabel}</p>
|
||
</div>
|
||
<div className="app-chat-activity-ticker__section">
|
||
<span className="app-chat-activity-ticker__label">현재 단계</span>
|
||
<p className="app-chat-activity-ticker__body">{descriptor.kindLabel}</p>
|
||
</div>
|
||
<div className="app-chat-activity-ticker__section">
|
||
<span className="app-chat-activity-ticker__label">실행 내용</span>
|
||
<p className="app-chat-activity-ticker__body">{descriptor.detailLabel}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
})
|
||
: null}
|
||
{options?.requestTree ?? null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function getSystemExecutionSortPriority(request: ChatConversationRequest | undefined) {
|
||
switch (request?.status) {
|
||
case 'started':
|
||
return 0;
|
||
case 'accepted':
|
||
return 1;
|
||
case 'queued':
|
||
return 2;
|
||
case 'failed':
|
||
return 3;
|
||
case 'completed':
|
||
return 4;
|
||
case 'cancelled':
|
||
case 'removed':
|
||
return 5;
|
||
default:
|
||
return 6;
|
||
}
|
||
}
|
||
|
||
function compareSystemExecutionRequests(
|
||
left: ChatConversationRequest,
|
||
right: ChatConversationRequest,
|
||
sort: SystemExecutionSort,
|
||
) {
|
||
if (sort === 'answered') {
|
||
const leftAnsweredAt = left.answeredAt || left.terminalAt || left.updatedAt || left.createdAt;
|
||
const rightAnsweredAt = right.answeredAt || right.terminalAt || right.updatedAt || right.createdAt;
|
||
return (
|
||
leftAnsweredAt.localeCompare(rightAnsweredAt) ||
|
||
left.updatedAt.localeCompare(right.updatedAt) ||
|
||
left.requestId.localeCompare(right.requestId)
|
||
);
|
||
}
|
||
|
||
if (sort === 'status') {
|
||
return (
|
||
getSystemExecutionSortPriority(left) - getSystemExecutionSortPriority(right) ||
|
||
left.createdAt.localeCompare(right.createdAt) ||
|
||
left.updatedAt.localeCompare(right.updatedAt) ||
|
||
left.requestId.localeCompare(right.requestId)
|
||
);
|
||
}
|
||
|
||
return (
|
||
left.createdAt.localeCompare(right.createdAt) ||
|
||
left.updatedAt.localeCompare(right.updatedAt) ||
|
||
left.requestId.localeCompare(right.requestId)
|
||
);
|
||
}
|
||
|
||
function getRequestDetailBadge(request: ChatConversationRequest | undefined): SystemExecutionBadge | null {
|
||
if (!request) {
|
||
return null;
|
||
}
|
||
|
||
if (request.status === 'failed') {
|
||
return { label: '확인 필요', shortLabel: '확인', tone: 'failed' as const };
|
||
}
|
||
|
||
if (request.status === 'cancelled' || request.status === 'removed') {
|
||
return { label: '중단됨', shortLabel: '중단', tone: 'cancelled' as const };
|
||
}
|
||
|
||
if (request.status === 'completed') {
|
||
return null;
|
||
}
|
||
|
||
if (request.status === 'started') {
|
||
return null;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getRequestRetryBadge(request: ChatConversationRequest | undefined): SystemExecutionBadge | null {
|
||
const retryCount = Math.max(0, Number(request?.retryCount ?? 0) || 0);
|
||
|
||
if (retryCount <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
label: `재처리 ${retryCount}회`,
|
||
shortLabel: `재처리 ${retryCount}`,
|
||
tone: 'processing',
|
||
};
|
||
}
|
||
|
||
function buildChecklistStageBadge(lines: string[], request?: ChatConversationRequest): SystemExecutionBadge | null {
|
||
const entries = buildChatActivityChecklistEntries(lines, request);
|
||
const activeEntry =
|
||
entries.find((entry) => entry.state === 'error') ??
|
||
entries.find((entry) => entry.state === 'current') ??
|
||
[...entries].reverse().find((entry) => entry.state === 'complete') ??
|
||
null;
|
||
|
||
if (!activeEntry) {
|
||
return null;
|
||
}
|
||
|
||
if (request?.status === 'started' && !hasAnsweredRequest(request)) {
|
||
const label = resolveProcessingStageBadgeLabel(activeEntry.key);
|
||
return {
|
||
label,
|
||
shortLabel: label.replace('중', ''),
|
||
tone: 'attention',
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: activeEntry.state === 'error' ? `${activeEntry.label} 확인 필요` : activeEntry.label,
|
||
shortLabel: activeEntry.label
|
||
.replace('요청 접수', '접수')
|
||
.replace('요청 분석', '분석')
|
||
.replace('관련 확인', '확인')
|
||
.replace('구현·응답 작성', '구현')
|
||
.replace('검증·결과 정리', '검증'),
|
||
tone:
|
||
activeEntry.state === 'error'
|
||
? 'failed'
|
||
: activeEntry.state === 'complete'
|
||
? 'completed'
|
||
: 'started',
|
||
};
|
||
}
|
||
|
||
function buildPromptStateBadge(options: {
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
submittedCount: number;
|
||
isManuallyCompleted?: boolean;
|
||
isPromptManuallyCompleted?: boolean;
|
||
}): SystemExecutionBadge | null {
|
||
const { promptTargets, submittedCount, isManuallyCompleted } = options;
|
||
|
||
if (promptTargets.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const resolvedCount = promptTargets.filter((target) => isPromptResolved(target)).length;
|
||
const unresolvedCount = Math.max(0, promptTargets.length - resolvedCount);
|
||
const autoSubmittedCount = Math.min(Math.max(0, submittedCount), unresolvedCount);
|
||
const pendingCount = Math.max(0, unresolvedCount - autoSubmittedCount);
|
||
|
||
if (pendingCount > 0) {
|
||
if (isManuallyCompleted) {
|
||
return {
|
||
label: '선택완료',
|
||
shortLabel: '완료',
|
||
tone: 'completed',
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: '선택대기',
|
||
shortLabel: '선택',
|
||
tone: 'attention',
|
||
};
|
||
}
|
||
|
||
if (autoSubmittedCount > 0) {
|
||
return {
|
||
label: '선택전송됨',
|
||
shortLabel: '전송됨',
|
||
tone: 'completed',
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: '선택완료',
|
||
shortLabel: '완료',
|
||
tone: 'completed',
|
||
};
|
||
}
|
||
|
||
function hasPendingPromptState(options: {
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
submittedCount: number;
|
||
isManuallyCompleted?: boolean;
|
||
}) {
|
||
const { promptTargets, submittedCount, isManuallyCompleted } = options;
|
||
|
||
if (promptTargets.length === 0 || isManuallyCompleted) {
|
||
return false;
|
||
}
|
||
|
||
const resolvedCount = promptTargets.filter((target) => isPromptResolved(target)).length;
|
||
const unresolvedCount = Math.max(0, promptTargets.length - resolvedCount);
|
||
const autoSubmittedCount = Math.min(Math.max(0, submittedCount), unresolvedCount);
|
||
|
||
return unresolvedCount - autoSubmittedCount > 0;
|
||
}
|
||
|
||
function buildVerificationStateBadge(options: {
|
||
request: ChatConversationRequest | undefined;
|
||
activityLines: string[];
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
hasVerificationTarget: boolean;
|
||
hasConfirmedVerificationTarget: boolean;
|
||
isManuallyCompleted?: boolean;
|
||
isPromptManuallyCompleted?: boolean;
|
||
}): SystemExecutionBadge | null {
|
||
const {
|
||
request,
|
||
activityLines,
|
||
promptTargets,
|
||
hasVerificationTarget,
|
||
hasConfirmedVerificationTarget,
|
||
isManuallyCompleted,
|
||
isPromptManuallyCompleted,
|
||
} = options;
|
||
|
||
if (!request || isPromptManuallyCompleted || promptTargets.length > 0 || !hasVerificationTarget) {
|
||
return null;
|
||
}
|
||
|
||
const verificationText = [request.userText, request.responseText, ...activityLines].join('\n');
|
||
const usesVerificationLabel = VERIFICATION_REQUEST_PATTERN.test(verificationText);
|
||
const completedLabel = usesVerificationLabel ? '검증 확인' : '응답 확인';
|
||
const pendingLabel = usesVerificationLabel ? '검증 미확인' : '응답 미확인';
|
||
|
||
if (!usesVerificationLabel) {
|
||
if (!request.hasResponse && request.status !== 'completed') {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return isManuallyCompleted
|
||
? {
|
||
label: completedLabel,
|
||
shortLabel: '확인',
|
||
tone: 'completed',
|
||
}
|
||
: {
|
||
label: pendingLabel,
|
||
shortLabel: '미확인',
|
||
tone: 'attention',
|
||
};
|
||
}
|
||
|
||
function hasPendingVerificationState(options: {
|
||
request: ChatConversationRequest | undefined;
|
||
activityLines: string[];
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
hasVerificationTarget: boolean;
|
||
hasConfirmedVerificationTarget: boolean;
|
||
isManuallyCompleted?: boolean;
|
||
isPromptManuallyCompleted?: boolean;
|
||
}) {
|
||
return buildVerificationStateBadge(options)?.tone === 'attention';
|
||
}
|
||
|
||
function buildManualCompletionTypes(options: {
|
||
hasPendingPromptBadge: boolean;
|
||
hasPendingVerificationBadge: boolean;
|
||
}) {
|
||
const completionTypes: Array<'prompt' | 'verification'> = [];
|
||
|
||
if (options.hasPendingPromptBadge) {
|
||
completionTypes.push('prompt');
|
||
}
|
||
|
||
if (options.hasPendingVerificationBadge) {
|
||
completionTypes.push('verification');
|
||
}
|
||
|
||
return completionTypes;
|
||
}
|
||
|
||
function resolvePrimaryManualCompletionType(options: {
|
||
secondaryBadges: SystemExecutionBadge[];
|
||
promptStateBadge: SystemExecutionBadge | null;
|
||
verificationStateBadge: SystemExecutionBadge | null;
|
||
hasPendingPromptBadge: boolean;
|
||
hasPendingVerificationBadge: boolean;
|
||
}): 'prompt' | 'verification' | null {
|
||
const candidates: Array<{
|
||
type: 'prompt' | 'verification';
|
||
badge: SystemExecutionBadge | null;
|
||
pending: boolean;
|
||
}> = [
|
||
{
|
||
type: 'prompt',
|
||
badge: options.promptStateBadge,
|
||
pending: options.hasPendingPromptBadge,
|
||
},
|
||
{
|
||
type: 'verification',
|
||
badge: options.verificationStateBadge,
|
||
pending: options.hasPendingVerificationBadge,
|
||
},
|
||
];
|
||
|
||
const actionableCandidate = candidates
|
||
.filter((candidate) => candidate.pending && candidate.badge != null)
|
||
.sort((left, right) => {
|
||
const leftIndex = options.secondaryBadges.findIndex((badge) => badge === left.badge);
|
||
const rightIndex = options.secondaryBadges.findIndex((badge) => badge === right.badge);
|
||
|
||
return leftIndex - rightIndex;
|
||
})[0];
|
||
|
||
return actionableCandidate?.type ?? null;
|
||
}
|
||
|
||
function buildPrimaryStatusBadge(
|
||
request: ChatConversationRequest | undefined,
|
||
options: {
|
||
activityLines: string[];
|
||
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
|
||
hasPendingPromptBadge?: boolean;
|
||
hasPendingVerificationBadge?: boolean;
|
||
},
|
||
): SystemExecutionBadge {
|
||
if (hasAnsweredRequest(request) && request?.status !== 'completed') {
|
||
return { label: '답변도착', shortLabel: '답변', tone: 'completed' };
|
||
}
|
||
|
||
switch (request?.status) {
|
||
case 'accepted':
|
||
return { label: '접수', shortLabel: '접수', tone: 'queued' };
|
||
case 'queued':
|
||
return { label: '대기중', shortLabel: '대기', tone: 'queued' };
|
||
case 'started':
|
||
return { label: '처리중', shortLabel: '처리', tone: 'started' };
|
||
case 'completed':
|
||
if (options.hasPendingPromptBadge || options.hasPendingVerificationBadge) {
|
||
return { label: "확인대기", shortLabel: "확인", tone: "attention" };
|
||
}
|
||
|
||
if (hasSourceAppliedResult(request, options.activityLines)) {
|
||
return { label: '반영완료', shortLabel: '반영', tone: 'completed' };
|
||
}
|
||
|
||
if (request?.hasResponse) {
|
||
return { label: '답변완료', shortLabel: '답변', tone: 'completed' };
|
||
}
|
||
|
||
return { label: '처리완료', shortLabel: '완료', tone: 'completed' };
|
||
case 'failed':
|
||
return { label: '실패', shortLabel: '실패', tone: 'failed' };
|
||
case 'cancelled':
|
||
return { label: '취소됨', shortLabel: '취소', tone: 'cancelled' };
|
||
case 'removed':
|
||
return { label: '삭제됨', shortLabel: '삭제', tone: 'cancelled' };
|
||
default:
|
||
return { label: '처리중', shortLabel: '처리', tone: 'neutral' };
|
||
}
|
||
}
|
||
|
||
function buildReadStateBadge(
|
||
request: ChatConversationRequest,
|
||
lastReadResponseMessageId: number | null,
|
||
): SystemExecutionBadge | null {
|
||
if (request.responseMessageId == null || !request.hasResponse) {
|
||
return null;
|
||
}
|
||
|
||
const readWatermark = typeof lastReadResponseMessageId === 'number' ? lastReadResponseMessageId : 0;
|
||
const isRead = request.responseMessageId <= readWatermark;
|
||
|
||
return {
|
||
label: isRead ? '답변 읽음' : '답변 안읽음',
|
||
shortLabel: isRead ? '읽음' : '새답',
|
||
tone: isRead ? 'neutral' : 'unread',
|
||
};
|
||
}
|
||
|
||
function hasReadResponse(request: ChatConversationRequest | undefined, lastReadResponseMessageId: number | null) {
|
||
if (!request || request.responseMessageId == null || !request.hasResponse) {
|
||
return false;
|
||
}
|
||
|
||
const readWatermark = typeof lastReadResponseMessageId === 'number' ? lastReadResponseMessageId : 0;
|
||
return request.responseMessageId <= readWatermark;
|
||
}
|
||
|
||
function buildHierarchyBadge(
|
||
request: ChatConversationRequest,
|
||
depth: number,
|
||
): SystemExecutionBadge | null {
|
||
if (depth <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
label: '자식 요청',
|
||
shortLabel: '자식',
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
function buildRootRelationshipBadge(
|
||
rootRequest: ChatConversationRequest,
|
||
groupedRequests: ChatConversationRequest[],
|
||
): SystemExecutionBadge | null {
|
||
const childRequests = groupedRequests.filter((request) => request.requestId !== rootRequest.requestId);
|
||
|
||
if (childRequests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
label: `자식 ${childRequests.length}건`,
|
||
shortLabel: `자식${childRequests.length}`,
|
||
tone: 'neutral',
|
||
};
|
||
}
|
||
|
||
function compareSystemExecutionHierarchyRequests(
|
||
left: ChatConversationRequest,
|
||
right: ChatConversationRequest,
|
||
sort: SystemExecutionSort,
|
||
) {
|
||
return compareSystemExecutionRequests(left, right, sort);
|
||
}
|
||
|
||
function buildMobileSecondaryBadge(
|
||
request: ChatConversationRequest,
|
||
badges: SystemExecutionBadge[],
|
||
): SystemExecutionBadge | null {
|
||
if (badges.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const tonePriority: Record<SystemExecutionBadgeTone, number> = {
|
||
failed: 0,
|
||
unread: 1,
|
||
attention: 2,
|
||
prompt: 3,
|
||
cancelled: 4,
|
||
started: 5,
|
||
completed: 6,
|
||
queued: 7,
|
||
neutral: 8,
|
||
};
|
||
const shouldKeepBadge = (badge: SystemExecutionBadge) => {
|
||
if (badge.tone === 'neutral' && badge.shortLabel === '읽음') {
|
||
return false;
|
||
}
|
||
|
||
if (request.status === 'accepted' || request.status === 'queued') {
|
||
return badge.shortLabel !== '대기';
|
||
}
|
||
|
||
if (request.status === 'started') {
|
||
return badge.shortLabel !== '실행' && badge.shortLabel !== '작성';
|
||
}
|
||
|
||
if (request.status === 'failed') {
|
||
return badge.shortLabel !== '확인';
|
||
}
|
||
|
||
if (request.status === 'cancelled' || request.status === 'removed') {
|
||
return badge.shortLabel !== '중단';
|
||
}
|
||
|
||
return true;
|
||
};
|
||
const deduped = badges.filter((badge, index, collection) => {
|
||
const normalized = badge.shortLabel.trim();
|
||
|
||
return normalized.length > 0 && collection.findIndex((item) => item.shortLabel.trim() === normalized) === index;
|
||
});
|
||
const filtered = deduped.filter(shouldKeepBadge);
|
||
const hasActionableStateBadge = filtered.some(
|
||
(badge) =>
|
||
badge.tone === 'attention' ||
|
||
badge.tone === 'completed' ||
|
||
badge.tone === 'failed' ||
|
||
badge.tone === 'unread',
|
||
);
|
||
const prioritized = hasActionableStateBadge
|
||
? filtered.filter((badge) => !(badge.tone === 'prompt' && badge.shortLabel === '후속'))
|
||
: filtered;
|
||
const selected = prioritized
|
||
.sort((left, right) => tonePriority[left.tone] - tonePriority[right.tone])
|
||
.slice(0, 2);
|
||
|
||
if (selected.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
label: selected.map((badge) => badge.label).join(' · '),
|
||
shortLabel: selected.map((badge) => badge.shortLabel).join('·'),
|
||
tone: selected[0]?.tone ?? 'neutral',
|
||
};
|
||
}
|
||
|
||
function getRequestDetailText(request: ChatConversationRequest | undefined) {
|
||
if (!request) {
|
||
return '';
|
||
}
|
||
|
||
const normalizedStatusMessage = String(request.statusMessage ?? '').trim();
|
||
|
||
if (!normalizedStatusMessage) {
|
||
return '';
|
||
}
|
||
|
||
if (request.status === 'failed') {
|
||
return normalizedStatusMessage.startsWith('실패')
|
||
? normalizedStatusMessage
|
||
: `실패 사유: ${normalizedStatusMessage}`;
|
||
}
|
||
|
||
if (request.status === 'cancelled') {
|
||
return normalizedStatusMessage.startsWith('취소')
|
||
? normalizedStatusMessage
|
||
: `취소 사유: ${normalizedStatusMessage}`;
|
||
}
|
||
|
||
if (request.status === 'removed') {
|
||
return normalizedStatusMessage;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function summarizeSystemExecutionRequestText(
|
||
request: ChatConversationRequest,
|
||
options?: {
|
||
preferResponse?: boolean;
|
||
maxLength?: number;
|
||
},
|
||
) {
|
||
const preferResponse = options?.preferResponse === true;
|
||
const maxLength = options?.maxLength ?? DEFAULT_QUEUE_SUMMARY_MAX_LENGTH;
|
||
const responseText = String(request.responseText ?? '').replace(/\s+/g, ' ').trim();
|
||
const requestText = String(request.userText ?? '').replace(/\s+/g, ' ').trim();
|
||
|
||
if (preferResponse && responseText) {
|
||
return summarizeQueuedText(responseText, maxLength);
|
||
}
|
||
|
||
if (requestText) {
|
||
return summarizeQueuedText(requestText, maxLength);
|
||
}
|
||
|
||
if (responseText) {
|
||
return summarizeQueuedText(responseText, maxLength);
|
||
}
|
||
|
||
const detailText = getRequestDetailText(request);
|
||
return detailText ? summarizeQueuedText(detailText, maxLength) : '표시할 요청 내용이 없습니다.';
|
||
}
|
||
|
||
function resolveSystemExecutionRequestTimestamp(request: ChatConversationRequest) {
|
||
return formatChatTimestamp(resolveSystemExecutionElapsedEndTime(request) || request.createdAt);
|
||
}
|
||
|
||
function InlineMessagePreview({
|
||
target,
|
||
isExpanded,
|
||
hasModalPreview,
|
||
onOpenModalPreview,
|
||
onToggle,
|
||
}: {
|
||
target: InlinePreviewTarget;
|
||
isExpanded: boolean;
|
||
hasModalPreview: boolean;
|
||
onOpenModalPreview: () => void;
|
||
onToggle: () => void;
|
||
}) {
|
||
const [textPreview, setTextPreview] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [previewError, setPreviewError] = useState('');
|
||
const [previewContentType, setPreviewContentType] = useState('');
|
||
const normalizedPreviewUrl = useMemo(() => normalizeChatResourceUrl(target.url), [target.url]);
|
||
|
||
useEffect(() => {
|
||
if (!isExpanded) {
|
||
return;
|
||
}
|
||
|
||
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
|
||
return;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
setIsLoading(true);
|
||
setPreviewError('');
|
||
setPreviewContentType('');
|
||
|
||
fetch(normalizedPreviewUrl, {
|
||
cache: 'no-store',
|
||
credentials: 'include',
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
if (!response.ok) {
|
||
throw await createPreviewFetchError(response);
|
||
}
|
||
|
||
setPreviewContentType(response.headers.get('content-type') ?? '');
|
||
const text = await response.text();
|
||
setTextPreview(text.slice(0, 1600));
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (controller.signal.aborted) {
|
||
return;
|
||
}
|
||
|
||
setTextPreview('');
|
||
setPreviewContentType('');
|
||
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
|
||
})
|
||
.finally(() => {
|
||
if (!controller.signal.aborted) {
|
||
setIsLoading(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
controller.abort();
|
||
};
|
||
}, [isExpanded, normalizedPreviewUrl, target.kind]);
|
||
|
||
const handleCopyPreview = () => {
|
||
void copyPreviewContent({
|
||
kind: target.kind,
|
||
url: normalizedPreviewUrl,
|
||
fallbackText: textPreview,
|
||
})
|
||
.then((result) => {
|
||
if (result === 'image') {
|
||
message.success('preview 이미지를 복사했습니다.');
|
||
return;
|
||
}
|
||
|
||
if (result === 'url') {
|
||
message.success('preview 이미지 URL을 복사했습니다.');
|
||
return;
|
||
}
|
||
|
||
message.success('preview 내용을 복사했습니다.');
|
||
})
|
||
.catch((error: unknown) => {
|
||
message.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.');
|
||
});
|
||
};
|
||
|
||
const handleSharePreview = () => {
|
||
void sharePreviewLink({
|
||
url: normalizedPreviewUrl,
|
||
title: target.label,
|
||
})
|
||
.then((result) => {
|
||
message.success(result === 'shared' ? 'preview 링크를 공유했습니다.' : 'preview 링크를 복사했습니다.');
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||
return;
|
||
}
|
||
|
||
message.error(error instanceof Error ? error.message : 'preview 링크를 공유하지 못했습니다.');
|
||
});
|
||
};
|
||
|
||
return (
|
||
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
|
||
<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">{target.label}</span>
|
||
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="app-chat-preview-card__actions">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={<CopyOutlined />}
|
||
aria-label="preview 내용 복사"
|
||
onClick={handleCopyPreview}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={hasModalPreview && isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||
aria-label={hasModalPreview && isExpanded ? 'preview 100% 닫기' : 'preview 100%'}
|
||
onClick={hasModalPreview ? onOpenModalPreview : onToggle}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={<ShareAltOutlined />}
|
||
aria-label="preview 공유"
|
||
onClick={handleSharePreview}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={<DownloadOutlined />}
|
||
aria-label="preview 다운로드"
|
||
onClick={() => {
|
||
void triggerResourceDownload(normalizedPreviewUrl, buildPreviewFileName(target)).catch((error: unknown) => {
|
||
message.error(error instanceof Error ? error.message : '다운로드에 실패했습니다.');
|
||
});
|
||
}}
|
||
/>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-preview-card__toggle"
|
||
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
|
||
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
|
||
onClick={onToggle}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{isExpanded ? (
|
||
<div className="app-chat-preview-card__body">
|
||
<ChatPreviewBody
|
||
target={target}
|
||
previewText={textPreview}
|
||
isPreviewLoading={isLoading}
|
||
previewError={previewError}
|
||
previewContentType={previewContentType}
|
||
maxMarkdownBlocks={12}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function DiffMessagePreview({
|
||
diffText,
|
||
fileCount,
|
||
isExpanded,
|
||
isFullscreen,
|
||
onToggle,
|
||
onToggleFullscreen,
|
||
}: {
|
||
diffText: string;
|
||
fileCount: number;
|
||
isExpanded: boolean;
|
||
isFullscreen: boolean;
|
||
onToggle: () => void;
|
||
onToggleFullscreen: () => void;
|
||
}) {
|
||
const handleCopyDiff = () => {
|
||
void copyText(diffText)
|
||
.then(() => {
|
||
message.success('diff를 복사했습니다.');
|
||
})
|
||
.catch((error: unknown) => {
|
||
message.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.');
|
||
});
|
||
};
|
||
|
||
const content = (
|
||
<section
|
||
className={`app-chat-preview-card${isExpanded || isFullscreen ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}${
|
||
isFullscreen ? ' app-chat-preview-card--fullscreen' : ''
|
||
}`}
|
||
>
|
||
<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">Codex Diff</span>
|
||
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}개`}</span>
|
||
</div>
|
||
</div>
|
||
<div className="app-chat-preview-card__actions">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={<CopyOutlined />}
|
||
aria-label="diff 복사"
|
||
onClick={handleCopyDiff}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||
aria-label={isFullscreen ? 'diff 최대화 해제' : 'diff 최대화'}
|
||
onClick={onToggleFullscreen}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-preview-card__action"
|
||
icon={<DownloadOutlined />}
|
||
aria-label="diff 다운로드"
|
||
onClick={() => {
|
||
downloadTextFile(diffText, 'codex-result.diff', 'text/x-diff;charset=utf-8');
|
||
}}
|
||
/>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-preview-card__toggle"
|
||
icon={isExpanded || isFullscreen ? <UpOutlined /> : <DownOutlined />}
|
||
aria-label={isExpanded || isFullscreen ? 'diff 접기' : 'diff 펼치기'}
|
||
onClick={onToggle}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{isExpanded || isFullscreen ? (
|
||
<div className="app-chat-preview-card__body">
|
||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||
<CodexDiffBlock
|
||
diffText={diffText}
|
||
showToolbar={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
|
||
if (isFullscreen && typeof document !== 'undefined') {
|
||
return createPortal(<div className="app-chat-preview-overlay">{content}</div>, document.body);
|
||
}
|
||
|
||
return content;
|
||
}
|
||
|
||
type ChatConversationViewProps = {
|
||
sessionId: string;
|
||
viewportRef: RefObject<HTMLDivElement | null>;
|
||
composerRef: RefObject<TextAreaRef | null>;
|
||
visibleMessages: ChatMessage[];
|
||
activeSystemStatus: string | null;
|
||
isSystemStatusPending: boolean;
|
||
showScrollToBottom: boolean;
|
||
copiedMessageId: number | null;
|
||
draft: string;
|
||
draftVersion: number;
|
||
composerAttachments: ChatComposerAttachment[];
|
||
requestStateMap: Map<string, ChatConversationRequest>;
|
||
lastReadResponseMessageId: number | null;
|
||
isConversationLoading: boolean;
|
||
conversationLoadingLabel: string;
|
||
hasOlderMessages: boolean;
|
||
isLoadingOlderMessages: boolean;
|
||
isPullToLoadArmed: boolean;
|
||
pullToLoadDistance: number;
|
||
selectedChatTypeId: string | null;
|
||
selectedCodexModel: string;
|
||
queuedRequests: QueuedRequestOption[];
|
||
composerAssistActions?: ComposerAssistAction[];
|
||
composerAssistModalTitle?: string;
|
||
chatTypeOptions: ChatTypeOption[];
|
||
codexModelOptions: CodexModelOption[];
|
||
sharedComposerModelTokenLimits?: Record<string, number | null>;
|
||
previewItems: PreviewOption[];
|
||
isResourceStripOpen: boolean;
|
||
showResourceStrip?: boolean;
|
||
showRoomsShareHeader?: boolean;
|
||
roomsShareUseSharedPageNav?: boolean;
|
||
useSharedComposerChrome?: boolean;
|
||
sharedComposerActionVariant?: 'text' | 'icon';
|
||
sharedComposerInputMode?: 'autosize' | 'fixed-scroll';
|
||
useRoomsShareBubbleFlow?: boolean;
|
||
roomsShareHeaderTitle?: string;
|
||
isLiveConnected?: boolean;
|
||
roomsHeaderMenuItems?: MenuProps['items'];
|
||
onRoomsHeaderMenuAction?: (key: string) => void;
|
||
onRoomsHeaderMinimize?: (() => void) | null;
|
||
onRoomsHeaderClose?: (() => void) | null;
|
||
isComposerDisabled: boolean;
|
||
enableExecutionReviewUi?: boolean;
|
||
isMobileViewport: boolean;
|
||
isIpadLikeViewport: boolean;
|
||
isChatTypeSelectionLocked: boolean;
|
||
isComposerAttachmentUploading: boolean;
|
||
isSendWithoutContextEnabled: boolean;
|
||
isImmediateSendPinned: boolean;
|
||
onViewportScroll: () => void;
|
||
onViewportTouchEnd: () => void;
|
||
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
|
||
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
|
||
onDraftChange: (value: string) => void;
|
||
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
|
||
onRemoveComposerAttachment: (attachmentId: string) => void;
|
||
onSelectChatType: (value: string) => void;
|
||
onSelectCodexModel: (value: string) => void;
|
||
onSend: (draftText?: string) => ComposerSendResult;
|
||
onSendReplyToResponse?: (payload: { draftText?: string; parentRequestId: string }) => ComposerSendResult;
|
||
onSendImmediate: (draftText?: string) => ComposerSendResult;
|
||
onToggleImmediateSendPinned: () => void;
|
||
onToggleSendWithoutContext: () => void;
|
||
onClearDraft: () => void;
|
||
onScrollToBottom: () => void;
|
||
onToggleResourceStrip: () => void;
|
||
onLoadOlderMessages?: () => void | Promise<void>;
|
||
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
|
||
onCopyMessage: (message: ChatMessage) => void;
|
||
onRetryMessage: (message: ChatMessage) => void;
|
||
onRetryFailedRequest: (request: ChatConversationRequest) => void;
|
||
onCancelMessage: (message: ChatMessage) => void;
|
||
onDeleteRequest: (message: ChatMessage) => void;
|
||
onCompleteManualRequestBadge: (requestId: string, type: 'prompt' | 'verification') => Promise<void> | void;
|
||
onRemoveQueuedRequest: (requestId: string) => void;
|
||
onPromoteQueuedRequest?: (requestId: string, text: string) => void;
|
||
onShareRequestBundle?: (requestId: string) => void;
|
||
onShareInquiryMessage?: (payload: { requestId: string; messageId: number }) => void;
|
||
onSharePromptTarget?: (payload: {
|
||
requestId: string;
|
||
sourceMessageId: number;
|
||
promptIndex: number;
|
||
promptTitle: string;
|
||
promptSignature: string;
|
||
}) => void;
|
||
onSubmitPrompt: (
|
||
payload: PromptSubmitPayload & { parentRequestId?: string | null; promptIndex: number; sourceMessageId: number },
|
||
) => Promise<boolean>;
|
||
onSubmitChildRequest: (payload: { text: string; parentRequestId: string }) => Promise<boolean>;
|
||
};
|
||
|
||
type ChatComposerInputProps = {
|
||
composerRef: RefObject<TextAreaRef | null>;
|
||
draft: string;
|
||
draftVersion: number;
|
||
forceDraftSyncVersion: number;
|
||
composerPlaceholder: string;
|
||
enableAutoSize?: boolean;
|
||
isComposerDisabled: boolean;
|
||
isComposerAttachmentUploading: boolean;
|
||
queuedRequests: QueuedRequestOption[];
|
||
composerAssistActions?: ComposerAssistAction[];
|
||
composerAssistModalTitle?: string;
|
||
hideAssistTrigger?: boolean;
|
||
hideClearButton?: boolean;
|
||
onPromoteQueuedRequest?: (requestId: string, text: string) => void;
|
||
onRemoveQueuedRequest: (requestId: string) => void;
|
||
onPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;
|
||
onCommitDraft: (nextValue: string) => void;
|
||
onLocalDraftChange: (nextValue: string) => void;
|
||
onSend: (nextValue: string) => void;
|
||
onClear: () => void;
|
||
};
|
||
|
||
const ChatComposerInput = memo(function ChatComposerInput({
|
||
composerRef,
|
||
draft,
|
||
draftVersion,
|
||
forceDraftSyncVersion,
|
||
composerPlaceholder,
|
||
enableAutoSize = false,
|
||
isComposerDisabled,
|
||
isComposerAttachmentUploading,
|
||
queuedRequests,
|
||
composerAssistActions,
|
||
composerAssistModalTitle,
|
||
hideAssistTrigger = false,
|
||
hideClearButton = false,
|
||
onPromoteQueuedRequest,
|
||
onRemoveQueuedRequest,
|
||
onPaste,
|
||
onCommitDraft,
|
||
onLocalDraftChange,
|
||
onSend,
|
||
onClear,
|
||
}: ChatComposerInputProps) {
|
||
const [localDraft, setLocalDraft] = useState(draft);
|
||
const [hasLocalDraft, setHasLocalDraft] = useState(draft.trim().length > 0);
|
||
const [pendingAssistKey, setPendingAssistKey] = useState('');
|
||
const [isAssistModalOpen, setIsAssistModalOpen] = useState(false);
|
||
const [selectedAssistKeys, setSelectedAssistKeys] = useState<string[]>(() =>
|
||
(composerAssistActions ?? []).map((item) => item.key),
|
||
);
|
||
const isFocusedRef = useRef(false);
|
||
const isComposingRef = useRef(false);
|
||
const lastForceDraftSyncVersionRef = useRef(forceDraftSyncVersion);
|
||
|
||
useEffect(() => {
|
||
if (lastForceDraftSyncVersionRef.current !== forceDraftSyncVersion) {
|
||
lastForceDraftSyncVersionRef.current = forceDraftSyncVersion;
|
||
setLocalDraft(draft);
|
||
setHasLocalDraft(draft.trim().length > 0);
|
||
return;
|
||
}
|
||
|
||
// While the user is actively typing, keep the textarea driven by local state.
|
||
// The local send/clear handlers already reset the field immediately, so parent
|
||
// draft updates during reconnect/session churn should not wipe in-progress text.
|
||
if ((isFocusedRef.current || isComposingRef.current) && draft !== localDraft) {
|
||
return;
|
||
}
|
||
|
||
setLocalDraft(draft);
|
||
setHasLocalDraft(draft.trim().length > 0);
|
||
}, [draft, draftVersion, forceDraftSyncVersion, localDraft]);
|
||
|
||
useEffect(() => {
|
||
setSelectedAssistKeys((current) => {
|
||
const nextKeys = (composerAssistActions ?? []).map((item) => item.key);
|
||
|
||
if (nextKeys.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const preservedKeys = current.filter((key) => nextKeys.includes(key));
|
||
return preservedKeys.length > 0 ? preservedKeys : nextKeys;
|
||
});
|
||
}, [composerAssistActions]);
|
||
|
||
const appendAssistText = (resolvedText: string) => {
|
||
const nextValue = localDraft.trim() ? `${localDraft.trimEnd()}\n${resolvedText}` : resolvedText;
|
||
setLocalDraft(nextValue);
|
||
setHasLocalDraft(true);
|
||
onLocalDraftChange(nextValue);
|
||
composerRef.current?.focus({ cursor: 'end' });
|
||
};
|
||
|
||
const handleApplyAssistSelections = async () => {
|
||
const actions = (composerAssistActions ?? []).filter((item) => selectedAssistKeys.includes(item.key));
|
||
|
||
if (actions.length === 0) {
|
||
message.warning('추가할 작업환경 항목을 선택하세요.');
|
||
return;
|
||
}
|
||
|
||
setPendingAssistKey('assist-modal');
|
||
|
||
try {
|
||
const resolvedTexts: string[] = [];
|
||
|
||
for (const action of actions) {
|
||
const resolvedText = (await action.resolveText()).trim();
|
||
if (resolvedText) {
|
||
resolvedTexts.push(resolvedText);
|
||
}
|
||
}
|
||
|
||
if (resolvedTexts.length === 0) {
|
||
message.warning('추가할 작업환경 정보가 없습니다.');
|
||
return;
|
||
}
|
||
|
||
appendAssistText(resolvedTexts.join('\n\n'));
|
||
setIsAssistModalOpen(false);
|
||
} finally {
|
||
setPendingAssistKey((current) => (current === 'assist-modal' ? '' : current));
|
||
}
|
||
};
|
||
|
||
const assistDropdownItems = useMemo<MenuProps['items']>(
|
||
() =>
|
||
!hideAssistTrigger && composerAssistActions && composerAssistActions.length > 0
|
||
? [
|
||
{
|
||
key: 'assist-modal',
|
||
icon: <AppstoreOutlined />,
|
||
label: buildComposerAssistDropdownLabel(composerAssistModalTitle),
|
||
},
|
||
]
|
||
: [],
|
||
[composerAssistActions, composerAssistModalTitle, hideAssistTrigger],
|
||
);
|
||
const hasAssistDropdown = assistDropdownItems.length > 0;
|
||
|
||
return (
|
||
<div
|
||
className={`app-chat-panel__composer-input-shell${
|
||
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
|
||
}${hasAssistDropdown ? ' app-chat-panel__composer-input-shell--with-assist' : ''}${
|
||
enableAutoSize ? ' app-chat-panel__composer-input-shell--autosize' : ''
|
||
}`}
|
||
>
|
||
{queuedRequests.length > 0 ? (
|
||
<div className="app-chat-panel__composer-queue" aria-live="polite">
|
||
<div className="app-chat-panel__composer-queue-count">
|
||
<span>대기열 {queuedRequests.length}건</span>
|
||
</div>
|
||
<div className="app-chat-panel__composer-queue-list">
|
||
{queuedRequests.map((item) => (
|
||
<div key={item.requestId} className="app-chat-panel__composer-queue-chip">
|
||
<div className="app-chat-panel__composer-queue-chip-main">
|
||
<span className="app-chat-panel__composer-queue-order">{item.order}</span>
|
||
<span className="app-chat-panel__composer-queue-text">{summarizeQueuedText(item.text)}</span>
|
||
</div>
|
||
<div className="app-chat-panel__composer-queue-chip-actions">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<ThunderboltOutlined />}
|
||
aria-label="대기 요청 바로 실행"
|
||
onClick={() => {
|
||
onPromoteQueuedRequest?.(item.requestId, item.text);
|
||
}}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
danger
|
||
icon={<CloseOutlined />}
|
||
aria-label="대기 요청 취소"
|
||
onClick={() => {
|
||
onRemoveQueuedRequest(item.requestId);
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Modal
|
||
open={isAssistModalOpen}
|
||
title={composerAssistModalTitle?.trim() || '작업환경 구성'}
|
||
onCancel={() => {
|
||
if (pendingAssistKey === 'assist-modal') {
|
||
return;
|
||
}
|
||
|
||
setIsAssistModalOpen(false);
|
||
}}
|
||
onOk={() => {
|
||
void handleApplyAssistSelections();
|
||
}}
|
||
okText="초안에 추가"
|
||
cancelText="닫기"
|
||
confirmLoading={pendingAssistKey === 'assist-modal'}
|
||
destroyOnHidden={false}
|
||
>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message="현재 채팅방 메뉴 기준으로 필요한 작업환경만 묶어서 초안에 추가합니다."
|
||
className="app-chat-panel__composer-assist-modal-alert"
|
||
/>
|
||
<div className="app-chat-panel__composer-assist-modal-list">
|
||
{(composerAssistActions ?? []).map((item) => {
|
||
const isChecked = selectedAssistKeys.includes(item.key);
|
||
|
||
return (
|
||
<label key={item.key} className="app-chat-panel__composer-assist-option">
|
||
<div className="app-chat-panel__composer-assist-option-main">
|
||
<Checkbox
|
||
checked={isChecked}
|
||
onChange={(event) => {
|
||
setSelectedAssistKeys((current) =>
|
||
event.target.checked ? [...current, item.key] : current.filter((key) => key !== item.key),
|
||
);
|
||
}}
|
||
>
|
||
<span className="app-chat-panel__composer-assist-option-label">
|
||
{resolveComposerAssistIcon(item.key)}
|
||
<span>{item.label}</span>
|
||
</span>
|
||
</Checkbox>
|
||
</div>
|
||
{item.description ? (
|
||
<p className="app-chat-panel__composer-assist-option-description">{item.description}</p>
|
||
) : null}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</Modal>
|
||
|
||
<Input.TextArea
|
||
ref={composerRef}
|
||
value={localDraft}
|
||
autoSize={enableAutoSize ? { minRows: 2, maxRows: 10 } : false}
|
||
placeholder={composerPlaceholder}
|
||
disabled={isComposerDisabled}
|
||
onFocus={() => {
|
||
isFocusedRef.current = true;
|
||
}}
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value;
|
||
setLocalDraft(nextValue);
|
||
setHasLocalDraft(nextValue.trim().length > 0);
|
||
onLocalDraftChange(nextValue);
|
||
}}
|
||
onCompositionStart={() => {
|
||
isComposingRef.current = true;
|
||
}}
|
||
onCompositionEnd={(event) => {
|
||
isComposingRef.current = false;
|
||
const nextValue = event.currentTarget.value;
|
||
setLocalDraft(nextValue);
|
||
setHasLocalDraft(nextValue.trim().length > 0);
|
||
onLocalDraftChange(nextValue);
|
||
}}
|
||
onPaste={onPaste}
|
||
onBlur={() => {
|
||
isFocusedRef.current = false;
|
||
isComposingRef.current = false;
|
||
onCommitDraft(localDraft);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
||
return;
|
||
}
|
||
|
||
const hasSubmitModifier = event.ctrlKey || event.metaKey;
|
||
if (!hasSubmitModifier) {
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
if (isComposerAttachmentUploading) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
onSend(localDraft);
|
||
}}
|
||
/>
|
||
{hasAssistDropdown ? (
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="topRight"
|
||
menu={{
|
||
items: assistDropdownItems,
|
||
onClick: ({ key }) => {
|
||
if (key === 'assist-modal') {
|
||
setIsAssistModalOpen(true);
|
||
}
|
||
},
|
||
}}
|
||
>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<AppstoreOutlined />}
|
||
className="app-chat-panel__composer-assist-trigger"
|
||
title="작업환경 도구"
|
||
aria-label="작업환경 도구"
|
||
/>
|
||
</Dropdown>
|
||
) : null}
|
||
{hideClearButton ? null : (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={`app-chat-panel__composer-clear${hasLocalDraft ? ' app-chat-panel__composer-clear--visible' : ''}`}
|
||
aria-label="입력창 비우기"
|
||
onClick={() => {
|
||
setLocalDraft('');
|
||
setHasLocalDraft(false);
|
||
onLocalDraftChange('');
|
||
onClear();
|
||
}}
|
||
disabled={!hasLocalDraft}
|
||
>
|
||
clear
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
function SharedRoomsRequestCard({
|
||
request,
|
||
attentionState,
|
||
canCompletePrompt = false,
|
||
canCompleteVerification = false,
|
||
canReplyToResponse = false,
|
||
isManualCompletionSaving = false,
|
||
isReplyReferenceActive = false,
|
||
onCompletePrompt,
|
||
onCompleteVerification,
|
||
onReplyToResponse,
|
||
onSelect,
|
||
}: {
|
||
request: ChatConversationRequest;
|
||
attentionState?: SystemExecutionAttentionState;
|
||
canCompletePrompt?: boolean;
|
||
canCompleteVerification?: boolean;
|
||
canReplyToResponse?: boolean;
|
||
isManualCompletionSaving?: boolean;
|
||
isReplyReferenceActive?: boolean;
|
||
onCompletePrompt?: (() => void) | null;
|
||
onCompleteVerification?: (() => void) | null;
|
||
onReplyToResponse?: (() => void) | null;
|
||
onSelect?: (() => void) | null;
|
||
}) {
|
||
const questionText = (request.userText ?? "").trim() || "-";
|
||
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
|
||
const requestStatusLabel = formatRequestStatusLabel(request, attentionState);
|
||
|
||
return (
|
||
<section
|
||
className={`app-chat-message-group${onSelect ? ' app-chat-message-group--interactive' : ''}`}
|
||
role={onSelect ? 'button' : undefined}
|
||
tabIndex={onSelect ? 0 : undefined}
|
||
onClick={
|
||
onSelect
|
||
? () => {
|
||
onSelect();
|
||
}
|
||
: undefined
|
||
}
|
||
onKeyDown={
|
||
onSelect
|
||
? (event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
onSelect();
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<header className="app-chat-message-group__header">
|
||
<div className="app-chat-message-group__header-meta">
|
||
{requestStatusLabel ? (
|
||
<span className="app-chat-message-group__detail">{requestStatusLabel}</span>
|
||
) : null}
|
||
</div>
|
||
<strong className="app-chat-message-group__title">{summarizeQueuedText(questionText, 88)}</strong>
|
||
</header>
|
||
<div className="app-chat-message-group__body">
|
||
<div className="app-chat-bubble app-chat-bubble--user">
|
||
<div className="app-chat-bubble__content">{questionText}</div>
|
||
</div>
|
||
<div className="app-chat-bubble app-chat-bubble--assistant">
|
||
<div className="app-chat-bubble__content">{answerText}</div>
|
||
</div>
|
||
</div>
|
||
{canCompletePrompt || canCompleteVerification || canReplyToResponse ? (
|
||
<div className="app-chat-message__response-actions">
|
||
{canCompletePrompt ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__response-action"
|
||
icon={<CheckOutlined />}
|
||
loading={isManualCompletionSaving}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onCompletePrompt?.();
|
||
}}
|
||
>
|
||
완료 처리
|
||
</Button>
|
||
) : null}
|
||
{canCompleteVerification ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__response-action"
|
||
icon={<CheckOutlined />}
|
||
loading={isManualCompletionSaving}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onCompleteVerification?.();
|
||
}}
|
||
>
|
||
완료 처리
|
||
</Button>
|
||
) : null}
|
||
{canReplyToResponse ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={[
|
||
'app-chat-message__response-action',
|
||
'app-chat-message__response-reply-button',
|
||
isReplyReferenceActive ? 'app-chat-message__response-reply-button--active' : '',
|
||
].filter(Boolean).join(' ')}
|
||
icon={<SendOutlined />}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onReplyToResponse?.();
|
||
}}
|
||
>
|
||
{isReplyReferenceActive ? '답변 참조 중' : '답변하기'}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
export function ChatConversationView({
|
||
sessionId,
|
||
viewportRef,
|
||
composerRef,
|
||
visibleMessages,
|
||
activeSystemStatus,
|
||
isSystemStatusPending,
|
||
showScrollToBottom,
|
||
copiedMessageId,
|
||
draft,
|
||
draftVersion,
|
||
composerAttachments,
|
||
requestStateMap,
|
||
lastReadResponseMessageId,
|
||
isConversationLoading,
|
||
conversationLoadingLabel,
|
||
hasOlderMessages,
|
||
isLoadingOlderMessages,
|
||
isPullToLoadArmed,
|
||
pullToLoadDistance,
|
||
selectedChatTypeId,
|
||
selectedCodexModel,
|
||
queuedRequests,
|
||
composerAssistActions,
|
||
composerAssistModalTitle,
|
||
chatTypeOptions,
|
||
codexModelOptions,
|
||
sharedComposerModelTokenLimits,
|
||
previewItems,
|
||
isResourceStripOpen,
|
||
showResourceStrip = true,
|
||
showRoomsShareHeader = false,
|
||
roomsShareUseSharedPageNav = false,
|
||
useSharedComposerChrome = false,
|
||
sharedComposerActionVariant = 'text',
|
||
sharedComposerInputMode = 'autosize',
|
||
useRoomsShareBubbleFlow = false,
|
||
roomsShareHeaderTitle,
|
||
isLiveConnected = false,
|
||
roomsHeaderMenuItems,
|
||
onRoomsHeaderMenuAction,
|
||
onRoomsHeaderMinimize = null,
|
||
onRoomsHeaderClose = null,
|
||
isComposerDisabled,
|
||
enableExecutionReviewUi = false,
|
||
isMobileViewport,
|
||
isIpadLikeViewport,
|
||
isChatTypeSelectionLocked,
|
||
isComposerAttachmentUploading,
|
||
isSendWithoutContextEnabled,
|
||
isImmediateSendPinned,
|
||
onViewportScroll,
|
||
onViewportTouchEnd,
|
||
onViewportTouchMove,
|
||
onViewportTouchStart,
|
||
onDraftChange,
|
||
onPickComposerFiles,
|
||
onRemoveComposerAttachment,
|
||
onSelectChatType,
|
||
onSelectCodexModel,
|
||
onSend,
|
||
onSendReplyToResponse,
|
||
onSendImmediate,
|
||
onToggleImmediateSendPinned,
|
||
onToggleSendWithoutContext,
|
||
onClearDraft,
|
||
onScrollToBottom,
|
||
onToggleResourceStrip,
|
||
onLoadOlderMessages,
|
||
onOpenPreview,
|
||
onCopyMessage,
|
||
onRetryMessage,
|
||
onRetryFailedRequest,
|
||
onCancelMessage,
|
||
onDeleteRequest,
|
||
onCompleteManualRequestBadge,
|
||
onRemoveQueuedRequest,
|
||
onPromoteQueuedRequest,
|
||
onShareRequestBundle,
|
||
onShareInquiryMessage,
|
||
onSharePromptTarget,
|
||
onSubmitPrompt,
|
||
onSubmitChildRequest,
|
||
}: ChatConversationViewProps) {
|
||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||
const useSharedComposerIconActions = useSharedComposerChrome && sharedComposerActionVariant === 'icon';
|
||
const enableSharedComposerAutoSize = useSharedComposerChrome && sharedComposerInputMode !== 'fixed-scroll';
|
||
const [pendingPromptSelections, setPendingPromptSelections] = useState<Record<string, PendingPromptSelection>>(() => {
|
||
const storedSelections = readStoredPromptSelections(sessionId);
|
||
return Object.fromEntries(
|
||
Object.entries(storedSelections).map(([key, selection]) => [
|
||
key,
|
||
{
|
||
...selection,
|
||
target: {
|
||
type: 'prompt',
|
||
title: selection.promptTitle || 'prompt',
|
||
options: [],
|
||
},
|
||
} satisfies PendingPromptSelection,
|
||
]),
|
||
);
|
||
});
|
||
const [openedPreviewUrls, setOpenedPreviewUrls] = useState<string[]>([]);
|
||
const [openedPreviewArtifactRequestIds, setOpenedPreviewArtifactRequestIds] = useState<string[]>([]);
|
||
const [expandedResponseMessageIds, setExpandedResponseMessageIds] = useState<number[]>([]);
|
||
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
||
const [expandedGroupIds, setExpandedGroupIds] = useState<string[]>([]);
|
||
const [openedChildComposerGroupIds, setOpenedChildComposerGroupIds] = useState<string[]>([]);
|
||
const [childComposerDraftsByGroupId, setChildComposerDraftsByGroupId] = useState<Record<string, string>>({});
|
||
const [submittingChildComposerGroupIds, setSubmittingChildComposerGroupIds] = useState<string[]>([]);
|
||
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
||
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
||
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
|
||
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
|
||
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
|
||
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
|
||
const [dismissedSystemExecutionRequestIds, setDismissedSystemExecutionRequestIds] = useState<string[]>([]);
|
||
const [pendingManualCompletionActionKeys, setPendingManualCompletionActionKeys] = useState<string[]>([]);
|
||
const [expandedSystemExecutionActivityRequestId, setExpandedSystemExecutionActivityRequestId] = useState<string | null>(null);
|
||
const [systemExecutionDisplayMode, setSystemExecutionDisplayMode] = useState<SystemExecutionDisplayMode>('collapsed');
|
||
const [systemExecutionFilter, setSystemExecutionFilter] = useState<SystemExecutionFilter>('active-attention');
|
||
const [systemExecutionSort, setSystemExecutionSort] = useState<SystemExecutionSort>('latest');
|
||
const [roomShareExpandMode, setRoomShareExpandMode] = useState<RoomShareExpandMode>('pending');
|
||
const [selectedRoomShareGroupId, setSelectedRoomShareGroupId] = useState<string | null>(null);
|
||
const [replyReferenceRequestId, setReplyReferenceRequestId] = useState('');
|
||
const [composerForceDraftSyncVersion, setComposerForceDraftSyncVersion] = useState(0);
|
||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
const childComposerRefs = useRef(new Map<string, TextAreaRef | null>());
|
||
const messageAnchorRefs = useRef(new Map<number, HTMLDivElement>());
|
||
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
|
||
const promptCardAnchorRefs = useRef(new Map<number, HTMLElement>());
|
||
const systemExecutionBodyRef = useRef<HTMLDivElement | null>(null);
|
||
const systemExecutionJumpFrameRef = useRef<number | null>(null);
|
||
const systemExecutionOlderLoadRequestedRef = useRef(false);
|
||
const systemExecutionOlderLoadScrollSnapshotRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||
const previousSystemExecutionDisplayModeRef = useRef<SystemExecutionDisplayMode>('collapsed');
|
||
const previousSessionIdRef = useRef(sessionId);
|
||
const shouldFollowLatestRoomShareGroupRef = useRef(false);
|
||
const pendingRoomShareJumpRef = useRef<
|
||
| {
|
||
groupId: string;
|
||
requestId?: string;
|
||
fallbackMessageId?: number;
|
||
}
|
||
| null
|
||
>(null);
|
||
const immediateSendHoldTimerRef = useRef<number | null>(null);
|
||
const suppressImmediateSendClickRef = useRef(false);
|
||
const composerDraftValueRef = useRef(draft);
|
||
const lastSyncedComposerDraftRef = useRef(draft);
|
||
|
||
const setComposerDraftValue = (nextValue: string) => {
|
||
composerDraftValueRef.current = nextValue;
|
||
};
|
||
|
||
const syncComposerDraft = (nextValue: string) => {
|
||
if (lastSyncedComposerDraftRef.current === nextValue) {
|
||
return;
|
||
}
|
||
|
||
lastSyncedComposerDraftRef.current = nextValue;
|
||
onDraftChange(nextValue);
|
||
};
|
||
|
||
const forceComposerDraftSync = () => {
|
||
setComposerForceDraftSyncVersion((current) => current + 1);
|
||
};
|
||
|
||
useEffect(() => {
|
||
lastSyncedComposerDraftRef.current = draft;
|
||
|
||
if (composerDraftValueRef.current === draft) {
|
||
return;
|
||
}
|
||
|
||
if (!draft) {
|
||
composerDraftValueRef.current = '';
|
||
return;
|
||
}
|
||
|
||
composerDraftValueRef.current = draft;
|
||
}, [draft, draftVersion]);
|
||
|
||
useEffect(() => {
|
||
if (previousSessionIdRef.current === sessionId) {
|
||
return;
|
||
}
|
||
|
||
previousSessionIdRef.current = sessionId;
|
||
const storedSelections = readStoredPromptSelections(sessionId);
|
||
setPendingPromptSelections(
|
||
Object.fromEntries(
|
||
Object.entries(storedSelections).map(([key, selection]) => [
|
||
key,
|
||
{
|
||
...selection,
|
||
target: {
|
||
type: 'prompt',
|
||
title: selection.promptTitle || 'prompt',
|
||
options: [],
|
||
},
|
||
} satisfies PendingPromptSelection,
|
||
]),
|
||
),
|
||
);
|
||
setExpandedResponseMessageIds([]);
|
||
setExpandedMessageIds([]);
|
||
setExpandedPreviewKey(null);
|
||
setFullscreenPreviewKey(null);
|
||
setCollapsibleMessageIds([]);
|
||
setOpenedChildComposerGroupIds([]);
|
||
setChildComposerDraftsByGroupId({});
|
||
setReplyReferenceRequestId('');
|
||
setSubmittingChildComposerGroupIds([]);
|
||
setShowBusyOverlay(false);
|
||
setPendingComposerUploads([]);
|
||
setDismissedSystemExecutionRequestIds([]);
|
||
setPendingManualCompletionActionKeys([]);
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
setSystemExecutionDisplayMode('collapsed');
|
||
setSystemExecutionFilter('active-attention');
|
||
setSystemExecutionSort('latest');
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = null;
|
||
previousSystemExecutionDisplayModeRef.current = 'collapsed';
|
||
}, [sessionId]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (immediateSendHoldTimerRef.current !== null) {
|
||
window.clearTimeout(immediateSendHoldTimerRef.current);
|
||
}
|
||
|
||
if (systemExecutionJumpFrameRef.current !== null) {
|
||
window.cancelAnimationFrame(systemExecutionJumpFrameRef.current);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!hasOlderMessages || !isLoadingOlderMessages) {
|
||
systemExecutionOlderLoadRequestedRef.current = false;
|
||
}
|
||
}, [hasOlderMessages, isLoadingOlderMessages]);
|
||
|
||
const clearImmediateSendHoldTimer = () => {
|
||
if (immediateSendHoldTimerRef.current === null) {
|
||
return;
|
||
}
|
||
|
||
window.clearTimeout(immediateSendHoldTimerRef.current);
|
||
immediateSendHoldTimerRef.current = null;
|
||
};
|
||
|
||
const startImmediateSendHoldTimer = () => {
|
||
if (isComposerDisabled || isComposerAttachmentUploading) {
|
||
return;
|
||
}
|
||
|
||
clearImmediateSendHoldTimer();
|
||
immediateSendHoldTimerRef.current = window.setTimeout(() => {
|
||
immediateSendHoldTimerRef.current = null;
|
||
suppressImmediateSendClickRef.current = true;
|
||
onToggleImmediateSendPinned();
|
||
}, IMMEDIATE_SEND_TOGGLE_HOLD_MS);
|
||
};
|
||
|
||
const handleImmediateSendButtonClick = () => {
|
||
if (suppressImmediateSendClickRef.current) {
|
||
suppressImmediateSendClickRef.current = false;
|
||
return;
|
||
}
|
||
|
||
const nextDraft = composerDraftValueRef.current;
|
||
if (onSendImmediate(nextDraft) !== 'sent') {
|
||
return;
|
||
}
|
||
|
||
lastSyncedComposerDraftRef.current = '';
|
||
composerDraftValueRef.current = '';
|
||
onDraftChange('');
|
||
forceComposerDraftSync();
|
||
};
|
||
|
||
const shouldShowConversationLoadingOverlay = isConversationLoading && visibleMessages.length === 0;
|
||
|
||
const orderedMessages = useMemo(() => {
|
||
const latestActivityByRequestId = new Map<string, ChatMessage>();
|
||
const orphanActivityMessages: ChatMessage[] = [];
|
||
const baseMessages = visibleMessages.filter((message) => {
|
||
if (!isActivityLogMessage(message)) {
|
||
return true;
|
||
}
|
||
|
||
const activityKey = message.clientRequestId?.trim();
|
||
|
||
if (!activityKey) {
|
||
orphanActivityMessages.push(message);
|
||
return false;
|
||
}
|
||
|
||
const requestState = requestStateMap.get(activityKey);
|
||
if (requestState?.status !== 'removed') {
|
||
latestActivityByRequestId.set(activityKey, message);
|
||
}
|
||
return false;
|
||
});
|
||
const insertedActivityRequestIds = new Set<string>();
|
||
const ordered: ChatMessage[] = [];
|
||
|
||
baseMessages.forEach((message) => {
|
||
ordered.push(message);
|
||
|
||
if (message.author !== 'user') {
|
||
return;
|
||
}
|
||
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId) {
|
||
return;
|
||
}
|
||
|
||
const activityMessage = latestActivityByRequestId.get(requestId);
|
||
|
||
if (!activityMessage) {
|
||
return;
|
||
}
|
||
|
||
ordered.push(activityMessage);
|
||
insertedActivityRequestIds.add(requestId);
|
||
});
|
||
|
||
latestActivityByRequestId.forEach((message, requestId) => {
|
||
if (!insertedActivityRequestIds.has(requestId)) {
|
||
orphanActivityMessages.push(message);
|
||
}
|
||
});
|
||
|
||
return [...ordered, ...orphanActivityMessages];
|
||
}, [requestStateMap, visibleMessages]);
|
||
useEffect(() => {
|
||
setRoomShareExpandMode('pending');
|
||
}, [useRoomsShareBubbleFlow]);
|
||
|
||
const useSharedRoomsSimplifiedView = showRoomsShareHeader && useSharedComposerChrome && !roomsShareUseSharedPageNav;
|
||
const useSharedRoomsBubbleFlow = showRoomsShareHeader && roomsShareUseSharedPageNav && useRoomsShareBubbleFlow;
|
||
const shouldHideSystemStatusPanel = useSharedRoomsBubbleFlow;
|
||
const messageEntries = useMemo<ConversationMessageEntry[]>(() => {
|
||
if (useSharedRoomsSimplifiedView) {
|
||
return orderedMessages
|
||
.filter((message) => !isActivityLogMessage(message))
|
||
.map(
|
||
(message): ConversationMessageEntry => ({
|
||
kind: 'single',
|
||
key: `message:${message.id}`,
|
||
message,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const entries: ConversationMessageEntry[] = [];
|
||
const groupedEntries = new Map<string, Extract<ConversationMessageEntry, { kind: 'group' }>>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim() || '';
|
||
|
||
if (!requestId) {
|
||
entries.push({
|
||
kind: 'single',
|
||
key: `message:${message.id}`,
|
||
message,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const groupRequestId = resolveConversationMessageGroupRequestId(requestId, requestStateMap) || requestId;
|
||
const existingEntry = groupedEntries.get(groupRequestId);
|
||
|
||
if (existingEntry) {
|
||
existingEntry.messages.push(message);
|
||
if (!existingEntry.requestIds.includes(requestId)) {
|
||
existingEntry.requestIds.push(requestId);
|
||
}
|
||
|
||
if (!existingEntry.request) {
|
||
existingEntry.request = requestStateMap.get(groupRequestId) ?? requestStateMap.get(requestId);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
const nextEntry: Extract<ConversationMessageEntry, { kind: 'group' }> = {
|
||
kind: 'group',
|
||
key: `request-group:${groupRequestId}`,
|
||
groupId: groupRequestId,
|
||
request: requestStateMap.get(groupRequestId) ?? requestStateMap.get(requestId),
|
||
requestIds: [requestId],
|
||
messages: [message],
|
||
};
|
||
|
||
groupedEntries.set(groupRequestId, nextEntry);
|
||
entries.push(nextEntry);
|
||
});
|
||
|
||
return entries.filter((entry) =>
|
||
entry.kind === 'single'
|
||
? !isActivityLogMessage(entry.message)
|
||
: entry.messages.some((message) => !isActivityLogMessage(message)),
|
||
);
|
||
}, [orderedMessages, requestStateMap, useSharedRoomsSimplifiedView]);
|
||
const hasConversationMessages = messageEntries.length > 0;
|
||
const systemExecutionActivityOverviewByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, SystemExecutionActivityOverview>();
|
||
|
||
visibleMessages.forEach((message) => {
|
||
if (!isActivityLogMessage(message)) {
|
||
return;
|
||
}
|
||
|
||
const requestId = message.clientRequestId?.trim() || '';
|
||
|
||
if (!requestId) {
|
||
return;
|
||
}
|
||
|
||
const overview = extractSystemExecutionActivityOverview(
|
||
message.text
|
||
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
|
||
.split('\n\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean),
|
||
);
|
||
|
||
if (overview.lines.length === 0 && overview.executors.length === 0) {
|
||
return;
|
||
}
|
||
|
||
nextMap.set(requestId, overview);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [visibleMessages]);
|
||
useEffect(() => {
|
||
if (systemExecutionDisplayMode === 'expanded') {
|
||
return;
|
||
}
|
||
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
}, [systemExecutionDisplayMode]);
|
||
const messageRenderPayloadById = useMemo(
|
||
() =>
|
||
new Map(
|
||
orderedMessages.map((message) => [message.id, extractMessageRenderPayload(message)] as const),
|
||
),
|
||
[orderedMessages],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setOpenedPreviewUrls([]);
|
||
setExpandedGroupIds([]);
|
||
}, [sessionId]);
|
||
|
||
useEffect(() => {
|
||
const activePromptKeys = new Set<string>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const { promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
|
||
promptTargets.forEach((target, index) => {
|
||
activePromptKeys.add(buildPendingPromptSelectionKey(message.id, index, target.title, target));
|
||
});
|
||
});
|
||
|
||
setPendingPromptSelections((current) => {
|
||
const nextEntries = Object.entries(current).filter(([key]) => activePromptKeys.has(key));
|
||
|
||
if (nextEntries.length === Object.keys(current).length) {
|
||
return current;
|
||
}
|
||
|
||
return Object.fromEntries(nextEntries);
|
||
});
|
||
}, [messageRenderPayloadById, orderedMessages]);
|
||
|
||
useEffect(() => {
|
||
writeStoredPromptSelections(sessionId, pendingPromptSelections);
|
||
}, [pendingPromptSelections, sessionId]);
|
||
|
||
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
|
||
const isChatTypeReadonly = isChatTypeSelectionLocked;
|
||
const selectedChatTypeOption = chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null;
|
||
const sharedComposerChatTypeLabel = selectedChatTypeOption?.label?.trim() || '컨텍스트 없음';
|
||
const normalizedSelectedCodexModel = normalizeCodexModel(selectedCodexModel);
|
||
const selectedCodexModelTokenLimit =
|
||
sharedComposerModelTokenLimits?.[normalizedSelectedCodexModel] ??
|
||
sharedComposerModelTokenLimits?.[selectedCodexModel] ??
|
||
null;
|
||
const normalizedSelectedCodexModelTokenLimit = Number.isFinite(selectedCodexModelTokenLimit)
|
||
? Math.max(0, Math.round(Number(selectedCodexModelTokenLimit)))
|
||
: null;
|
||
const sharedComposerModelUsedTokens = useMemo(() => {
|
||
if (!useSharedComposerChrome) {
|
||
return 0;
|
||
}
|
||
|
||
let total = 0;
|
||
|
||
requestStateMap.forEach((request) => {
|
||
if (resolveRequestCodexModel(request) !== normalizedSelectedCodexModel) {
|
||
return;
|
||
}
|
||
|
||
total += resolveRequestModelTokenUsage(request);
|
||
});
|
||
|
||
return total;
|
||
}, [requestStateMap, normalizedSelectedCodexModel, useSharedComposerChrome]);
|
||
const sharedComposerModelRemainingTokensText = useMemo(() => {
|
||
if (normalizedSelectedCodexModelTokenLimit === null) {
|
||
return `이번 세션 사용량 ${sharedComposerModelUsedTokens.toLocaleString('ko-KR')} 토큰`;
|
||
}
|
||
|
||
const remainingTokens = Math.max(0, normalizedSelectedCodexModelTokenLimit - sharedComposerModelUsedTokens);
|
||
|
||
return `잔여 ${remainingTokens.toLocaleString('ko-KR')} / ${normalizedSelectedCodexModelTokenLimit.toLocaleString(
|
||
'ko-KR',
|
||
)} 토큰`;
|
||
}, [normalizedSelectedCodexModelTokenLimit, sharedComposerModelUsedTokens]);
|
||
const sharedComposerModelTokenLimitLabel =
|
||
sharedComposerModelRemainingTokensText && sharedComposerModelRemainingTokensText.length > 0
|
||
? sharedComposerModelRemainingTokensText
|
||
: '토큰 정보 없음';
|
||
const pendingManualCompletionActionKeySet = useMemo(
|
||
() => new Set(pendingManualCompletionActionKeys),
|
||
[pendingManualCompletionActionKeys],
|
||
);
|
||
const collapsibleMessageIdSet = useMemo(() => new Set(collapsibleMessageIds), [collapsibleMessageIds]);
|
||
const activityLinesByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, string[]>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
if (!isActivityLogMessage(message)) {
|
||
return;
|
||
}
|
||
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId) {
|
||
return;
|
||
}
|
||
|
||
nextMap.set(
|
||
requestId,
|
||
message.text
|
||
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
|
||
.split('\n\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean),
|
||
);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [orderedMessages]);
|
||
const promptTargetsByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, Extract<ChatMessagePart, { type: 'prompt' }>[]>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId || (message.author !== 'codex' && message.author !== 'system')) {
|
||
return;
|
||
}
|
||
|
||
const { promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
|
||
if (promptTargets.length === 0) {
|
||
return;
|
||
}
|
||
|
||
nextMap.set(requestId, promptTargets);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [messageRenderPayloadById, orderedMessages]);
|
||
const firstPromptMessageIdByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, number>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId || nextMap.has(requestId) || (message.author !== 'codex' && message.author !== 'system')) {
|
||
return;
|
||
}
|
||
|
||
const { promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
|
||
if (promptTargets.length === 0) {
|
||
return;
|
||
}
|
||
|
||
nextMap.set(requestId, message.id);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [messageRenderPayloadById, orderedMessages]);
|
||
const promptFollowupCountByParentRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, number>();
|
||
|
||
Array.from(requestStateMap.values()).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;
|
||
}, [requestStateMap]);
|
||
const localSubmittedPromptCountByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, number>();
|
||
|
||
Object.values(pendingPromptSelections).forEach((selection) => {
|
||
const requestId = selection.requestId?.trim();
|
||
|
||
if (!requestId || selection.status !== 'submitted') {
|
||
return;
|
||
}
|
||
|
||
nextMap.set(requestId, (nextMap.get(requestId) ?? 0) + 1);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [pendingPromptSelections]);
|
||
const previewResourceRequestIdsByUrl = useMemo(() => {
|
||
const nextMap = new Map<string, Set<string>>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId || (message.author !== 'codex' && message.author !== 'system')) {
|
||
return;
|
||
}
|
||
|
||
const { previewSourceText, promptTargets } =
|
||
messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||
const promptPreviewUrls = extractPromptPreviewUrls(promptTargets);
|
||
|
||
inlinePreviewTargets.forEach((target) => {
|
||
const normalizedUrl = normalizeChatResourceUrl(target.url);
|
||
const currentRequestIds = nextMap.get(normalizedUrl) ?? new Set<string>();
|
||
currentRequestIds.add(requestId);
|
||
nextMap.set(normalizedUrl, currentRequestIds);
|
||
});
|
||
|
||
promptPreviewUrls.forEach((url) => {
|
||
const normalizedUrl = normalizeChatResourceUrl(url);
|
||
const currentRequestIds = nextMap.get(normalizedUrl) ?? new Set<string>();
|
||
currentRequestIds.add(requestId);
|
||
nextMap.set(normalizedUrl, currentRequestIds);
|
||
});
|
||
});
|
||
|
||
return nextMap;
|
||
}, [messageRenderPayloadById, orderedMessages]);
|
||
const requestIdByMessageId = useMemo(() => {
|
||
const nextMap = new Map<number, string>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (requestId) {
|
||
nextMap.set(message.id, requestId);
|
||
}
|
||
});
|
||
|
||
return nextMap;
|
||
}, [orderedMessages]);
|
||
const openedPreviewRequestIds = useMemo(() => {
|
||
const nextSet = new Set<string>();
|
||
|
||
openedPreviewUrls.forEach((url) => {
|
||
const requestIds = previewResourceRequestIdsByUrl.get(normalizeChatResourceUrl(url));
|
||
|
||
if (!requestIds) {
|
||
return;
|
||
}
|
||
|
||
requestIds.forEach((requestId) => nextSet.add(requestId));
|
||
});
|
||
|
||
return nextSet;
|
||
}, [openedPreviewUrls, previewResourceRequestIdsByUrl]);
|
||
const expandedResponseRequestIds = useMemo(() => {
|
||
const nextSet = new Set<string>();
|
||
|
||
expandedResponseMessageIds.forEach((messageId) => {
|
||
const requestId = requestIdByMessageId.get(messageId);
|
||
|
||
if (requestId) {
|
||
nextSet.add(requestId);
|
||
}
|
||
});
|
||
|
||
return nextSet;
|
||
}, [expandedResponseMessageIds, requestIdByMessageId]);
|
||
const requestIdsWithPreviewArtifacts = useMemo(() => {
|
||
const nextSet = new Set<string>();
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const requestId = message.clientRequestId?.trim();
|
||
|
||
if (!requestId || (message.author !== 'codex' && message.author !== 'system')) {
|
||
return;
|
||
}
|
||
|
||
const { previewSourceText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } =
|
||
messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||
|
||
if (
|
||
diffBlocks.length > 0 ||
|
||
inlinePreviewTargets.length > 0 ||
|
||
rankedLinkTargets.length > 0 ||
|
||
linkCardTargets.length > 0 ||
|
||
hasPromptPreviewArtifact(promptTargets)
|
||
) {
|
||
nextSet.add(requestId);
|
||
}
|
||
});
|
||
|
||
return nextSet;
|
||
}, [messageRenderPayloadById, orderedMessages]);
|
||
const openedPreviewArtifactRequestIdSet = useMemo(
|
||
() => new Set(openedPreviewArtifactRequestIds),
|
||
[openedPreviewArtifactRequestIds],
|
||
);
|
||
const systemExecutionRequests = useMemo(
|
||
() =>
|
||
Array.from(requestStateMap.values())
|
||
.filter((request) => request.status !== 'removed')
|
||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
|
||
[requestStateMap],
|
||
);
|
||
const visibleSystemExecutionRequests = useMemo(
|
||
() =>
|
||
systemExecutionRequests.filter(
|
||
(request) => !dismissedSystemExecutionRequestIds.includes(request.requestId),
|
||
),
|
||
[dismissedSystemExecutionRequestIds, systemExecutionRequests],
|
||
);
|
||
const childRequestIdsByParentRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, string[]>();
|
||
|
||
Array.from(requestStateMap.values()).forEach((request) => {
|
||
const parentRequestId = request.parentRequestId?.trim() || '';
|
||
const requestId = request.requestId.trim();
|
||
|
||
if (!parentRequestId || !requestId || parentRequestId === requestId) {
|
||
return;
|
||
}
|
||
|
||
const currentChildRequestIds = nextMap.get(parentRequestId) ?? [];
|
||
|
||
if (!currentChildRequestIds.includes(requestId)) {
|
||
currentChildRequestIds.push(requestId);
|
||
}
|
||
|
||
nextMap.set(parentRequestId, currentChildRequestIds);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [requestStateMap]);
|
||
const activeSystemExecutionRequests = useMemo(
|
||
() =>
|
||
visibleSystemExecutionRequests.filter(
|
||
(request) =>
|
||
!isPromptFollowupRequest(request)
|
||
&& (request.status === 'accepted' || request.status === 'queued' || request.status === 'started'),
|
||
),
|
||
[visibleSystemExecutionRequests],
|
||
);
|
||
const systemExecutionSortOptions = useMemo(
|
||
() => [
|
||
{ value: 'latest', label: '최신순' },
|
||
{ value: 'answered', label: '답변순' },
|
||
{ value: 'status', label: '상태 우선' },
|
||
] satisfies { value: SystemExecutionSort; label: string }[],
|
||
[],
|
||
);
|
||
const systemExecutionFilterOptions = useMemo(
|
||
() => [
|
||
{ value: 'all', label: '전체', compactLabel: '전체', ariaLabel: '전체 실행 기록 보기' },
|
||
{ value: 'active', label: '처리중', compactLabel: '처리', ariaLabel: '처리중인 실행 기록만 보기' },
|
||
{
|
||
value: 'active-attention',
|
||
label: '처리+확인',
|
||
compactLabel: '처리·확인',
|
||
ariaLabel: '처리중이거나 확인이 필요한 실행 기록 보기',
|
||
},
|
||
{
|
||
value: 'attention',
|
||
label: '확인필요',
|
||
compactLabel: '확인',
|
||
ariaLabel: '확인 필요한 실행 기록만 보기',
|
||
},
|
||
] satisfies SystemExecutionFilterOption[],
|
||
[],
|
||
);
|
||
const currentSystemExecutionFilterOption =
|
||
systemExecutionFilterOptions.find((option) => option.value === systemExecutionFilter) ?? systemExecutionFilterOptions[0];
|
||
const mobileSystemExecutionNextFilterOption =
|
||
systemExecutionFilterOptions[
|
||
(systemExecutionFilterOptions.findIndex((option) => option.value === systemExecutionFilter) + 1) %
|
||
systemExecutionFilterOptions.length
|
||
] ?? systemExecutionFilterOptions[0];
|
||
const useCompactSystemExecutionControls = isIpadLikeViewport;
|
||
const useIconSystemExecutionFilterToggle = isMobileViewport || useCompactSystemExecutionControls;
|
||
const canLoadOlderSystemExecutionRequests = systemExecutionFilter === 'all' && Boolean(onLoadOlderMessages);
|
||
const requestIdsByRootRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, string[]>();
|
||
|
||
Array.from(requestStateMap.values()).forEach((request) => {
|
||
const rootRequestId = resolveConversationRootRequestId(request.requestId, requestStateMap) || request.requestId;
|
||
const currentRequestIds = nextMap.get(rootRequestId) ?? [];
|
||
currentRequestIds.push(request.requestId);
|
||
nextMap.set(rootRequestId, currentRequestIds);
|
||
});
|
||
|
||
return nextMap;
|
||
}, [requestStateMap]);
|
||
const systemExecutionAttentionStateByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, SystemExecutionAttentionState>();
|
||
|
||
visibleSystemExecutionRequests.forEach((request) => {
|
||
if (isPromptFollowupRequest(request)) {
|
||
nextMap.set(request.requestId, {
|
||
activityLines: [],
|
||
promptTargets: [],
|
||
promptSubmittedCount: 0,
|
||
isPromptManuallyCompleted: true,
|
||
hasVerificationTarget: false,
|
||
hasConfirmedVerificationTarget: true,
|
||
isVerificationManuallyCompleted: true,
|
||
hasPendingPromptBadge: false,
|
||
hasPendingVerificationBadge: false,
|
||
hasOwnAttentionState: false,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const activityLines = activityLinesByRequestId.get(request.requestId) ?? [];
|
||
const promptTargets = promptTargetsByRequestId.get(request.requestId) ?? [];
|
||
const promptSubmittedCount =
|
||
Math.max(
|
||
promptFollowupCountByParentRequestId.get(request.requestId) ?? 0,
|
||
localSubmittedPromptCountByRequestId.get(request.requestId) ?? 0,
|
||
);
|
||
const isPromptManuallyCompleted = Boolean(request.manualPromptCompletedAt);
|
||
const hasVerificationTarget =
|
||
(request.status === 'completed' && !request.hasResponse) ||
|
||
request.hasResponse ||
|
||
requestIdsWithPreviewArtifacts.has(request.requestId) ||
|
||
(typeof request.responseMessageId === 'number' && collapsibleMessageIdSet.has(request.responseMessageId));
|
||
const hasConfirmedVerificationTarget =
|
||
expandedResponseRequestIds.has(request.requestId) ||
|
||
openedPreviewRequestIds.has(request.requestId) ||
|
||
openedPreviewArtifactRequestIdSet.has(request.requestId);
|
||
const isVerificationManuallyCompleted = Boolean(request.manualVerificationCompletedAt);
|
||
const hasPendingPromptBadge =
|
||
!isPromptManuallyCompleted &&
|
||
hasPendingPromptState({
|
||
promptTargets,
|
||
submittedCount: promptSubmittedCount,
|
||
});
|
||
const hasPendingVerificationBadge =
|
||
!isVerificationManuallyCompleted &&
|
||
hasPendingVerificationState({
|
||
request,
|
||
activityLines,
|
||
promptTargets,
|
||
hasVerificationTarget,
|
||
hasConfirmedVerificationTarget,
|
||
isPromptManuallyCompleted,
|
||
});
|
||
const hasOwnAttentionState =
|
||
hasPendingPromptBadge || hasPendingVerificationBadge || isDisconnectedRequestNeedingAttention(request);
|
||
|
||
nextMap.set(request.requestId, {
|
||
activityLines,
|
||
promptTargets,
|
||
promptSubmittedCount,
|
||
isPromptManuallyCompleted,
|
||
hasVerificationTarget,
|
||
hasConfirmedVerificationTarget,
|
||
isVerificationManuallyCompleted,
|
||
hasPendingPromptBadge,
|
||
hasPendingVerificationBadge,
|
||
hasOwnAttentionState,
|
||
});
|
||
});
|
||
|
||
return nextMap;
|
||
}, [
|
||
activityLinesByRequestId,
|
||
collapsibleMessageIdSet,
|
||
expandedResponseRequestIds,
|
||
lastReadResponseMessageId,
|
||
openedPreviewArtifactRequestIdSet,
|
||
openedPreviewRequestIds,
|
||
childRequestIdsByParentRequestId,
|
||
localSubmittedPromptCountByRequestId,
|
||
promptFollowupCountByParentRequestId,
|
||
promptTargetsByRequestId,
|
||
requestIdsWithPreviewArtifacts,
|
||
visibleSystemExecutionRequests,
|
||
]);
|
||
const filteredSystemExecutionRequests = useMemo(
|
||
() => {
|
||
if (systemExecutionFilter === 'all') {
|
||
return visibleSystemExecutionRequests;
|
||
}
|
||
|
||
if (systemExecutionFilter === 'active') {
|
||
return visibleSystemExecutionRequests.filter(
|
||
(request) =>
|
||
!isPromptFollowupRequest(request)
|
||
&& (request.status === 'accepted' || request.status === 'queued' || request.status === 'started'),
|
||
);
|
||
}
|
||
|
||
if (systemExecutionFilter === 'active-attention') {
|
||
return visibleSystemExecutionRequests.filter((request) => {
|
||
if (isPromptFollowupRequest(request)) {
|
||
return false;
|
||
}
|
||
|
||
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
|
||
return true;
|
||
}
|
||
|
||
return systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
|
||
});
|
||
}
|
||
|
||
return visibleSystemExecutionRequests.filter(
|
||
(request) =>
|
||
!isPromptFollowupRequest(request)
|
||
&& systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true,
|
||
);
|
||
},
|
||
[systemExecutionAttentionStateByRequestId, visibleSystemExecutionRequests, systemExecutionFilter],
|
||
);
|
||
const filteredSystemExecutionRequestIdSet = useMemo(
|
||
() => new Set(filteredSystemExecutionRequests.map((request) => request.requestId)),
|
||
[filteredSystemExecutionRequests],
|
||
);
|
||
const useFlatSystemExecutionFilterView = systemExecutionFilter !== 'all';
|
||
const orderedFilteredSystemExecutionRequests = useMemo(
|
||
() =>
|
||
[...filteredSystemExecutionRequests].sort((left, right) =>
|
||
compareSystemExecutionRequests(left, right, systemExecutionSort),
|
||
),
|
||
[filteredSystemExecutionRequests, systemExecutionSort],
|
||
);
|
||
const orderedSystemExecutionRootRequests = useMemo(() => {
|
||
const rootRequestsById = new Map<string, ChatConversationRequest>();
|
||
|
||
filteredSystemExecutionRequests.forEach((request) => {
|
||
const rootRequestId = resolveConversationRootRequestId(request.requestId, requestStateMap) || request.requestId;
|
||
const rootRequest = requestStateMap.get(rootRequestId) ?? request;
|
||
const currentRootRequest = rootRequestsById.get(rootRequest.requestId);
|
||
|
||
if (!currentRootRequest) {
|
||
rootRequestsById.set(rootRequest.requestId, rootRequest);
|
||
return;
|
||
}
|
||
|
||
if (compareSystemExecutionRequests(rootRequest, currentRootRequest, systemExecutionSort) < 0) {
|
||
rootRequestsById.set(rootRequest.requestId, rootRequest);
|
||
}
|
||
});
|
||
|
||
const orderedRootRequests = Array.from(rootRequestsById.values()).sort((left, right) =>
|
||
compareSystemExecutionRequests(left, right, systemExecutionSort),
|
||
);
|
||
return orderedRootRequests;
|
||
}, [
|
||
filteredSystemExecutionRequests,
|
||
requestStateMap,
|
||
systemExecutionSort,
|
||
]);
|
||
useEffect(() => {
|
||
if (!expandedSystemExecutionActivityRequestId) {
|
||
return;
|
||
}
|
||
|
||
const expandedRequestStillVisible = orderedSystemExecutionRootRequests.some((request) => {
|
||
const groupedRequestIds = requestIdsByRootRequestId.get(request.requestId) ?? [request.requestId];
|
||
return groupedRequestIds.includes(expandedSystemExecutionActivityRequestId);
|
||
});
|
||
|
||
if (!expandedRequestStillVisible) {
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
}
|
||
}, [
|
||
expandedSystemExecutionActivityRequestId,
|
||
orderedSystemExecutionRootRequests,
|
||
requestIdsByRootRequestId,
|
||
]);
|
||
const collapsedVisibleSystemExecutionRequest =
|
||
useFlatSystemExecutionFilterView
|
||
? orderedFilteredSystemExecutionRequests[orderedFilteredSystemExecutionRequests.length - 1] ?? null
|
||
: orderedSystemExecutionRootRequests[orderedSystemExecutionRootRequests.length - 1] ?? null;
|
||
const activeSystemStatusText = activeSystemStatus?.trim() || null;
|
||
const hiddenSystemExecutionCountText = useMemo(() => {
|
||
const actualExecutionCount = activeSystemExecutionRequests.length;
|
||
|
||
if (actualExecutionCount <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return `${actualExecutionCount}건 실행 중`;
|
||
}, [activeSystemExecutionRequests.length]);
|
||
const hiddenSystemStatusSummaryText = hiddenSystemExecutionCountText ?? activeSystemStatusText;
|
||
const roomShareRequestGroups = useMemo(
|
||
() => {
|
||
if (useSharedRoomsSimplifiedView) {
|
||
return Array.from(requestStateMap.values())
|
||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
||
.map((request) => {
|
||
const groupedRequests = [request];
|
||
const statusSummary = resolveAggregatedRequestStatusSummary(groupedRequests, systemExecutionAttentionStateByRequestId);
|
||
const hasAttention =
|
||
isRequestRunningStatus(request.status) ||
|
||
isRequestQueueStatus(request.status) ||
|
||
systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
|
||
const messages = orderedMessages.filter((message) => {
|
||
if (isActivityLogMessage(message)) {
|
||
return false;
|
||
}
|
||
|
||
const requestId = message.clientRequestId?.trim() || '';
|
||
|
||
if (!requestId) {
|
||
return false;
|
||
}
|
||
|
||
return (resolveConversationMessageGroupRequestId(requestId, requestStateMap) || requestId) === request.requestId;
|
||
});
|
||
|
||
return {
|
||
groupId: request.requestId,
|
||
groupedRequests,
|
||
representativeRequest: request,
|
||
statusSummary,
|
||
hasAttention,
|
||
messages,
|
||
};
|
||
});
|
||
}
|
||
|
||
const groupRequestIdsByRootId = new Map<string, string[]>();
|
||
|
||
requestIdsByRootRequestId.forEach((requestIds, rootRequestId) => {
|
||
const dedupedRequestIds = Array.from(new Set(requestIds.map((requestId) => requestId.trim()).filter(Boolean)));
|
||
|
||
if (dedupedRequestIds.length > 0) {
|
||
groupRequestIdsByRootId.set(rootRequestId, dedupedRequestIds);
|
||
}
|
||
});
|
||
|
||
const groupedEntries = messageEntries
|
||
.filter((entry): entry is Extract<ConversationMessageEntry, { kind: 'group' }> => entry.kind === 'group')
|
||
.map((entry) => {
|
||
const groupedRequestIds = groupRequestIdsByRootId.get(entry.groupId) ?? entry.requestIds;
|
||
const groupedRequests = Array.from(
|
||
new Map(
|
||
groupedRequestIds
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
.map((request) => [request.requestId, request] as const),
|
||
).values(),
|
||
);
|
||
const representativeRequest =
|
||
entry.request && groupedRequests.some((request) => request.requestId === entry.request?.requestId)
|
||
? entry.request
|
||
: groupedRequests[groupedRequests.length - 1] ?? entry.request;
|
||
const statusSummary = resolveAggregatedRequestStatusSummary(groupedRequests, systemExecutionAttentionStateByRequestId);
|
||
const hasAttention = groupedRequests.some((request) => {
|
||
if (isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status)) {
|
||
return true;
|
||
}
|
||
|
||
return systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
|
||
});
|
||
|
||
return {
|
||
groupId: entry.groupId,
|
||
groupedRequests,
|
||
representativeRequest,
|
||
statusSummary,
|
||
hasAttention,
|
||
};
|
||
});
|
||
const existingGroupIdSet = new Set(groupedEntries.map((entry) => entry.groupId));
|
||
const pendingOnlyGroups = Array.from(groupRequestIdsByRootId.entries())
|
||
.filter(([groupId]) => !existingGroupIdSet.has(groupId))
|
||
.map(([groupId, groupedRequestIds]) => {
|
||
const groupedRequests = Array.from(
|
||
new Map(
|
||
groupedRequestIds
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
.map((request) => [request.requestId, request] as const),
|
||
).values(),
|
||
);
|
||
|
||
if (groupedRequests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const hasAttention = groupedRequests.some((request) => {
|
||
if (isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status)) {
|
||
return true;
|
||
}
|
||
|
||
return systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
|
||
});
|
||
|
||
if (!hasAttention) {
|
||
return null;
|
||
}
|
||
|
||
const representativeRequest = groupedRequests[groupedRequests.length - 1] ?? null;
|
||
|
||
return {
|
||
groupId,
|
||
groupedRequests,
|
||
representativeRequest,
|
||
statusSummary: resolveAggregatedRequestStatusSummary(groupedRequests, systemExecutionAttentionStateByRequestId),
|
||
hasAttention,
|
||
};
|
||
})
|
||
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
||
|
||
return [...groupedEntries, ...pendingOnlyGroups]
|
||
.filter((entry) => entry.representativeRequest != null)
|
||
.sort((left, right) => left.representativeRequest.createdAt.localeCompare(right.representativeRequest.createdAt));
|
||
},
|
||
[
|
||
messageEntries,
|
||
orderedMessages,
|
||
requestIdsByRootRequestId,
|
||
requestStateMap,
|
||
systemExecutionAttentionStateByRequestId,
|
||
useSharedRoomsSimplifiedView,
|
||
],
|
||
);
|
||
const roomShareNavigableGroups = useMemo(() => {
|
||
if (roomShareExpandMode === 'pending') {
|
||
return roomShareRequestGroups.filter((entry) => entry.hasAttention);
|
||
}
|
||
|
||
return roomShareRequestGroups;
|
||
}, [roomShareExpandMode, roomShareRequestGroups]);
|
||
const roomShareGroupIdByRequestId = useMemo(() => {
|
||
const nextMap = new Map<string, string>();
|
||
|
||
roomShareRequestGroups.forEach((entry) => {
|
||
entry.groupedRequests.forEach((groupedRequest) => {
|
||
const normalizedRequestId = groupedRequest.requestId.trim();
|
||
|
||
if (normalizedRequestId) {
|
||
nextMap.set(normalizedRequestId, entry.groupId);
|
||
}
|
||
});
|
||
});
|
||
|
||
return nextMap;
|
||
}, [roomShareRequestGroups]);
|
||
useEffect(() => {
|
||
if (!showRoomsShareHeader) {
|
||
return;
|
||
}
|
||
|
||
if (roomShareRequestGroups.length === 0) {
|
||
if (selectedRoomShareGroupId !== null) {
|
||
setSelectedRoomShareGroupId(null);
|
||
}
|
||
shouldFollowLatestRoomShareGroupRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (roomShareExpandMode === 'latest' && shouldFollowLatestRoomShareGroupRef.current) {
|
||
const nextLatestGroupId = roomShareRequestGroups[roomShareRequestGroups.length - 1]?.groupId ?? null;
|
||
shouldFollowLatestRoomShareGroupRef.current = false;
|
||
|
||
if (nextLatestGroupId && nextLatestGroupId !== selectedRoomShareGroupId) {
|
||
pendingRoomShareJumpRef.current = {
|
||
groupId: nextLatestGroupId,
|
||
};
|
||
setSelectedRoomShareGroupId(nextLatestGroupId);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (
|
||
selectedRoomShareGroupId &&
|
||
roomShareRequestGroups.some((entry) => entry.groupId === selectedRoomShareGroupId)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setSelectedRoomShareGroupId(roomShareRequestGroups[roomShareRequestGroups.length - 1]?.groupId ?? null);
|
||
}, [roomShareExpandMode, roomShareRequestGroups, selectedRoomShareGroupId, showRoomsShareHeader]);
|
||
const selectedRoomShareGroup = useMemo(
|
||
() => roomShareRequestGroups.find((entry) => entry.groupId === selectedRoomShareGroupId) ?? null,
|
||
[roomShareRequestGroups, selectedRoomShareGroupId],
|
||
);
|
||
const selectedRoomShareGroupIndex = useMemo(
|
||
() => roomShareRequestGroups.findIndex((entry) => entry.groupId === selectedRoomShareGroupId),
|
||
[roomShareRequestGroups, selectedRoomShareGroupId],
|
||
);
|
||
const canMoveToPreviousRoomShareGroup = roomShareExpandMode === 'latest' && selectedRoomShareGroupIndex > 0;
|
||
const canMoveToNextRoomShareGroup =
|
||
roomShareExpandMode === 'latest' &&
|
||
selectedRoomShareGroupIndex >= 0 &&
|
||
selectedRoomShareGroupIndex < roomShareRequestGroups.length - 1;
|
||
const roomShareVisibleGroupIdSet = useMemo(() => {
|
||
if (!showRoomsShareHeader) {
|
||
return null;
|
||
}
|
||
|
||
if (roomShareExpandMode === 'latest') {
|
||
return new Set(selectedRoomShareGroup?.groupId ? [selectedRoomShareGroup.groupId] : []);
|
||
}
|
||
|
||
return new Set(roomShareNavigableGroups.map((entry) => entry.groupId));
|
||
}, [roomShareExpandMode, roomShareNavigableGroups, selectedRoomShareGroup, showRoomsShareHeader]);
|
||
const roomShareAllRequests = useMemo(
|
||
() => roomShareRequestGroups.flatMap((entry) => entry.groupedRequests),
|
||
[roomShareRequestGroups],
|
||
);
|
||
const roomShareAggregateStatusSummary = useMemo(
|
||
() => resolveAggregatedRequestStatusSummary(roomShareAllRequests, systemExecutionAttentionStateByRequestId),
|
||
[roomShareAllRequests, systemExecutionAttentionStateByRequestId],
|
||
);
|
||
const roomShareMenuTitle = useMemo(() => {
|
||
const normalizedTitle = roomsShareHeaderTitle?.trim() || "현재 메뉴";
|
||
const menuSegments = normalizedTitle
|
||
.replaceAll("|", ">")
|
||
.replaceAll("/", ">")
|
||
.split(">")
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
return menuSegments.at(-1) || normalizedTitle;
|
||
}, [roomsShareHeaderTitle]);
|
||
const roomShareHeaderSummaryLabel = useMemo(() => {
|
||
const nowMs = Date.now();
|
||
const processingRequests = roomShareAllRequests.filter(
|
||
(request) => isRequestRunningStatus(request.status) || isRequestQueueStatus(request.status),
|
||
).filter((request) => !isPromptFollowupRequest(request));
|
||
const processingTarget = processingRequests[processingRequests.length - 1] ?? null;
|
||
const elapsedLabel = processingTarget ? formatOngoingElapsedLabel(processingTarget.createdAt, nowMs) : '';
|
||
const processingCount = processingRequests.length;
|
||
const attentionCount = roomShareRequestGroups.filter((entry) => entry.hasAttention).length;
|
||
|
||
return `처리 시간 ${elapsedLabel || '-'} · 처리 건수 ${processingCount}건 · 미확인 ${attentionCount}건`;
|
||
}, [roomShareAllRequests, roomShareRequestGroups]);
|
||
const roomShareCurrentGroup = selectedRoomShareGroup ?? roomShareRequestGroups[roomShareRequestGroups.length - 1] ?? null;
|
||
const roomShareHiddenBeforeCount =
|
||
roomShareExpandMode === 'latest' && selectedRoomShareGroupIndex >= 0 ? selectedRoomShareGroupIndex : 0;
|
||
const roomShareHiddenAfterCount =
|
||
roomShareExpandMode === 'latest' && selectedRoomShareGroupIndex >= 0
|
||
? Math.max(0, roomShareRequestGroups.length - selectedRoomShareGroupIndex - 1)
|
||
: 0;
|
||
const roomShareProgressLabel =
|
||
roomShareRequestGroups.length === 0
|
||
? ''
|
||
: roomShareExpandMode === 'all'
|
||
? `전체 ${roomShareRequestGroups.length}건`
|
||
: roomShareExpandMode === 'pending'
|
||
? `처리중·미확인 ${roomShareNavigableGroups.length}건`
|
||
: `${selectedRoomShareGroupIndex + 1} / ${roomShareRequestGroups.length}`;
|
||
const roomShareCollapsedActivitySummary = useMemo(() => {
|
||
const representativeRequest = roomShareCurrentGroup?.representativeRequest ?? null;
|
||
|
||
if (!representativeRequest || roomShareExpandMode !== 'latest' || !(isRequestRunningStatus(representativeRequest.status) || isRequestQueueStatus(representativeRequest.status))) {
|
||
return [] as string[];
|
||
}
|
||
|
||
return summarizeRoomShareActivityLines(
|
||
systemExecutionAttentionStateByRequestId.get(representativeRequest.requestId)?.activityLines ?? [],
|
||
);
|
||
}, [roomShareCurrentGroup, roomShareExpandMode, systemExecutionAttentionStateByRequestId]);
|
||
const roomShareExpandModeMenuItems = useMemo<MenuProps['items']>(
|
||
() => [
|
||
{ key: 'latest', label: ROOM_SHARE_EXPAND_MODE_LABELS.latest },
|
||
{ key: 'pending', label: ROOM_SHARE_EXPAND_MODE_LABELS.pending },
|
||
{ key: 'all', label: ROOM_SHARE_EXPAND_MODE_LABELS.all },
|
||
],
|
||
[],
|
||
);
|
||
const expandedSystemExecutionActivityRequest = useMemo(() => {
|
||
const requestId = expandedSystemExecutionActivityRequestId?.trim() || '';
|
||
|
||
if (!requestId) {
|
||
return null;
|
||
}
|
||
|
||
return requestStateMap.get(requestId) ?? null;
|
||
}, [expandedSystemExecutionActivityRequestId, requestStateMap]);
|
||
const expandedSystemExecutionActivityPanels = useMemo(() => {
|
||
if (!expandedSystemExecutionActivityRequest) {
|
||
return [];
|
||
}
|
||
|
||
const expandedRootRequest =
|
||
orderedSystemExecutionRootRequests.find((rootRequest) => {
|
||
const groupedRequestIds = requestIdsByRootRequestId.get(rootRequest.requestId) ?? [rootRequest.requestId];
|
||
return groupedRequestIds.includes(expandedSystemExecutionActivityRequest.requestId);
|
||
}) ?? expandedSystemExecutionActivityRequest;
|
||
const groupedRequestIds = requestIdsByRootRequestId.get(expandedRootRequest.requestId) ?? [
|
||
expandedSystemExecutionActivityRequest.requestId,
|
||
];
|
||
const groupedRequests = groupedRequestIds
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((request): request is ChatConversationRequest => request != null);
|
||
const visibleRequests = resolveVisibleActivityOverviewRequests(
|
||
groupedRequests,
|
||
systemExecutionActivityOverviewByRequestId,
|
||
systemExecutionAttentionStateByRequestId,
|
||
).sort((left, right) => compareRepresentativeRequests(left, right, systemExecutionAttentionStateByRequestId));
|
||
|
||
return visibleRequests
|
||
.map((request) => {
|
||
const overview = systemExecutionActivityOverviewByRequestId.get(request.requestId) ?? null;
|
||
|
||
if (!overview) {
|
||
return null;
|
||
}
|
||
|
||
const attentionState = systemExecutionAttentionStateByRequestId.get(request.requestId) ?? null;
|
||
|
||
return {
|
||
request,
|
||
overview,
|
||
attentionState,
|
||
summaryText: summarizeSystemExecutionRequestText(request, {
|
||
preferResponse: false,
|
||
maxLength: resolveSystemExecutionSummaryMaxLength(),
|
||
}),
|
||
};
|
||
})
|
||
.filter(
|
||
(
|
||
panel,
|
||
): panel is {
|
||
request: ChatConversationRequest;
|
||
overview: SystemExecutionActivityOverview;
|
||
attentionState: SystemExecutionAttentionState | null;
|
||
summaryText: string;
|
||
} => panel != null,
|
||
);
|
||
}, [
|
||
expandedSystemExecutionActivityRequest,
|
||
orderedSystemExecutionRootRequests,
|
||
requestIdsByRootRequestId,
|
||
requestStateMap,
|
||
systemExecutionActivityOverviewByRequestId,
|
||
systemExecutionAttentionStateByRequestId,
|
||
]);
|
||
const hasExpandedSystemExecutionActivityContent = expandedSystemExecutionActivityPanels.length > 0;
|
||
const systemExecutionMeta = useMemo(() => {
|
||
const actualExecutionCount = activeSystemExecutionRequests.length;
|
||
|
||
if (actualExecutionCount > 0) {
|
||
return `실제 실행 ${actualExecutionCount}건`;
|
||
}
|
||
|
||
return activeSystemStatusText;
|
||
}, [activeSystemExecutionRequests.length, activeSystemStatusText]);
|
||
const visiblePreviewItems = useMemo(() => {
|
||
if (!showLatestResourceOnly) {
|
||
return previewItems;
|
||
}
|
||
|
||
const seenFileNames = new Set<string>();
|
||
return previewItems.filter((item) => {
|
||
const fileName = buildPreviewFileName(item);
|
||
|
||
if (seenFileNames.has(fileName)) {
|
||
return false;
|
||
}
|
||
|
||
seenFileNames.add(fileName);
|
||
return true;
|
||
});
|
||
}, [previewItems, showLatestResourceOnly]);
|
||
|
||
useEffect(() => {
|
||
if (expandedSystemExecutionActivityRequestId && !hasExpandedSystemExecutionActivityContent) {
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
}
|
||
}, [
|
||
expandedSystemExecutionActivityRequestId,
|
||
hasExpandedSystemExecutionActivityContent,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const { body, documentElement } = document;
|
||
const previousBodyOverflow = body.style.overflow;
|
||
const previousHtmlOverflow = documentElement.style.overflow;
|
||
|
||
if (fullscreenPreviewKey) {
|
||
body.style.overflow = 'hidden';
|
||
documentElement.style.overflow = 'hidden';
|
||
}
|
||
|
||
return () => {
|
||
body.style.overflow = previousBodyOverflow;
|
||
documentElement.style.overflow = previousHtmlOverflow;
|
||
};
|
||
}, [fullscreenPreviewKey]);
|
||
|
||
const setMessageAnchorRef = (messageId: number, element: HTMLDivElement | null) => {
|
||
if (element) {
|
||
messageAnchorRefs.current.set(messageId, element);
|
||
return;
|
||
}
|
||
|
||
messageAnchorRefs.current.delete(messageId);
|
||
};
|
||
|
||
const setMessageBodyRef = (messageId: number, element: HTMLDivElement | null) => {
|
||
if (element) {
|
||
messageBodyRefs.current.set(messageId, element);
|
||
return;
|
||
}
|
||
|
||
messageBodyRefs.current.delete(messageId);
|
||
};
|
||
const setPromptCardAnchorRef = (messageId: number, element: HTMLElement | null) => {
|
||
if (!Number.isFinite(messageId)) {
|
||
return;
|
||
}
|
||
|
||
if (element) {
|
||
promptCardAnchorRefs.current.set(messageId, element);
|
||
return;
|
||
}
|
||
|
||
promptCardAnchorRefs.current.delete(messageId);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
let frameId = 0;
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
|
||
const measureCollapsibleMessages = () => {
|
||
const nextIds: number[] = [];
|
||
|
||
orderedMessages.forEach((message) => {
|
||
if (message.author !== 'user' && message.author !== 'codex') {
|
||
return;
|
||
}
|
||
|
||
if (isLikelyCollapsibleMessage(message.text)) {
|
||
nextIds.push(message.id);
|
||
return;
|
||
}
|
||
|
||
const element = messageBodyRefs.current.get(message.id);
|
||
|
||
if (!element) {
|
||
return;
|
||
}
|
||
|
||
const computedStyle = window.getComputedStyle(element);
|
||
const rawLineHeight = Number.parseFloat(computedStyle.lineHeight);
|
||
const lineHeight = Number.isFinite(rawLineHeight) ? rawLineHeight : 12 * 1.45;
|
||
const collapsedMaxHeight = lineHeight * COLLAPSIBLE_MESSAGE_LINE_COUNT;
|
||
const fullHeight = element.scrollHeight;
|
||
|
||
if (fullHeight > collapsedMaxHeight + 4) {
|
||
nextIds.push(message.id);
|
||
}
|
||
});
|
||
|
||
setCollapsibleMessageIds((current) => {
|
||
if (current.length === nextIds.length && current.every((id, index) => id === nextIds[index])) {
|
||
return current;
|
||
}
|
||
|
||
return nextIds;
|
||
});
|
||
};
|
||
|
||
const scheduleMeasure = () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
frameId = window.requestAnimationFrame(measureCollapsibleMessages);
|
||
};
|
||
|
||
scheduleMeasure();
|
||
window.addEventListener('resize', scheduleMeasure);
|
||
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
scheduleMeasure();
|
||
});
|
||
|
||
orderedMessages.forEach((message) => {
|
||
const element = messageBodyRefs.current.get(message.id);
|
||
|
||
if (element) {
|
||
resizeObserver?.observe(element);
|
||
}
|
||
});
|
||
}
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
window.removeEventListener('resize', scheduleMeasure);
|
||
resizeObserver?.disconnect();
|
||
};
|
||
}, [orderedMessages, expandedMessageIds]);
|
||
|
||
useEffect(() => {
|
||
if (isConversationLoading) {
|
||
setShowBusyOverlay(false);
|
||
return;
|
||
}
|
||
|
||
if (!isComposerAttachmentUploading) {
|
||
setShowBusyOverlay(false);
|
||
return;
|
||
}
|
||
|
||
const timeoutId = window.setTimeout(() => {
|
||
setShowBusyOverlay(true);
|
||
}, 350);
|
||
|
||
return () => {
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [isComposerAttachmentUploading, isConversationLoading]);
|
||
|
||
useEffect(() => {
|
||
const previousDisplayMode = previousSystemExecutionDisplayModeRef.current;
|
||
previousSystemExecutionDisplayModeRef.current = systemExecutionDisplayMode;
|
||
|
||
if (systemExecutionDisplayMode !== 'expanded') {
|
||
if (previousDisplayMode === 'expanded') {
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (systemExecutionOlderLoadScrollSnapshotRef.current && !isLoadingOlderMessages) {
|
||
const animationFrameId = window.requestAnimationFrame(() => {
|
||
const systemExecutionBodyElement = systemExecutionBodyRef.current;
|
||
const snapshot = systemExecutionOlderLoadScrollSnapshotRef.current;
|
||
|
||
if (!systemExecutionBodyElement || !snapshot) {
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = null;
|
||
return;
|
||
}
|
||
|
||
const heightDelta = systemExecutionBodyElement.scrollHeight - snapshot.scrollHeight;
|
||
systemExecutionBodyElement.scrollTop = Math.max(0, snapshot.scrollTop + heightDelta);
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = null;
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
};
|
||
}
|
||
|
||
if (previousDisplayMode === 'expanded') {
|
||
return;
|
||
}
|
||
|
||
const scrollToBottom = () => {
|
||
const viewportElement = viewportRef.current;
|
||
const systemExecutionBodyElement = systemExecutionBodyRef.current;
|
||
|
||
if (systemExecutionBodyElement) {
|
||
systemExecutionBodyElement.scrollTop = systemExecutionBodyElement.scrollHeight;
|
||
}
|
||
|
||
if (viewportElement) {
|
||
viewportElement.scrollTo({
|
||
top: viewportElement.scrollHeight,
|
||
behavior: 'auto',
|
||
});
|
||
}
|
||
};
|
||
|
||
const animationFrameId = window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(scrollToBottom);
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(animationFrameId);
|
||
};
|
||
}, [filteredSystemExecutionRequests.length, isLoadingOlderMessages, systemExecutionDisplayMode, viewportRef]);
|
||
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const syncViewportAfterLayoutChange = () => {
|
||
const viewportElement = viewportRef.current;
|
||
|
||
if (!viewportElement) {
|
||
return;
|
||
}
|
||
|
||
const maxScrollTop = Math.max(0, viewportElement.scrollHeight - viewportElement.clientHeight);
|
||
const remainingDistance = maxScrollTop - viewportElement.scrollTop;
|
||
const isNearBottom = remainingDistance <= 8;
|
||
|
||
if (maxScrollTop === 0) {
|
||
if (viewportElement.scrollTop !== 0) {
|
||
viewportElement.scrollTop = 0;
|
||
}
|
||
onViewportScroll();
|
||
return;
|
||
}
|
||
|
||
if (viewportElement.scrollTop > maxScrollTop) {
|
||
viewportElement.scrollTop = maxScrollTop;
|
||
onViewportScroll();
|
||
return;
|
||
}
|
||
|
||
if (isNearBottom) {
|
||
viewportElement.scrollTop = maxScrollTop;
|
||
onViewportScroll();
|
||
}
|
||
};
|
||
|
||
const frameId = window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(syncViewportAfterLayoutChange);
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
};
|
||
}, [
|
||
activeSystemStatus,
|
||
filteredSystemExecutionRequests.length,
|
||
hasConversationMessages,
|
||
isLoadingOlderMessages,
|
||
isSystemStatusPending,
|
||
onViewportScroll,
|
||
pullToLoadDistance,
|
||
requestStateMap,
|
||
systemExecutionDisplayMode,
|
||
viewportRef,
|
||
visibleMessages,
|
||
visibleSystemExecutionRequests.length,
|
||
]);
|
||
|
||
const scrollToAnchorElement = (anchorElement: HTMLElement | null | undefined, behavior: ScrollBehavior) => {
|
||
if (!anchorElement) {
|
||
return false;
|
||
}
|
||
|
||
const viewportElement = viewportRef.current;
|
||
const scrollContainer = resolveScrollableAnchorContainer(anchorElement, viewportElement);
|
||
|
||
if (!scrollContainer) {
|
||
anchorElement.scrollIntoView({
|
||
behavior,
|
||
block: 'start',
|
||
});
|
||
return true;
|
||
}
|
||
|
||
const nextTop = Math.max(
|
||
0,
|
||
getElementOffsetWithinContainer(anchorElement, scrollContainer) - getContainerScrollPaddingTop(scrollContainer),
|
||
);
|
||
|
||
scrollContainer.scrollTo({
|
||
top: nextTop,
|
||
behavior,
|
||
});
|
||
|
||
return true;
|
||
};
|
||
const scrollToMessageAnchor = (messageId: number, behavior: ScrollBehavior) =>
|
||
scrollToAnchorElement(messageAnchorRefs.current.get(messageId), behavior);
|
||
const scrollToPromptCardAnchor = (messageId: number | null | undefined, behavior: ScrollBehavior) =>
|
||
typeof messageId === 'number' && Number.isFinite(messageId)
|
||
? scrollToAnchorElement(promptCardAnchorRefs.current.get(messageId), behavior)
|
||
: false;
|
||
|
||
const triggerOlderSystemExecutionLoad = () => {
|
||
if (
|
||
!canLoadOlderSystemExecutionRequests ||
|
||
!hasOlderMessages ||
|
||
isLoadingOlderMessages ||
|
||
systemExecutionOlderLoadRequestedRef.current ||
|
||
!onLoadOlderMessages
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const systemExecutionBodyElement = systemExecutionBodyRef.current;
|
||
|
||
if (systemExecutionDisplayMode === 'expanded' && systemExecutionBodyElement) {
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = {
|
||
scrollHeight: systemExecutionBodyElement.scrollHeight,
|
||
scrollTop: systemExecutionBodyElement.scrollTop,
|
||
};
|
||
}
|
||
|
||
systemExecutionOlderLoadRequestedRef.current = true;
|
||
|
||
Promise.resolve(onLoadOlderMessages()).catch(() => {
|
||
systemExecutionOlderLoadRequestedRef.current = false;
|
||
systemExecutionOlderLoadScrollSnapshotRef.current = null;
|
||
});
|
||
};
|
||
|
||
const handleSystemExecutionBodyScroll = (event: UIEvent<HTMLDivElement>) => {
|
||
if (!canLoadOlderSystemExecutionRequests) {
|
||
return;
|
||
}
|
||
|
||
if (event.currentTarget.scrollTop > 24) {
|
||
return;
|
||
}
|
||
|
||
triggerOlderSystemExecutionLoad();
|
||
};
|
||
|
||
const scheduleSystemExecutionJump = (scroll: (behavior: ScrollBehavior) => boolean, attempt = 0) => {
|
||
if (systemExecutionJumpFrameRef.current !== null) {
|
||
window.cancelAnimationFrame(systemExecutionJumpFrameRef.current);
|
||
}
|
||
|
||
systemExecutionJumpFrameRef.current = window.requestAnimationFrame(() => {
|
||
systemExecutionJumpFrameRef.current = null;
|
||
const didScroll = scroll(attempt === 0 ? 'smooth' : 'auto');
|
||
|
||
if (attempt < SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
|
||
scheduleSystemExecutionJump(scroll, attempt + 1);
|
||
}
|
||
|
||
if (!didScroll && attempt >= SYSTEM_EXECUTION_JUMP_MAX_RETRIES) {
|
||
return;
|
||
}
|
||
});
|
||
};
|
||
|
||
const resolveSystemExecutionJumpTarget = (request: ChatConversationRequest | undefined) => {
|
||
if (!request) {
|
||
return null;
|
||
}
|
||
|
||
const normalizedRequestId = request.requestId.trim();
|
||
|
||
if (!normalizedRequestId) {
|
||
return null;
|
||
}
|
||
|
||
const firstPromptMessageId = firstPromptMessageIdByRequestId.get(normalizedRequestId) ?? null;
|
||
const hasPromptTarget = firstPromptMessageId != null || (promptTargetsByRequestId.get(normalizedRequestId)?.length ?? 0) > 0;
|
||
|
||
if (hasPromptTarget) {
|
||
const anchorMessageId =
|
||
[firstPromptMessageId, request.responseMessageId].find(
|
||
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
|
||
) ?? null;
|
||
|
||
return {
|
||
kind: 'prompt' as const,
|
||
requestId: normalizedRequestId,
|
||
promptMessageId: firstPromptMessageId,
|
||
anchorMessageId,
|
||
buttonLabel: 'prompt 위치로 이동',
|
||
};
|
||
}
|
||
|
||
if (typeof request.responseMessageId === 'number' && Number.isFinite(request.responseMessageId)) {
|
||
return {
|
||
kind: 'response' as const,
|
||
requestId: normalizedRequestId,
|
||
anchorMessageId: request.responseMessageId,
|
||
buttonLabel: '답변 위치로 이동',
|
||
};
|
||
}
|
||
|
||
if (typeof request.userMessageId === 'number' && Number.isFinite(request.userMessageId)) {
|
||
return {
|
||
kind: 'request' as const,
|
||
requestId: normalizedRequestId,
|
||
anchorMessageId: request.userMessageId,
|
||
buttonLabel: '요청 위치로 이동',
|
||
};
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const scrollToSystemExecutionRequest = (
|
||
requestId: string,
|
||
options?: {
|
||
fallbackMessageId?: number;
|
||
jumpTarget?: ReturnType<typeof resolveSystemExecutionJumpTarget>;
|
||
},
|
||
) => {
|
||
const request = requestStateMap.get(requestId);
|
||
const normalizedRequestId = requestId.trim();
|
||
const rootRequestId =
|
||
(normalizedRequestId ? resolveConversationRootRequestId(normalizedRequestId, requestStateMap) : '') ||
|
||
normalizedRequestId;
|
||
const resolvedJumpTarget = options?.jumpTarget ?? resolveSystemExecutionJumpTarget(request);
|
||
const anchorMessageId =
|
||
resolvedJumpTarget?.anchorMessageId ??
|
||
[request?.responseMessageId, request?.userMessageId, options?.fallbackMessageId].find(
|
||
(messageId): messageId is number => typeof messageId === 'number' && Number.isFinite(messageId),
|
||
) ??
|
||
null;
|
||
|
||
if (!anchorMessageId) {
|
||
return false;
|
||
}
|
||
|
||
if (rootRequestId) {
|
||
setExpandedGroupIds((current) => (current.includes(rootRequestId) ? current : [...current, rootRequestId]));
|
||
}
|
||
setExpandedMessageIds((current) => (current.includes(anchorMessageId) ? current : [...current, anchorMessageId]));
|
||
scheduleSystemExecutionJump((behavior) => {
|
||
if (resolvedJumpTarget?.kind === 'prompt') {
|
||
return (
|
||
scrollToPromptCardAnchor(resolvedJumpTarget.promptMessageId, behavior) ||
|
||
scrollToMessageAnchor(anchorMessageId, behavior)
|
||
);
|
||
}
|
||
|
||
return scrollToMessageAnchor(anchorMessageId, behavior);
|
||
});
|
||
return true;
|
||
};
|
||
|
||
const scrollToRoomShareGroup = (groupId: string) => {
|
||
const targetGroup = roomShareRequestGroups.find((entry) => entry.groupId === groupId);
|
||
const representativeRequest = targetGroup?.representativeRequest;
|
||
|
||
if (!representativeRequest) {
|
||
return;
|
||
}
|
||
|
||
scrollToSystemExecutionRequest(
|
||
representativeRequest.requestId,
|
||
{
|
||
fallbackMessageId: representativeRequest.responseMessageId ?? representativeRequest.userMessageId ?? undefined,
|
||
jumpTarget: resolveSystemExecutionJumpTarget(representativeRequest),
|
||
},
|
||
);
|
||
};
|
||
|
||
const moveRoomShareGroupSelection = (direction: 'previous' | 'next') => {
|
||
if (roomShareExpandMode !== 'latest') {
|
||
return;
|
||
}
|
||
|
||
const nextIndex = direction === 'previous' ? selectedRoomShareGroupIndex - 1 : selectedRoomShareGroupIndex + 1;
|
||
const nextGroup = roomShareRequestGroups[nextIndex];
|
||
|
||
if (!nextGroup) {
|
||
return;
|
||
}
|
||
|
||
setSelectedRoomShareGroupId(nextGroup.groupId);
|
||
scrollToRoomShareGroup(nextGroup.groupId);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const pendingJump = pendingRoomShareJumpRef.current;
|
||
|
||
if (!pendingJump || pendingJump.groupId !== selectedRoomShareGroupId) {
|
||
return;
|
||
}
|
||
|
||
pendingRoomShareJumpRef.current = null;
|
||
|
||
const frameId = window.requestAnimationFrame(() => {
|
||
if (pendingJump.requestId) {
|
||
const pendingRequest = requestStateMap.get(pendingJump.requestId);
|
||
const pendingJumpTarget = resolveSystemExecutionJumpTarget(pendingRequest);
|
||
const didScroll = scrollToSystemExecutionRequest(pendingJump.requestId, {
|
||
fallbackMessageId: pendingJump.fallbackMessageId,
|
||
jumpTarget: pendingJumpTarget,
|
||
});
|
||
|
||
if (
|
||
didScroll &&
|
||
(pendingJumpTarget?.kind === 'prompt' || pendingJumpTarget?.kind === 'response') &&
|
||
isPhoneLikeViewport() &&
|
||
systemExecutionDisplayMode === 'expanded'
|
||
) {
|
||
setSystemExecutionDisplayMode('hidden');
|
||
}
|
||
return;
|
||
}
|
||
|
||
scrollToRoomShareGroup(pendingJump.groupId);
|
||
});
|
||
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId);
|
||
};
|
||
}, [requestStateMap, scrollToRoomShareGroup, selectedRoomShareGroupId, systemExecutionDisplayMode]);
|
||
|
||
const handleSystemExecutionJump = (request: ChatConversationRequest) => {
|
||
const jumpTarget = resolveSystemExecutionJumpTarget(request);
|
||
const normalizedRequestId = request.requestId.trim();
|
||
const targetGroupId = normalizedRequestId ? roomShareGroupIdByRequestId.get(normalizedRequestId) ?? null : null;
|
||
const fallbackMessageId = request.responseMessageId ?? request.userMessageId ?? undefined;
|
||
|
||
if (
|
||
showRoomsShareHeader &&
|
||
roomShareExpandMode === 'latest' &&
|
||
targetGroupId &&
|
||
targetGroupId !== selectedRoomShareGroupId
|
||
) {
|
||
pendingRoomShareJumpRef.current = {
|
||
groupId: targetGroupId,
|
||
requestId: normalizedRequestId,
|
||
fallbackMessageId,
|
||
};
|
||
setSelectedRoomShareGroupId(targetGroupId);
|
||
return;
|
||
}
|
||
|
||
const didScroll = scrollToSystemExecutionRequest(request.requestId, {
|
||
fallbackMessageId,
|
||
jumpTarget,
|
||
});
|
||
|
||
if (
|
||
didScroll &&
|
||
(jumpTarget?.kind === 'prompt' || jumpTarget?.kind === 'response') &&
|
||
isPhoneLikeViewport() &&
|
||
systemExecutionDisplayMode === 'expanded'
|
||
) {
|
||
setSystemExecutionDisplayMode('hidden');
|
||
}
|
||
};
|
||
|
||
const markPreviewResourceOpened = (url: string) => {
|
||
const normalizedUrl = normalizeChatResourceUrl(url);
|
||
|
||
setOpenedPreviewUrls((current) => (current.includes(normalizedUrl) ? current : [...current, normalizedUrl]));
|
||
};
|
||
const markPreviewArtifactOpened = (requestId?: string | null) => {
|
||
const normalizedRequestId = requestId?.trim();
|
||
|
||
if (!normalizedRequestId) {
|
||
return;
|
||
}
|
||
|
||
setOpenedPreviewArtifactRequestIds((current) =>
|
||
current.includes(normalizedRequestId) ? current : [...current, normalizedRequestId],
|
||
);
|
||
};
|
||
const handleCompleteManualBadges = async (requestId: string, types: Array<'prompt' | 'verification'>) => {
|
||
const normalizedRequestId = requestId.trim();
|
||
const normalizedTypes = Array.from(new Set(types)).slice(0, 1);
|
||
const actionKeys = normalizedTypes.map((type) => `${type}:${normalizedRequestId}`);
|
||
|
||
if (
|
||
!normalizedRequestId ||
|
||
normalizedTypes.length === 0 ||
|
||
actionKeys.some((actionKey) => pendingManualCompletionActionKeySet.has(actionKey))
|
||
) {
|
||
return;
|
||
}
|
||
|
||
setPendingManualCompletionActionKeys((current) =>
|
||
Array.from(new Set([...current, ...actionKeys])),
|
||
);
|
||
|
||
try {
|
||
for (const type of normalizedTypes) {
|
||
await onCompleteManualRequestBadge(normalizedRequestId, type);
|
||
}
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '완료 상태를 저장하지 못했습니다.');
|
||
} finally {
|
||
setPendingManualCompletionActionKeys((current) =>
|
||
current.filter((item) => !actionKeys.includes(item)),
|
||
);
|
||
}
|
||
};
|
||
const completeManualBadge = async (options: {
|
||
requestId: string;
|
||
type: 'prompt' | 'verification';
|
||
}) => {
|
||
const normalizedRequestId = options.requestId.trim();
|
||
|
||
if (!normalizedRequestId) {
|
||
return;
|
||
}
|
||
|
||
const confirmed = await new Promise<boolean>((resolve) => {
|
||
modalApi.confirm({
|
||
title: options.type === 'prompt' ? 'prompt 완료 처리' : '응답 확인 처리',
|
||
content:
|
||
options.type === 'prompt'
|
||
? '이 요청의 prompt 선택대기를 수동 완료 처리할까요?'
|
||
: '이 요청의 일반 답변을 응답 확인 완료로 처리할까요?',
|
||
okText: '완료 처리',
|
||
cancelText: '취소',
|
||
centered: true,
|
||
onOk: async () => {
|
||
resolve(true);
|
||
},
|
||
onCancel: () => {
|
||
resolve(false);
|
||
},
|
||
});
|
||
});
|
||
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
await handleCompleteManualBadges(normalizedRequestId, [options.type]);
|
||
};
|
||
|
||
const renderSystemExecutionRequestSummary = (
|
||
request: ChatConversationRequest,
|
||
options?: { isCollapsed?: boolean; depth?: number },
|
||
) => {
|
||
const isCollapsed = options?.isCollapsed === true;
|
||
const depth = Math.max(0, options?.depth ?? 0);
|
||
const summaryMaxLength = resolveSystemExecutionSummaryMaxLength();
|
||
const groupedRequests =
|
||
depth === 0
|
||
? (requestIdsByRootRequestId.get(request.requestId) ?? [])
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
: [request];
|
||
const representativeRequest =
|
||
depth === 0
|
||
? resolveRepresentativeSystemExecutionRequest(groupedRequests, systemExecutionAttentionStateByRequestId) ?? request
|
||
: request;
|
||
const rootRelationshipBadge = depth === 0 ? buildRootRelationshipBadge(request, groupedRequests) : null;
|
||
const attentionState = systemExecutionAttentionStateByRequestId.get(representativeRequest.requestId);
|
||
const activityLines = attentionState?.activityLines ?? [];
|
||
const promptTargets = attentionState?.promptTargets ?? [];
|
||
const aggregatedStatusSummary = resolveAggregatedRequestStatusSummary(
|
||
groupedRequests.length > 0 ? groupedRequests : [request],
|
||
systemExecutionAttentionStateByRequestId,
|
||
);
|
||
const requestPrimaryStatusBadge = buildPrimaryStatusBadge(representativeRequest, {
|
||
activityLines,
|
||
promptTargets,
|
||
hasPendingPromptBadge: attentionState?.hasPendingPromptBadge === true,
|
||
hasPendingVerificationBadge: attentionState?.hasPendingVerificationBadge === true,
|
||
});
|
||
const primaryStatusBadge =
|
||
depth === 0 && aggregatedStatusSummary.label
|
||
? {
|
||
label: aggregatedStatusSummary.label,
|
||
shortLabel: aggregatedStatusSummary.label,
|
||
tone: aggregatedStatusSummary.tone,
|
||
}
|
||
: requestPrimaryStatusBadge;
|
||
const hierarchyBadge = buildHierarchyBadge(representativeRequest, depth);
|
||
const detailBadge = getRequestDetailBadge(representativeRequest);
|
||
const promptSubmittedCount = attentionState?.promptSubmittedCount ?? 0;
|
||
const isPromptManuallyCompleted = attentionState?.isPromptManuallyCompleted === true;
|
||
const promptStateBadge = buildPromptStateBadge({
|
||
promptTargets,
|
||
submittedCount: promptSubmittedCount,
|
||
isManuallyCompleted: isPromptManuallyCompleted,
|
||
});
|
||
const hasPendingPromptBadge = attentionState?.hasPendingPromptBadge === true;
|
||
const hasVerificationTarget = attentionState?.hasVerificationTarget === true;
|
||
const hasConfirmedVerificationTarget = attentionState?.hasConfirmedVerificationTarget === true;
|
||
const isVerificationManuallyCompleted = attentionState?.isVerificationManuallyCompleted === true;
|
||
const verificationStateBadge = buildVerificationStateBadge({
|
||
request: representativeRequest,
|
||
activityLines,
|
||
promptTargets,
|
||
hasVerificationTarget,
|
||
hasConfirmedVerificationTarget,
|
||
isManuallyCompleted: isVerificationManuallyCompleted,
|
||
isPromptManuallyCompleted,
|
||
});
|
||
const hasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
|
||
const manualCompletionTypes = buildManualCompletionTypes({
|
||
hasPendingPromptBadge,
|
||
hasPendingVerificationBadge,
|
||
});
|
||
const checklistBadge = buildChecklistStageBadge(activityLines, representativeRequest);
|
||
const readStateBadge =
|
||
verificationStateBadge ? null : buildReadStateBadge(representativeRequest, lastReadResponseMessageId);
|
||
const retryBadge = getRequestRetryBadge(representativeRequest);
|
||
const secondaryBadges = [
|
||
hierarchyBadge,
|
||
rootRelationshipBadge,
|
||
retryBadge,
|
||
detailBadge,
|
||
promptStateBadge,
|
||
verificationStateBadge,
|
||
checklistBadge,
|
||
readStateBadge,
|
||
].filter((badge): badge is SystemExecutionBadge => badge != null);
|
||
const primaryManualCompletionType = resolvePrimaryManualCompletionType({
|
||
secondaryBadges,
|
||
promptStateBadge,
|
||
verificationStateBadge,
|
||
hasPendingPromptBadge,
|
||
hasPendingVerificationBadge,
|
||
});
|
||
const canCompleteManualBadge = primaryManualCompletionType != null;
|
||
const isManualCompletionSaving = manualCompletionTypes.some((type) =>
|
||
pendingManualCompletionActionKeySet.has(`${type}:${representativeRequest.requestId}`),
|
||
);
|
||
const mobileSecondaryBadge = buildMobileSecondaryBadge(representativeRequest, secondaryBadges);
|
||
const displayBadges: SystemExecutionBadgeDisplay[] = mobileSecondaryBadge
|
||
? [...secondaryBadges.map((badge) => ({ ...badge, hideOnMobile: true })), mobileSecondaryBadge]
|
||
: secondaryBadges;
|
||
const summaryRequest = depth === 0 ? request : representativeRequest;
|
||
const summaryText = summarizeSystemExecutionRequestText(summaryRequest, {
|
||
preferResponse: false,
|
||
maxLength: summaryMaxLength,
|
||
});
|
||
const detailText = getRequestDetailText(summaryRequest);
|
||
const responseSummary = String(summaryRequest.responseText ?? '').replace(/\s+/g, ' ').trim();
|
||
const representativeSummaryText =
|
||
representativeRequest.requestId !== summaryRequest.requestId
|
||
? summarizeSystemExecutionRequestText(representativeRequest, {
|
||
preferResponse: false,
|
||
maxLength: summaryMaxLength,
|
||
})
|
||
: '';
|
||
const supplementalDetail = representativeSummaryText
|
||
? `현재 확인 대상: ${representativeSummaryText}`
|
||
: responseSummary && summarizeQueuedText(responseSummary, summaryMaxLength) !== summaryText
|
||
? `답변: ${summarizeQueuedText(responseSummary, summaryMaxLength)}`
|
||
: detailText && detailText !== summaryText
|
||
? detailText
|
||
: '';
|
||
const activityOverviewTargetRequest = resolveVisibleActivityOverviewTargetRequest(
|
||
depth === 0 ? groupedRequests : [request],
|
||
systemExecutionActivityOverviewByRequestId,
|
||
systemExecutionAttentionStateByRequestId,
|
||
);
|
||
const activityActionTargetRequest = resolveActiveSystemExecutionActionTargetRequest(
|
||
depth === 0 ? groupedRequests : [request],
|
||
systemExecutionAttentionStateByRequestId,
|
||
);
|
||
const activityOverview = activityOverviewTargetRequest
|
||
? systemExecutionActivityOverviewByRequestId.get(activityOverviewTargetRequest.requestId) ?? null
|
||
: null;
|
||
const canToggleActivityOverview = activityActionTargetRequest != null;
|
||
const timestampLabel = resolveSystemExecutionRequestTimestamp(representativeRequest);
|
||
const elapsedLabel = formatSystemExecutionElapsedLabel(representativeRequest);
|
||
const summaryJumpTarget = resolveSystemExecutionJumpTarget(summaryRequest);
|
||
const representativeJumpTarget = resolveSystemExecutionJumpTarget(representativeRequest);
|
||
const jumpTargetRequest =
|
||
getSystemExecutionJumpTargetPriority(representativeJumpTarget) >
|
||
getSystemExecutionJumpTargetPriority(summaryJumpTarget)
|
||
? representativeRequest
|
||
: summaryRequest;
|
||
const jumpButtonLabel =
|
||
(jumpTargetRequest.requestId === representativeRequest.requestId
|
||
? representativeJumpTarget
|
||
: summaryJumpTarget)?.buttonLabel ?? '위치로 이동';
|
||
|
||
return (
|
||
<div
|
||
key={request.requestId}
|
||
className={`app-chat-panel__system-execution-record${
|
||
isCollapsed ? ' app-chat-panel__system-execution-record--collapsed' : ''
|
||
}${depth > 0 ? ' app-chat-panel__system-execution-record--child' : ''}`}
|
||
style={
|
||
depth > 0
|
||
? {
|
||
'--system-execution-indent-level': String(depth),
|
||
} as CSSProperties
|
||
: undefined
|
||
}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="app-chat-panel__system-execution-record-main"
|
||
aria-label={jumpButtonLabel}
|
||
onClick={() => {
|
||
handleSystemExecutionJump(jumpTargetRequest);
|
||
}}
|
||
>
|
||
{depth > 0 ? (
|
||
<span className="app-chat-panel__system-execution-record-hierarchy">
|
||
부모 요청에 연결된 자식 실행
|
||
</span>
|
||
) : rootRelationshipBadge ? (
|
||
<span className="app-chat-panel__system-execution-record-hierarchy app-chat-panel__system-execution-record-hierarchy--root">
|
||
{rootRelationshipBadge.label}
|
||
</span>
|
||
) : null}
|
||
<span className="app-chat-panel__system-execution-record-row">
|
||
<span className="app-chat-panel__system-execution-record-badges">
|
||
<span
|
||
className={`app-chat-panel__system-execution-record-status app-chat-panel__system-execution-record-status--${primaryStatusBadge.tone}`}
|
||
aria-label={primaryStatusBadge.label}
|
||
>
|
||
<span className="app-chat-panel__system-execution-record-status-text app-chat-panel__system-execution-record-status-text--full">
|
||
{primaryStatusBadge.label}
|
||
</span>
|
||
<span className="app-chat-panel__system-execution-record-status-text app-chat-panel__system-execution-record-status-text--compact">
|
||
{primaryStatusBadge.shortLabel}
|
||
</span>
|
||
</span>
|
||
{displayBadges.map((badge) => (
|
||
<span
|
||
key={`${request.requestId}:${badge.label}:${badge.tone}:${badge.shortLabel}:${mobileSecondaryBadge === badge ? 'mobile' : 'full'}`}
|
||
className={`app-chat-panel__system-execution-record-status app-chat-panel__system-execution-record-status--${badge.tone}${
|
||
badge.hideOnMobile ? ' app-chat-panel__system-execution-record-status--desktop-only' : ''
|
||
}${mobileSecondaryBadge === badge ? ' app-chat-panel__system-execution-record-status--mobile-summary' : ''}`}
|
||
aria-label={badge.label}
|
||
>
|
||
<span className="app-chat-panel__system-execution-record-status-text app-chat-panel__system-execution-record-status-text--full">
|
||
{badge.label}
|
||
</span>
|
||
<span className="app-chat-panel__system-execution-record-status-text app-chat-panel__system-execution-record-status-text--compact">
|
||
{badge.shortLabel}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</span>
|
||
{timestampLabel || elapsedLabel ? (
|
||
<span className="app-chat-panel__system-execution-record-time">
|
||
{[timestampLabel, elapsedLabel].filter(Boolean).join(' · ')}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
<span className="app-chat-panel__system-execution-record-text">{summaryText}</span>
|
||
{supplementalDetail && supplementalDetail !== summaryText ? (
|
||
<span className="app-chat-panel__system-execution-record-detail">{supplementalDetail}</span>
|
||
) : null}
|
||
</button>
|
||
{canCompleteManualBadge || canToggleActivityOverview || Boolean(onShareRequestBundle) ? (
|
||
<div className="app-chat-panel__system-execution-record-actions">
|
||
{onShareRequestBundle ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-execution-record-action"
|
||
icon={<ShareAltOutlined />}
|
||
aria-label="요청묶음 공유"
|
||
onClick={() => {
|
||
onShareRequestBundle(representativeRequest.requestId);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{canToggleActivityOverview ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-execution-record-action app-chat-panel__system-execution-record-action--activity"
|
||
icon={<ProfileOutlined />}
|
||
aria-label="Plan 체크리스트와 실행기 보기"
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode('expanded');
|
||
setExpandedSystemExecutionActivityRequestId(
|
||
activityOverviewTargetRequest?.requestId ?? activityActionTargetRequest?.requestId ?? null,
|
||
);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{canCompleteManualBadge ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
loading={isManualCompletionSaving}
|
||
className="app-chat-panel__system-execution-record-action app-chat-panel__system-execution-record-action--complete"
|
||
onClick={() => {
|
||
if (!primaryManualCompletionType) {
|
||
return;
|
||
}
|
||
|
||
void completeManualBadge({
|
||
requestId: representativeRequest.requestId,
|
||
type: primaryManualCompletionType,
|
||
});
|
||
}}
|
||
>
|
||
완료
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
const renderSystemExecutionRequestTree = (
|
||
request: ChatConversationRequest,
|
||
options?: {
|
||
isCollapsed?: boolean;
|
||
depth?: number;
|
||
scopeRequestIdSet?: Set<string>;
|
||
excludedRequestIdSet?: Set<string>;
|
||
filterToVisibleSet?: boolean;
|
||
visitedRequestIdSet?: Set<string>;
|
||
},
|
||
): ReactNode => {
|
||
const depth = Math.max(0, options?.depth ?? 0);
|
||
const isCollapsed = options?.isCollapsed === true;
|
||
const scopeRequestIdSet = options?.scopeRequestIdSet ?? null;
|
||
const excludedRequestIdSet = options?.excludedRequestIdSet ?? null;
|
||
const filterToVisibleSet = options?.filterToVisibleSet !== false;
|
||
const visitedRequestIdSet = options?.visitedRequestIdSet ?? new Set<string>();
|
||
|
||
if (visitedRequestIdSet.has(request.requestId)) {
|
||
return renderSystemExecutionRequestSummary(request, { isCollapsed: true, depth });
|
||
}
|
||
|
||
const nextVisitedRequestIdSet = new Set(visitedRequestIdSet);
|
||
nextVisitedRequestIdSet.add(request.requestId);
|
||
const childRequests = isCollapsed
|
||
? []
|
||
: (childRequestIdsByParentRequestId.get(request.requestId) ?? [])
|
||
.filter((requestId) => {
|
||
if (scopeRequestIdSet && !scopeRequestIdSet.has(requestId)) {
|
||
return false;
|
||
}
|
||
|
||
if (excludedRequestIdSet?.has(requestId)) {
|
||
return false;
|
||
}
|
||
|
||
return !filterToVisibleSet || filteredSystemExecutionRequestIdSet.has(requestId);
|
||
})
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
.sort((left, right) => compareSystemExecutionHierarchyRequests(left, right, systemExecutionSort));
|
||
|
||
return (
|
||
<div
|
||
key={`${request.requestId}:${depth}:${isCollapsed ? 'collapsed' : 'expanded'}`}
|
||
className={`app-chat-panel__system-execution-record-tree${
|
||
depth > 0 ? ' app-chat-panel__system-execution-record-tree--child' : ''
|
||
}`}
|
||
>
|
||
{renderSystemExecutionRequestSummary(request, { isCollapsed, depth })}
|
||
{childRequests.length > 0 ? (
|
||
<div className="app-chat-panel__system-execution-record-children">
|
||
{childRequests.map((childRequest) =>
|
||
renderSystemExecutionRequestTree(childRequest, {
|
||
depth: depth + 1,
|
||
scopeRequestIdSet,
|
||
excludedRequestIdSet,
|
||
filterToVisibleSet,
|
||
visitedRequestIdSet: nextVisitedRequestIdSet,
|
||
}),
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const resolveVisibleSystemExecutionEntryRequests = (
|
||
rootRequest: ChatConversationRequest,
|
||
options?: {
|
||
filterToVisibleSet?: boolean;
|
||
},
|
||
) => {
|
||
const filterToVisibleSet = options?.filterToVisibleSet !== false;
|
||
|
||
if (!filterToVisibleSet || filteredSystemExecutionRequestIdSet.has(rootRequest.requestId)) {
|
||
return [rootRequest];
|
||
}
|
||
|
||
const entryRequests: ChatConversationRequest[] = [];
|
||
const seenEntryRequestIds = new Set<string>();
|
||
const visitedParentRequestIds = new Set<string>();
|
||
const pendingParentRequestIds = [rootRequest.requestId];
|
||
|
||
while (pendingParentRequestIds.length > 0) {
|
||
const currentParentRequestId = pendingParentRequestIds.shift();
|
||
|
||
if (!currentParentRequestId || visitedParentRequestIds.has(currentParentRequestId)) {
|
||
continue;
|
||
}
|
||
|
||
visitedParentRequestIds.add(currentParentRequestId);
|
||
const childRequestIds = childRequestIdsByParentRequestId.get(currentParentRequestId) ?? [];
|
||
|
||
childRequestIds.forEach((childRequestId) => {
|
||
if (filteredSystemExecutionRequestIdSet.has(childRequestId)) {
|
||
if (seenEntryRequestIds.has(childRequestId)) {
|
||
return;
|
||
}
|
||
|
||
const childRequest = requestStateMap.get(childRequestId);
|
||
|
||
if (!childRequest) {
|
||
return;
|
||
}
|
||
|
||
entryRequests.push(childRequest);
|
||
seenEntryRequestIds.add(childRequestId);
|
||
return;
|
||
}
|
||
|
||
pendingParentRequestIds.push(childRequestId);
|
||
});
|
||
}
|
||
|
||
return entryRequests.length > 0
|
||
? entryRequests.sort((left, right) => compareSystemExecutionHierarchyRequests(left, right, systemExecutionSort))
|
||
: [rootRequest];
|
||
};
|
||
|
||
const renderSystemExecutionRootEntries = (
|
||
rootRequest: ChatConversationRequest,
|
||
options?: {
|
||
isCollapsed?: boolean;
|
||
filterToVisibleSet?: boolean;
|
||
},
|
||
) =>
|
||
resolveVisibleSystemExecutionEntryRequests(rootRequest, {
|
||
filterToVisibleSet: options?.filterToVisibleSet,
|
||
}).map((entryRequest) =>
|
||
renderSystemExecutionRequestTree(entryRequest, {
|
||
depth: getConversationRequestDepth(entryRequest, requestStateMap),
|
||
isCollapsed: options?.isCollapsed,
|
||
filterToVisibleSet: options?.filterToVisibleSet,
|
||
}),
|
||
);
|
||
|
||
const renderEmbeddedChildRequestTree = (
|
||
parentRequest: ChatConversationRequest | null | undefined,
|
||
options?: {
|
||
title?: string;
|
||
className?: string;
|
||
scopeRequestIdSet?: Set<string>;
|
||
excludedRequestIdSet?: Set<string>;
|
||
filterToVisibleSet?: boolean;
|
||
flattenDescendants?: boolean;
|
||
},
|
||
): ReactNode => {
|
||
if (!parentRequest) {
|
||
return null;
|
||
}
|
||
|
||
const scopeRequestIdSet = options?.scopeRequestIdSet ?? null;
|
||
const excludedRequestIdSet = options?.excludedRequestIdSet ?? null;
|
||
const filterToVisibleSet = options?.filterToVisibleSet !== false;
|
||
const flattenDescendants = options?.flattenDescendants === true;
|
||
const visibleChildRequestIds = new Set<string>();
|
||
const visitedRequestIds = new Set<string>();
|
||
const pendingParentRequestIds = [parentRequest.requestId];
|
||
|
||
while (pendingParentRequestIds.length > 0) {
|
||
const currentParentRequestId = pendingParentRequestIds.shift();
|
||
|
||
if (!currentParentRequestId || visitedRequestIds.has(currentParentRequestId)) {
|
||
continue;
|
||
}
|
||
|
||
visitedRequestIds.add(currentParentRequestId);
|
||
const currentChildRequestIds = childRequestIdsByParentRequestId.get(currentParentRequestId) ?? [];
|
||
|
||
currentChildRequestIds.forEach((requestId) => {
|
||
if (scopeRequestIdSet && !scopeRequestIdSet.has(requestId)) {
|
||
return;
|
||
}
|
||
|
||
if (excludedRequestIdSet?.has(requestId)) {
|
||
return;
|
||
}
|
||
|
||
if (filterToVisibleSet && !filteredSystemExecutionRequestIdSet.has(requestId)) {
|
||
return;
|
||
}
|
||
|
||
if (visibleChildRequestIds.has(requestId)) {
|
||
return;
|
||
}
|
||
|
||
visibleChildRequestIds.add(requestId);
|
||
|
||
if (flattenDescendants) {
|
||
pendingParentRequestIds.push(requestId);
|
||
}
|
||
});
|
||
|
||
if (!flattenDescendants) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
const childRequests = Array.from(visibleChildRequestIds)
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
.sort((left, right) => compareSystemExecutionHierarchyRequests(left, right, systemExecutionSort));
|
||
|
||
if (childRequests.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<section className={['app-chat-embedded-request-tree', options?.className].filter(Boolean).join(' ')}>
|
||
<div className="app-chat-embedded-request-tree__title">{options?.title ?? '자식 요청'}</div>
|
||
<div className="app-chat-embedded-request-tree__body">
|
||
{childRequests.map((childRequest) =>
|
||
flattenDescendants
|
||
? renderSystemExecutionRequestSummary(childRequest, {
|
||
isCollapsed: true,
|
||
depth: 1,
|
||
})
|
||
: renderSystemExecutionRequestTree(childRequest, {
|
||
depth: getConversationRequestDepth(childRequest, requestStateMap),
|
||
scopeRequestIdSet,
|
||
excludedRequestIdSet,
|
||
filterToVisibleSet,
|
||
}),
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
|
||
|
||
const setChildComposerRef = (groupId: string, instance: TextAreaRef | null) => {
|
||
if (!groupId.trim()) {
|
||
return;
|
||
}
|
||
|
||
if (instance) {
|
||
childComposerRefs.current.set(groupId, instance);
|
||
return;
|
||
}
|
||
|
||
childComposerRefs.current.delete(groupId);
|
||
};
|
||
|
||
const openChildComposer = (groupId: string) => {
|
||
const normalizedGroupId = groupId.trim();
|
||
|
||
if (!normalizedGroupId) {
|
||
return;
|
||
}
|
||
|
||
setOpenedChildComposerGroupIds((current) =>
|
||
current.includes(normalizedGroupId) ? current : [...current, normalizedGroupId],
|
||
);
|
||
|
||
window.setTimeout(() => {
|
||
childComposerRefs.current.get(normalizedGroupId)?.focus({ cursor: 'end' });
|
||
}, 0);
|
||
};
|
||
|
||
const activateReplyReference = (
|
||
requestId: string | null | undefined,
|
||
options?: {
|
||
focusComposer?: boolean;
|
||
},
|
||
) => {
|
||
const normalizedRequestId = requestId?.trim() || '';
|
||
|
||
if (!normalizedRequestId) {
|
||
return;
|
||
}
|
||
|
||
setReplyReferenceRequestId(normalizedRequestId);
|
||
|
||
if (options?.focusComposer !== false) {
|
||
composerRef.current?.focus({ cursor: 'end' });
|
||
}
|
||
};
|
||
|
||
const closeChildComposer = (groupId: string) => {
|
||
const normalizedGroupId = groupId.trim();
|
||
|
||
if (!normalizedGroupId) {
|
||
return;
|
||
}
|
||
|
||
setOpenedChildComposerGroupIds((current) => current.filter((item) => item !== normalizedGroupId));
|
||
setSubmittingChildComposerGroupIds((current) => current.filter((item) => item !== normalizedGroupId));
|
||
};
|
||
|
||
const submitChildComposer = async (groupId: string, parentRequestId: string) => {
|
||
const normalizedGroupId = groupId.trim();
|
||
const normalizedParentRequestId = parentRequestId.trim();
|
||
const nextText = childComposerDraftsByGroupId[normalizedGroupId]?.trim() ?? '';
|
||
|
||
if (!normalizedGroupId || !normalizedParentRequestId || !nextText) {
|
||
return;
|
||
}
|
||
|
||
if (submittingChildComposerGroupIds.includes(normalizedGroupId)) {
|
||
return;
|
||
}
|
||
|
||
setSubmittingChildComposerGroupIds((current) =>
|
||
current.includes(normalizedGroupId) ? current : [...current, normalizedGroupId],
|
||
);
|
||
|
||
try {
|
||
const submitted = await onSubmitChildRequest({
|
||
text: nextText,
|
||
parentRequestId: normalizedParentRequestId,
|
||
});
|
||
|
||
if (!submitted) {
|
||
return;
|
||
}
|
||
|
||
setChildComposerDraftsByGroupId((current) => {
|
||
if (!(normalizedGroupId in current)) {
|
||
return current;
|
||
}
|
||
|
||
const next = { ...current };
|
||
delete next[normalizedGroupId];
|
||
return next;
|
||
});
|
||
setOpenedChildComposerGroupIds((current) => current.filter((item) => item !== normalizedGroupId));
|
||
} finally {
|
||
setSubmittingChildComposerGroupIds((current) => current.filter((item) => item !== normalizedGroupId));
|
||
}
|
||
};
|
||
|
||
const renderConversationMessage = (
|
||
message: ChatMessage,
|
||
_options?: {
|
||
defaultCollapsedPrompt?: boolean;
|
||
},
|
||
) => {
|
||
const canCollapseMessage = collapsibleMessageIdSet.has(message.id);
|
||
// Prompt cards should open by default even when the surrounding message group is collapsed.
|
||
const defaultCollapsedPrompt = false;
|
||
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
||
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
||
const isRecoveredMissingRequest = isMissingRequestMessage(message);
|
||
const isRecoveredExecutionFailure = isExecutionFailureMessage(message);
|
||
const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${
|
||
isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : ''
|
||
}`;
|
||
const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, previewCardTargets, promptTargets } =
|
||
messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message);
|
||
const renderedText = isRecoveredMissingRequest
|
||
? getMissingRequestMessageText(message)
|
||
: isRecoveredExecutionFailure
|
||
? getExecutionFailureMessageText(message)
|
||
: visibleText;
|
||
const isActivityMessage = isActivityLogMessage(message);
|
||
const activityOverview = isActivityMessage
|
||
? extractSystemExecutionActivityOverview(
|
||
message.text
|
||
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
|
||
.split('\n\n')
|
||
.map((line) => line.trim())
|
||
.filter(Boolean),
|
||
)
|
||
: null;
|
||
|
||
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
||
const hasPreviewCards =
|
||
diffBlocks.length > 0 ||
|
||
inlinePreviewTargets.length > 0 ||
|
||
rankedLinkTargets.length > 0 ||
|
||
linkCardTargets.length > 0 ||
|
||
previewCardTargets.length > 0 ||
|
||
promptTargets.length > 0;
|
||
const shouldRenderStandalonePreview =
|
||
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
|
||
const stackClassName = [
|
||
`app-chat-message-stack app-chat-message-stack--${message.author}`,
|
||
useSharedRoomsBubbleFlow ? 'app-chat-message-stack--rooms-share-bubble' : '',
|
||
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const messageClassName = [
|
||
'app-chat-message',
|
||
isRecoveredMissingRequest || isRecoveredExecutionFailure
|
||
? 'app-chat-message--system-inline'
|
||
: isActivityMessage
|
||
? 'app-chat-message--activity'
|
||
: `app-chat-message--${message.author}`,
|
||
useSharedRoomsBubbleFlow ? 'app-chat-message--rooms-share-bubble' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const previewContainerClassName = [
|
||
'app-chat-message-stack__previews',
|
||
useSharedRoomsBubbleFlow ? 'app-chat-message-stack__previews--rooms-share-bubble' : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
|
||
const attentionState = message.clientRequestId
|
||
? systemExecutionAttentionStateByRequestId.get(message.clientRequestId)
|
||
: undefined;
|
||
const requestStatusLabel = formatRequestStatusLabel(requestState, attentionState, {
|
||
hideFinalizedLabel: message.author === 'user',
|
||
});
|
||
const requestDetailText = getRequestDetailText(requestState);
|
||
const responsePromptTargets = attentionState?.promptTargets ?? [];
|
||
const responsePromptSubmittedCount = attentionState?.promptSubmittedCount ?? 0;
|
||
const isResponsePromptManuallyCompleted = attentionState?.isPromptManuallyCompleted === true;
|
||
const responsePromptStateBadge = buildPromptStateBadge({
|
||
promptTargets: responsePromptTargets,
|
||
submittedCount: responsePromptSubmittedCount,
|
||
isManuallyCompleted: isResponsePromptManuallyCompleted,
|
||
});
|
||
const responseHasPendingPromptBadge = attentionState?.hasPendingPromptBadge === true;
|
||
const responseHasVerificationTarget = attentionState?.hasVerificationTarget === true;
|
||
const responseHasConfirmedVerificationTarget = attentionState?.hasConfirmedVerificationTarget === true;
|
||
const isResponseVerificationManuallyCompleted = attentionState?.isVerificationManuallyCompleted === true;
|
||
const responseVerificationStateBadge = buildVerificationStateBadge({
|
||
request: requestState,
|
||
activityLines: attentionState?.activityLines ?? [],
|
||
promptTargets: responsePromptTargets,
|
||
hasVerificationTarget: responseHasVerificationTarget,
|
||
hasConfirmedVerificationTarget: responseHasConfirmedVerificationTarget,
|
||
isManuallyCompleted: isResponseVerificationManuallyCompleted,
|
||
isPromptManuallyCompleted: isResponsePromptManuallyCompleted,
|
||
});
|
||
const responseHasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
|
||
const responseSecondaryBadges = [responsePromptStateBadge, responseVerificationStateBadge].filter(
|
||
(badge): badge is SystemExecutionBadge => badge != null,
|
||
);
|
||
const responseRetryBadge = getRequestRetryBadge(requestState);
|
||
const responseDisplayBadges = [
|
||
...(responseRetryBadge ? [responseRetryBadge] : []),
|
||
...responseSecondaryBadges,
|
||
];
|
||
const responsePrimaryManualCompletionType = resolvePrimaryManualCompletionType({
|
||
secondaryBadges: responseDisplayBadges,
|
||
promptStateBadge: responsePromptStateBadge,
|
||
verificationStateBadge: responseVerificationStateBadge,
|
||
hasPendingPromptBadge: responseHasPendingPromptBadge,
|
||
hasPendingVerificationBadge: responseHasPendingVerificationBadge,
|
||
});
|
||
const responseManualCompletionTypes = buildManualCompletionTypes({
|
||
hasPendingPromptBadge: responseHasPendingPromptBadge,
|
||
hasPendingVerificationBadge: responseHasPendingVerificationBadge,
|
||
});
|
||
const isResponseManualCompletionSaving =
|
||
Boolean(message.clientRequestId) &&
|
||
responseManualCompletionTypes.some((type) =>
|
||
pendingManualCompletionActionKeySet.has(`${type}:${message.clientRequestId}`),
|
||
);
|
||
const promptParentRequestId = resolvePromptSubmissionParentRequestId(message.clientRequestId ?? null, requestStateMap);
|
||
const bubblePromptShareTarget =
|
||
message.author === 'codex' && promptParentRequestId && promptTargets.length > 0 && onSharePromptTarget
|
||
? {
|
||
requestId: promptParentRequestId,
|
||
sourceMessageId: message.id,
|
||
promptIndex: 0,
|
||
promptTitle: promptTargets[0]?.title || 'prompt',
|
||
promptSignature: buildPromptTargetSignature(promptTargets[0]!),
|
||
}
|
||
: null;
|
||
const showResponseManualCompleteAction =
|
||
enableExecutionReviewUi &&
|
||
message.author === 'codex' &&
|
||
Boolean(message.clientRequestId) &&
|
||
Boolean(responsePrimaryManualCompletionType);
|
||
const canCompletePromptFromResponse =
|
||
showRoomsShareHeader &&
|
||
message.author === 'codex' &&
|
||
Boolean(message.clientRequestId) &&
|
||
responsePromptTargets.length > 0 &&
|
||
!isResponsePromptManuallyCompleted &&
|
||
!isRequestInFlightStatus(requestState?.status);
|
||
const canCompleteVerificationFromResponse =
|
||
showRoomsShareHeader &&
|
||
message.author === 'codex' &&
|
||
Boolean(message.clientRequestId) &&
|
||
responsePromptTargets.length === 0 &&
|
||
responsePrimaryManualCompletionType === 'verification' &&
|
||
!isRequestInFlightStatus(requestState?.status);
|
||
const canReplyToResponse =
|
||
showRoomsShareHeader &&
|
||
message.author === 'codex' &&
|
||
Boolean(message.clientRequestId) &&
|
||
responsePromptTargets.length === 0 &&
|
||
!isRequestInFlightStatus(requestState?.status);
|
||
const isReplyReferenceActive =
|
||
canReplyToResponse &&
|
||
replyReferenceRequestId.trim() === (message.clientRequestId?.trim() ?? '');
|
||
|
||
return (
|
||
<div
|
||
key={message.id}
|
||
ref={(element) => {
|
||
setMessageAnchorRef(message.id, element);
|
||
}}
|
||
className={stackClassName}
|
||
>
|
||
{shouldRenderStandalonePreview ? null : (
|
||
<article className={messageClassName}>
|
||
<div className="app-chat-message__header">
|
||
<div className="app-chat-message__header-meta">
|
||
<strong>
|
||
{isRecoveredMissingRequest
|
||
? '원문 누락'
|
||
: isRecoveredExecutionFailure
|
||
? '실행 실패'
|
||
: isActivityMessage
|
||
? '진행 현황'
|
||
: message.author === 'codex'
|
||
? 'Codex'
|
||
: message.author === 'user'
|
||
? 'You'
|
||
: 'System'}
|
||
</strong>
|
||
<span>{formatChatTimestamp(message.timestamp)}</span>
|
||
{message.author === 'user' && requestStatusLabel ? (
|
||
<span className="app-chat-message__status" aria-label={`요청 상태 ${requestStatusLabel}`}>
|
||
<span>{requestStatusLabel}</span>
|
||
</span>
|
||
) : null}
|
||
{message.author === 'user' &&
|
||
requestState?.status === 'queued' &&
|
||
message.clientRequestId &&
|
||
onPromoteQueuedRequest ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-message__retry app-chat-message__retry--icon-only app-chat-message__queue-promote"
|
||
icon={<ThunderboltOutlined />}
|
||
aria-label="즉시 접수"
|
||
onClick={() => {
|
||
onPromoteQueuedRequest(message.clientRequestId!, requestState.userText || message.text);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{message.author === 'user' && message.deliveryStatus === 'retrying' ? (
|
||
<span className="app-chat-message__status app-chat-message__status--retrying" aria-label="재전송 중">
|
||
<SyncOutlined spin />
|
||
<span>{message.retryCount && message.retryCount > 0 ? `재시도 ${message.retryCount}` : '재전송 대기'}</span>
|
||
</span>
|
||
) : null}
|
||
{message.author === 'user' && message.deliveryStatus === 'failed' ? (
|
||
<span className="app-chat-message__status app-chat-message__status--failed" aria-label="전송 실패">
|
||
<ExclamationCircleOutlined />
|
||
<span>전송 실패</span>
|
||
</span>
|
||
) : null}
|
||
{message.author === 'user' &&
|
||
(message.deliveryStatus === 'retrying' || message.deliveryStatus === 'failed') ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
danger
|
||
className="app-chat-message__cancel"
|
||
icon={<CloseOutlined />}
|
||
onClick={() => {
|
||
onCancelMessage(message);
|
||
}}
|
||
>
|
||
취소
|
||
</Button>
|
||
) : null}
|
||
{message.author === 'user' && message.deliveryStatus === 'failed' ? (
|
||
<>
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-message__retry"
|
||
icon={<RedoOutlined />}
|
||
onClick={() => {
|
||
onRetryMessage(message);
|
||
}}
|
||
>
|
||
재전송
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
{message.author === 'user' && message.deliveryStatus !== 'failed' && requestState?.status === 'failed' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-message__retry app-chat-message__retry--icon-only"
|
||
icon={<RedoOutlined />}
|
||
aria-label="실패 요청 재처리"
|
||
title="실패 요청 재처리"
|
||
onClick={() => {
|
||
onRetryFailedRequest(requestState);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{message.author === 'user' &&
|
||
requestState?.canDelete &&
|
||
requestState.status !== 'accepted' ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
className="app-chat-message__retry app-chat-message__delete"
|
||
icon={<DeleteOutlined />}
|
||
aria-label="메시지 삭제"
|
||
onClick={() => {
|
||
onDeleteRequest(message);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{message.author !== 'system' ? (
|
||
<div className="app-chat-message__header-actions">
|
||
{bubblePromptShareTarget ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__header-action"
|
||
icon={<ShareAltOutlined />}
|
||
aria-label="prompt 공유"
|
||
title="prompt 공유"
|
||
onClick={() => {
|
||
onSharePromptTarget?.(bubblePromptShareTarget);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{message.author === 'user' && requestState && onShareInquiryMessage ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__header-action"
|
||
icon={<ShareAltOutlined />}
|
||
aria-label="문의 공유"
|
||
title="문의 공유"
|
||
onClick={() => {
|
||
onShareInquiryMessage({
|
||
requestId: requestState.requestId,
|
||
messageId: message.id,
|
||
});
|
||
}}
|
||
/>
|
||
) : null}
|
||
{showResponseManualCompleteAction && !showRoomsShareHeader ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
loading={isResponseManualCompletionSaving}
|
||
className="app-chat-message__header-action app-chat-message__header-action--complete"
|
||
icon={<CheckOutlined />}
|
||
aria-label="응답 완료 처리"
|
||
title="응답 완료 처리"
|
||
onClick={() => {
|
||
if (!message.clientRequestId || !responsePrimaryManualCompletionType) {
|
||
return;
|
||
}
|
||
|
||
void completeManualBadge({
|
||
requestId: message.clientRequestId,
|
||
type: responsePrimaryManualCompletionType,
|
||
});
|
||
}}
|
||
/>
|
||
) : null}
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__header-action"
|
||
icon={<CopyOutlined />}
|
||
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
|
||
onClick={() => {
|
||
onCopyMessage(message);
|
||
}}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
{message.author === 'codex' && showRoomsShareHeader ? (
|
||
<div className="app-chat-message__response-actions">
|
||
{canCompletePromptFromResponse && message.clientRequestId ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__response-action"
|
||
icon={<CheckOutlined />}
|
||
loading={isResponseManualCompletionSaving}
|
||
onClick={() => {
|
||
void completeManualBadge({
|
||
requestId: message.clientRequestId!,
|
||
type: 'prompt',
|
||
});
|
||
}}
|
||
>
|
||
완료 처리
|
||
</Button>
|
||
) : null}
|
||
{!canCompletePromptFromResponse && isResponsePromptManuallyCompleted ? <Tag color="success">prompt 완료 처리됨</Tag> : null}
|
||
{canCompleteVerificationFromResponse && message.clientRequestId ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__response-action"
|
||
icon={<CheckOutlined />}
|
||
loading={isResponseManualCompletionSaving}
|
||
onClick={() => {
|
||
void completeManualBadge({
|
||
requestId: message.clientRequestId!,
|
||
type: 'verification',
|
||
});
|
||
}}
|
||
>
|
||
완료 처리
|
||
</Button>
|
||
) : null}
|
||
{!canCompleteVerificationFromResponse && responsePromptTargets.length === 0 && isResponseVerificationManuallyCompleted ? <Tag color="success">응답 확인 완료</Tag> : null}
|
||
{canReplyToResponse && message.clientRequestId ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={['app-chat-message__response-action', 'app-chat-message__response-reply-button', isReplyReferenceActive ? 'app-chat-message__response-reply-button--active' : ''].filter(Boolean).join(' ')}
|
||
icon={<SendOutlined />}
|
||
onClick={() => {
|
||
activateReplyReference(message.clientRequestId, {
|
||
focusComposer: false,
|
||
});
|
||
}}
|
||
>
|
||
{isReplyReferenceActive ? '답변 참조 중' : '답변하기'}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div
|
||
ref={(element) => {
|
||
setMessageBodyRef(message.id, element);
|
||
}}
|
||
className={baseMessageBodyClassName}
|
||
>
|
||
{isActivityMessage && activityOverview
|
||
? renderActivityOverviewBody(activityOverview, message.id, requestState, {
|
||
attentionState,
|
||
requestTree: renderEmbeddedChildRequestTree(requestState, {
|
||
title: '연결된 자식 요청',
|
||
className: 'app-chat-activity-checklist-stack__request-tree',
|
||
filterToVisibleSet: false,
|
||
}),
|
||
})
|
||
: renderedText
|
||
? renderMessageBody(renderedText)
|
||
: null}
|
||
</div>
|
||
{message.author === 'user' && requestDetailText ? (
|
||
<div className="app-chat-message__request-detail" role="status" aria-live="polite">
|
||
{requestDetailText}
|
||
</div>
|
||
) : null}
|
||
{canCollapseMessage ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message__expand"
|
||
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
|
||
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
|
||
onClick={() => {
|
||
const isExpanding = !isExpandedMessage;
|
||
|
||
if (isExpanding && message.author !== 'user') {
|
||
setExpandedResponseMessageIds((current) =>
|
||
current.includes(message.id) ? current : [...current, message.id],
|
||
);
|
||
}
|
||
|
||
setExpandedMessageIds((current) =>
|
||
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
|
||
);
|
||
}}
|
||
>
|
||
{isExpandedMessage ? '접기' : '펼치기'}
|
||
</Button>
|
||
) : null}
|
||
</article>
|
||
)}
|
||
{hasPreviewCards ? (
|
||
<div className={previewContainerClassName}>
|
||
{linkCardTargets.map((target) => (
|
||
<ChatLinkCardPreview
|
||
key={`${message.id}-${target.url}-${target.title}`}
|
||
target={target}
|
||
onOpen={() => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
}}
|
||
/>
|
||
))}
|
||
{previewCardTargets.map((target, index) => (
|
||
<ChatStructuredPreviewCard
|
||
key={`${message.id}-preview-card-${index}-${target.title}`}
|
||
target={target}
|
||
onOpen={(previewUrl) => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
|
||
if (previewUrl) {
|
||
markPreviewResourceOpened(previewUrl);
|
||
}
|
||
}}
|
||
/>
|
||
))}
|
||
{promptTargets.map((target, index) => (
|
||
(() => {
|
||
const selectionKey = buildPendingPromptSelectionKey(message.id, index, target.title, target);
|
||
const draftSelection = pendingPromptSelections[selectionKey]?.status === 'draft'
|
||
? pendingPromptSelections[selectionKey]
|
||
: null;
|
||
const submittedSelection = pendingPromptSelections[selectionKey]?.status === 'submitted'
|
||
? pendingPromptSelections[selectionKey]
|
||
: null;
|
||
const promptParentQuestionText = resolvePromptParentQuestionText(
|
||
promptParentRequestId ? requestStateMap.get(promptParentRequestId) ?? null : null,
|
||
);
|
||
|
||
return (
|
||
<ChatPromptCard
|
||
key={`${message.id}-prompt-${index}-${target.title}`}
|
||
target={target}
|
||
parentQuestionText={promptParentQuestionText}
|
||
defaultCollapsed={defaultCollapsedPrompt}
|
||
draftSelection={draftSelection}
|
||
submittedSelection={submittedSelection}
|
||
onPreviewViewed={(previewUrl) => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
|
||
if (previewUrl) {
|
||
markPreviewResourceOpened(previewUrl);
|
||
}
|
||
}}
|
||
onSubmit={(payload) =>
|
||
onSubmitPrompt({
|
||
...payload,
|
||
parentRequestId: promptParentRequestId || null,
|
||
promptIndex: index,
|
||
sourceMessageId: message.id,
|
||
})
|
||
}
|
||
anchorRef={
|
||
index === 0 && message.clientRequestId
|
||
? (element) => {
|
||
setPromptCardAnchorRef(message.id, element);
|
||
}
|
||
: null
|
||
}
|
||
onSharePrompt={
|
||
promptParentRequestId && onSharePromptTarget
|
||
? () => {
|
||
onSharePromptTarget({
|
||
requestId: promptParentRequestId,
|
||
sourceMessageId: message.id,
|
||
promptIndex: index,
|
||
promptTitle: target.title,
|
||
promptSignature: buildPromptTargetSignature(target),
|
||
});
|
||
}
|
||
: null
|
||
}
|
||
onSubmitted={(selection) => {
|
||
setPendingPromptSelections((current) => {
|
||
const previousSelection = current[selectionKey];
|
||
|
||
return {
|
||
...current,
|
||
[selectionKey]: {
|
||
...selection,
|
||
status: 'submitted',
|
||
promptTitle: target.title,
|
||
target,
|
||
requestId: promptParentRequestId || (message.clientRequestId ?? null),
|
||
summaryText:
|
||
selection.summaryText ||
|
||
previousSelection?.summaryText ||
|
||
null,
|
||
},
|
||
};
|
||
});
|
||
}}
|
||
onSelectionChange={(selection) => {
|
||
setPendingPromptSelections((current) => {
|
||
if (!selection) {
|
||
if (!(selectionKey in current)) {
|
||
return current;
|
||
}
|
||
|
||
const next = { ...current };
|
||
delete next[selectionKey];
|
||
return next;
|
||
}
|
||
|
||
return {
|
||
...current,
|
||
[selectionKey]: {
|
||
...selection,
|
||
status: 'draft',
|
||
promptTitle: target.title,
|
||
target,
|
||
requestId: message.clientRequestId ?? null,
|
||
},
|
||
};
|
||
});
|
||
}}
|
||
/>
|
||
);
|
||
})()
|
||
))}
|
||
{rankedLinkTargets.map((target) => (
|
||
<ChatRankedLinkPreview
|
||
key={`${message.id}-${target.url}-${target.title}`}
|
||
target={target}
|
||
onOpen={() => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
}}
|
||
/>
|
||
))}
|
||
{(() => {
|
||
if (!diffBlocks.length) {
|
||
return null;
|
||
}
|
||
|
||
const previewKey = `${message.id}-diff`;
|
||
const combinedDiff = combineCodexDiffBlocks(diffBlocks);
|
||
|
||
return (
|
||
<DiffMessagePreview
|
||
key={previewKey}
|
||
diffText={combinedDiff.diffText}
|
||
fileCount={Math.max(1, combinedDiff.fileCount)}
|
||
isExpanded={expandedPreviewKey === previewKey}
|
||
isFullscreen={fullscreenPreviewKey === previewKey}
|
||
onToggle={() => {
|
||
if (expandedPreviewKey !== previewKey && fullscreenPreviewKey !== previewKey) {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
}
|
||
setExpandedPreviewKey((current) => {
|
||
if (fullscreenPreviewKey === previewKey) {
|
||
setFullscreenPreviewKey(null);
|
||
return null;
|
||
}
|
||
|
||
return current === previewKey ? null : previewKey;
|
||
});
|
||
}}
|
||
onToggleFullscreen={() => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
setFullscreenPreviewKey((current) => {
|
||
const nextKey = current === previewKey ? null : previewKey;
|
||
|
||
if (nextKey) {
|
||
setExpandedPreviewKey(previewKey);
|
||
}
|
||
|
||
return nextKey;
|
||
});
|
||
}}
|
||
/>
|
||
);
|
||
})()}
|
||
{inlinePreviewTargets.map((target) => {
|
||
const previewKey = `${message.id}-${target.url}`;
|
||
const matchedPreview = previewItemsByUrl.get(target.url);
|
||
|
||
return (
|
||
<InlineMessagePreview
|
||
key={previewKey}
|
||
target={target}
|
||
isExpanded={expandedPreviewKey === previewKey}
|
||
hasModalPreview
|
||
onOpenModalPreview={() => {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
markPreviewResourceOpened(target.url);
|
||
onOpenPreview(
|
||
matchedPreview
|
||
? matchedPreview.id
|
||
: {
|
||
id: previewKey,
|
||
label: target.label,
|
||
url: target.url,
|
||
kind: target.kind,
|
||
source: 'message',
|
||
},
|
||
{ fullscreen: true },
|
||
);
|
||
}}
|
||
onToggle={() => {
|
||
if (expandedPreviewKey !== previewKey) {
|
||
markPreviewArtifactOpened(message.clientRequestId ?? null);
|
||
markPreviewResourceOpened(target.url);
|
||
}
|
||
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
||
}}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const syncPendingComposerUploads = async (files: File[]) => {
|
||
const attemptId = `composer-upload-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
|
||
const nextPendingUploads = files.map((file) => ({
|
||
attemptId,
|
||
key: buildComposerFilePickKey(file),
|
||
name: file.name,
|
||
status: 'uploading' as const,
|
||
}));
|
||
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
|
||
|
||
setPendingComposerUploads((current) => [
|
||
...current.filter((item) => !pendingKeys.has(item.key) && !nextPendingNames.has(normalizeAttachmentName(item.name))),
|
||
...nextPendingUploads,
|
||
]);
|
||
|
||
let result: ComposerFilePickResult = { items: [] };
|
||
|
||
try {
|
||
result = (await onPickComposerFiles(files)) ?? { items: [] };
|
||
} catch {
|
||
result = {
|
||
items: nextPendingUploads.map((item) => ({
|
||
key: item.key,
|
||
fileName: item.name,
|
||
status: 'failed',
|
||
})),
|
||
};
|
||
}
|
||
|
||
const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
|
||
result.items.map((item) => [item.key, item]),
|
||
);
|
||
|
||
setPendingComposerUploads((current) =>
|
||
current.flatMap((item) => {
|
||
if (item.attemptId !== attemptId || !pendingKeys.has(item.key)) {
|
||
return [item];
|
||
}
|
||
|
||
const matched = resultByKey.get(item.key);
|
||
|
||
if (!matched || matched.status === 'failed') {
|
||
return [{ ...item, status: 'failed', reason: matched?.reason }];
|
||
}
|
||
|
||
return [];
|
||
}),
|
||
);
|
||
};
|
||
|
||
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||
const files = Array.from(event.target.files ?? []);
|
||
event.target.value = '';
|
||
|
||
if (files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
void syncPendingComposerUploads(files);
|
||
};
|
||
|
||
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const clipboardData = event.clipboardData;
|
||
|
||
if (!clipboardData) {
|
||
return;
|
||
}
|
||
const files = resolveComposerPasteFiles(clipboardData);
|
||
|
||
if (files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
void syncPendingComposerUploads(files);
|
||
};
|
||
|
||
const handleComposerSendRequest = (draftText?: string) => {
|
||
const nextDraft = typeof draftText === 'string' ? draftText : composerDraftValueRef.current;
|
||
const normalizedReplyReferenceRequestId = replyReferenceRequestId.trim();
|
||
const result = normalizedReplyReferenceRequestId && onSendReplyToResponse
|
||
? onSendReplyToResponse({ draftText: nextDraft, parentRequestId: normalizedReplyReferenceRequestId })
|
||
: onSend(nextDraft);
|
||
|
||
if (result !== 'sent') {
|
||
return;
|
||
}
|
||
|
||
if (showRoomsShareHeader && roomShareExpandMode === 'latest') {
|
||
shouldFollowLatestRoomShareGroupRef.current = true;
|
||
}
|
||
|
||
lastSyncedComposerDraftRef.current = '';
|
||
composerDraftValueRef.current = '';
|
||
setComposerDraftValue('');
|
||
onDraftChange('');
|
||
if (normalizedReplyReferenceRequestId) {
|
||
setReplyReferenceRequestId('');
|
||
}
|
||
forceComposerDraftSync();
|
||
};
|
||
|
||
const handleComposerClear = () => {
|
||
lastSyncedComposerDraftRef.current = '';
|
||
composerDraftValueRef.current = '';
|
||
onDraftChange('');
|
||
onClearDraft();
|
||
};
|
||
|
||
const dismissPendingComposerUpload = (key: string) => {
|
||
setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
|
||
};
|
||
|
||
const composerAttachmentStrip =
|
||
pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
|
||
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
|
||
{pendingComposerUploads.map((upload) => (
|
||
<div
|
||
key={`pending:${upload.key}`}
|
||
className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
|
||
upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
|
||
}`}
|
||
title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
|
||
>
|
||
<span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
|
||
<span className="app-chat-panel__composer-attachment-pending-label">
|
||
{upload.status === 'failed' ? upload.reason ?? '업로드 실패' : '업로드 중'}
|
||
</span>
|
||
{upload.status === 'failed' ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__composer-attachment-remove"
|
||
icon={<CloseOutlined />}
|
||
aria-label={`${upload.name} 업로드 실패 항목 닫기`}
|
||
onClick={() => {
|
||
dismissPendingComposerUpload(upload.key);
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
{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={() => {
|
||
onRemoveComposerAttachment(attachment.id);
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null;
|
||
const replyReferenceRequest = replyReferenceRequestId.trim() ? requestStateMap.get(replyReferenceRequestId.trim()) ?? null : null;
|
||
const replyReferenceSummary = replyReferenceRequest
|
||
? summarizeQueuedText(
|
||
String(replyReferenceRequest.responseText ?? '').trim() ||
|
||
replyReferenceRequest.statusMessage?.trim() ||
|
||
replyReferenceRequest.userText ||
|
||
'선택한 답변',
|
||
96,
|
||
)
|
||
: '';
|
||
|
||
useEffect(() => {
|
||
if (!replyReferenceRequestId.trim()) {
|
||
return;
|
||
}
|
||
|
||
if (requestStateMap.has(replyReferenceRequestId.trim())) {
|
||
return;
|
||
}
|
||
|
||
setReplyReferenceRequestId('');
|
||
}, [replyReferenceRequestId, requestStateMap]);
|
||
|
||
const composerPlaceholder = isComposerDisabled
|
||
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
|
||
: replyReferenceRequest
|
||
? '선택한 답변을 바탕으로 새 대화에 이어서 보낼 내용을 입력하세요. 첨부만 추가해서 보내도 됩니다.'
|
||
: showRoomsShareHeader
|
||
? isMobileViewport
|
||
? '공유채팅에 보낼 내용을 입력하세요.'
|
||
: '공유채팅에 보낼 내용을 입력하세요. Ctrl+Enter로 전송하고, 답변과 preview를 이 대화에서 바로 이어서 확인할 수 있습니다.'
|
||
: isMobileViewport
|
||
? '메시지를 입력하세요.'
|
||
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
|
||
|
||
return (
|
||
<div
|
||
ref={viewportRef}
|
||
className="app-chat-panel__conversation-view"
|
||
onScroll={onViewportScroll}
|
||
onTouchEnd={onViewportTouchEnd}
|
||
onTouchMove={onViewportTouchMove}
|
||
onTouchStart={onViewportTouchStart}
|
||
>
|
||
{modalContextHolder}
|
||
{shouldShowConversationLoadingOverlay ? (
|
||
<div className="app-chat-panel__conversation-loading" aria-live="polite">
|
||
<Spin size="large" />
|
||
<strong>{conversationLoadingLabel}</strong>
|
||
<span>재접속 중에는 채팅방 내용을 다시 맞춘 뒤 자연스럽게 표시합니다.</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{showBusyOverlay ? (
|
||
<div className="app-chat-panel__busy-overlay" aria-live="polite" aria-busy="true">
|
||
<Spin size="large" />
|
||
<strong>{busyOverlayLabel}</strong>
|
||
<span>처리가 끝나면 화면이 바로 갱신됩니다.</span>
|
||
</div>
|
||
) : null}
|
||
|
||
<div
|
||
className={`app-chat-panel__conversation-view-inner${shouldShowConversationLoadingOverlay ? ' is-loading' : ''}${
|
||
showBusyOverlay ? ' is-busy' : ''
|
||
}`}
|
||
>
|
||
{showResourceStrip ? (
|
||
<div className="app-chat-panel__conversation-toolbar">
|
||
<Button
|
||
type={isResourceStripOpen ? 'default' : 'text'}
|
||
size="small"
|
||
className="app-chat-panel__conversation-toggle"
|
||
icon={<PaperClipOutlined />}
|
||
aria-label={isResourceStripOpen ? '채팅 리소스 숨기기' : '채팅 리소스 보기'}
|
||
onClick={onToggleResourceStrip}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{showResourceStrip && isResourceStripOpen ? (
|
||
<div className="app-chat-panel__resource-strip">
|
||
{previewItems.length > 0 ? (
|
||
<>
|
||
<label className="app-chat-panel__resource-strip-filter">
|
||
<Checkbox
|
||
checked={showLatestResourceOnly}
|
||
onChange={(event) => {
|
||
setShowLatestResourceOnly(event.target.checked);
|
||
}}
|
||
>
|
||
같은 파일명은 최종 리소스만 보기
|
||
</Checkbox>
|
||
</label>
|
||
<div className="app-chat-panel__resource-strip-list">
|
||
{visiblePreviewItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
className="app-chat-panel__resource-chip"
|
||
onClick={() => {
|
||
markPreviewResourceOpened(item.url);
|
||
onOpenPreview(item.id);
|
||
}}
|
||
>
|
||
<span className="app-chat-panel__resource-chip-main">
|
||
<span className="app-chat-panel__resource-chip-icon" aria-hidden="true">
|
||
{resolveChatPreviewGlyph(item.kind as ChatPreviewKind)}
|
||
</span>
|
||
<span className="app-chat-panel__resource-chip-label" title={item.label}>
|
||
{item.label}
|
||
</span>
|
||
</span>
|
||
<span className="app-chat-panel__resource-chip-meta" aria-label="preview 형식">
|
||
{buildResourceChipMeta(item)}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<span className="app-chat-panel__resource-strip-empty">
|
||
현재 대화에 바로 열 수 있는 리소스가 없습니다.
|
||
</span>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
{showRoomsShareHeader ? (
|
||
<div className="app-chat-panel__rooms-share-header">
|
||
<div className="app-chat-panel__rooms-share-header-main">
|
||
<div className="app-chat-panel__rooms-share-header-copy">
|
||
<div className="app-chat-panel__rooms-share-header-title-wrap">
|
||
<strong className="app-chat-panel__rooms-share-header-title">
|
||
{roomShareMenuTitle}
|
||
</strong>
|
||
<span
|
||
className={`app-chat-panel__rooms-share-live-indicator app-chat-panel__rooms-share-live-indicator--${
|
||
isLiveConnected ? 'connected' : 'disconnected'
|
||
}`}
|
||
aria-label={isLiveConnected ? '웹소켓 연결 정상' : '웹소켓 연결 끊김'}
|
||
title={isLiveConnected ? '웹소켓 연결 정상' : '웹소켓 연결 끊김'}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="app-chat-panel__rooms-share-header-actions">
|
||
<div className="app-chat-panel__rooms-share-nav" aria-label="대화 이동">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={`app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--nav${roomsShareUseSharedPageNav ? ' app-chat-panel__rooms-share-action--nav-shared' : ''}`}
|
||
icon={roomsShareUseSharedPageNav ? <LeftOutlined /> : undefined}
|
||
disabled={!canMoveToPreviousRoomShareGroup}
|
||
onClick={() => {
|
||
moveRoomShareGroupSelection('previous');
|
||
}}
|
||
>
|
||
{roomsShareUseSharedPageNav ? '이전' : <span className="app-chat-panel__rooms-share-action-label">{'< 이전'}</span>}
|
||
</Button>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={`app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--nav${roomsShareUseSharedPageNav ? ' app-chat-panel__rooms-share-action--nav-shared' : ''}`}
|
||
icon={roomsShareUseSharedPageNav ? <RightOutlined /> : undefined}
|
||
iconPosition={roomsShareUseSharedPageNav ? 'end' : undefined}
|
||
disabled={!canMoveToNextRoomShareGroup}
|
||
onClick={() => {
|
||
moveRoomShareGroupSelection('next');
|
||
}}
|
||
>
|
||
{roomsShareUseSharedPageNav ? '다음' : <span className="app-chat-panel__rooms-share-action-label">{'다음 >'}</span>}
|
||
</Button>
|
||
</div>
|
||
{roomsHeaderMenuItems && onRoomsHeaderMenuAction ? (
|
||
<div className="app-chat-panel__rooms-share-window-actions">
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
menu={{
|
||
items: roomsHeaderMenuItems,
|
||
onClick: ({ key }) => {
|
||
onRoomsHeaderMenuAction(String(key));
|
||
},
|
||
}}
|
||
>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--tool"
|
||
icon={<SettingOutlined />}
|
||
aria-label="채팅방 설정"
|
||
title="채팅방 설정"
|
||
>
|
||
<span className="app-chat-panel__rooms-share-tool-label">설정</span>
|
||
</Button>
|
||
</Dropdown>
|
||
{onRoomsHeaderMinimize ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon"
|
||
icon={<MinusOutlined />}
|
||
aria-label="채팅 최소화"
|
||
title="채팅 최소화"
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
onRoomsHeaderMinimize();
|
||
}}
|
||
/>
|
||
) : null}
|
||
{onRoomsHeaderClose ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
danger
|
||
className="app-chat-panel__rooms-share-action app-chat-panel__rooms-share-action--icon app-chat-panel__rooms-share-action--close"
|
||
icon={<CloseOutlined />}
|
||
aria-label="채팅 닫기"
|
||
title="채팅 닫기"
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
onRoomsHeaderClose();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="app-chat-panel__rooms-share-header-sub">
|
||
<div className="app-chat-panel__rooms-share-current">
|
||
<span className="app-chat-panel__rooms-share-summary">
|
||
{roomShareHeaderSummaryLabel}
|
||
</span>
|
||
</div>
|
||
<Dropdown
|
||
trigger={['click']}
|
||
placement="bottomRight"
|
||
menu={{
|
||
items: roomShareExpandModeMenuItems,
|
||
selectable: true,
|
||
selectedKeys: [roomShareExpandMode],
|
||
onClick: ({ key }) => {
|
||
setRoomShareExpandMode(key as RoomShareExpandMode);
|
||
},
|
||
}}
|
||
>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className={`app-chat-panel__rooms-share-action app-chat-panel__rooms-share-filter${
|
||
roomShareExpandMode !== 'latest' ? ' app-chat-panel__rooms-share-filter--active' : ''
|
||
}`}
|
||
aria-label={`공유형 필터: ${ROOM_SHARE_EXPAND_MODE_LABELS[roomShareExpandMode]} ${roomShareProgressLabel}`.trim()}
|
||
title={`공유형 필터: ${ROOM_SHARE_EXPAND_MODE_LABELS[roomShareExpandMode]} ${roomShareProgressLabel}`.trim()}
|
||
icon={<FilterOutlined />}
|
||
>
|
||
{roomShareExpandMode === 'latest' ? '필터' : ROOM_SHARE_EXPAND_MODE_LABELS[roomShareExpandMode]}
|
||
</Button>
|
||
</Dropdown>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="app-chat-panel__messages">
|
||
{isLoadingOlderMessages || pullToLoadDistance > 0 ? (
|
||
<div
|
||
className={`app-chat-panel__history-loader${
|
||
isLoadingOlderMessages ? ' is-loading' : ''
|
||
}${isPullToLoadArmed ? ' is-armed' : ''}`}
|
||
style={{
|
||
maxHeight: `${Math.max(isLoadingOlderMessages ? 52 : 0, pullToLoadDistance)}px`,
|
||
opacity: isLoadingOlderMessages || pullToLoadDistance > 0 ? 1 : 0.72,
|
||
}}
|
||
>
|
||
<Spin size="small" spinning={isLoadingOlderMessages} />
|
||
<span>
|
||
{isLoadingOlderMessages
|
||
? '이전 대화를 동기화하는 중입니다.'
|
||
: isPullToLoadArmed
|
||
? '손을 놓으면 이전 대화를 더 불러옵니다.'
|
||
: hasOlderMessages
|
||
? '최상단에서 아래로 끌어당겨 이전 대화를 불러오세요.'
|
||
: '이전 대화가 없습니다.'}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
<div className="app-chat-panel__messages-content">
|
||
{!hasConversationMessages ? (
|
||
<div className="app-chat-panel__messages-empty" aria-live="polite">
|
||
<div className="app-chat-panel__messages-empty-card">
|
||
<strong>아직 대화 콘텐츠가 없습니다.</strong>
|
||
<span>첫 요청을 보내면 이 영역이 viewport 높이에 맞춰 바로 채워집니다.</span>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{useSharedRoomsBubbleFlow && roomShareExpandMode === 'latest' && roomShareHiddenBeforeCount > 0 ? (
|
||
<div className="app-chat-panel__messages-omission" aria-label={`이전 채팅 ${roomShareHiddenBeforeCount}건 숨김`}>
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
<span className="app-chat-panel__messages-omission-text">생략 {roomShareHiddenBeforeCount}건</span>
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
</div>
|
||
) : null}
|
||
{useSharedRoomsBubbleFlow && roomShareExpandMode === 'latest' && roomShareCollapsedActivitySummary.length > 0 ? (
|
||
<section className="app-chat-panel__rooms-share-activity" aria-label="현재 진행 상황">
|
||
<strong className="app-chat-panel__rooms-share-activity-title">현재 진행 상황</strong>
|
||
<div className="app-chat-panel__rooms-share-activity-list">
|
||
{roomShareCollapsedActivitySummary.map((item) => (
|
||
<span key={item} className="app-chat-panel__rooms-share-activity-item">{item}</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{useSharedRoomsSimplifiedView
|
||
? roomShareNavigableGroups.map((entry) => {
|
||
const representativeRequest = entry.representativeRequest;
|
||
const visibleMessages = Array.isArray(entry.messages) ? entry.messages.filter((message) => !isActivityLogMessage(message)) : [];
|
||
const collapsedVisibleFinalMessage =
|
||
visibleMessages.length > 0 ? visibleMessages[visibleMessages.length - 1] : null;
|
||
const representativeAttentionState = representativeRequest
|
||
? systemExecutionAttentionStateByRequestId.get(representativeRequest.requestId)
|
||
: undefined;
|
||
const representativePromptTargets = representativeAttentionState?.promptTargets ?? [];
|
||
const isRepresentativePromptManuallyCompleted =
|
||
representativeAttentionState?.isPromptManuallyCompleted === true;
|
||
const representativeHasPendingPromptBadge =
|
||
representativeAttentionState?.hasPendingPromptBadge === true;
|
||
const representativeHasPendingVerificationBadge =
|
||
representativeAttentionState?.hasPendingVerificationBadge === true;
|
||
const representativeManualCompletionTypes = buildManualCompletionTypes({
|
||
hasPendingPromptBadge: representativeHasPendingPromptBadge,
|
||
hasPendingVerificationBadge: representativeHasPendingVerificationBadge,
|
||
});
|
||
const isRepresentativeManualCompletionSaving =
|
||
representativeRequest != null &&
|
||
representativeManualCompletionTypes.some((type) =>
|
||
pendingManualCompletionActionKeySet.has(`${type}:${representativeRequest.requestId}`),
|
||
);
|
||
const canCompletePromptFromCard =
|
||
representativeRequest != null &&
|
||
representativePromptTargets.length > 0 &&
|
||
!isRepresentativePromptManuallyCompleted &&
|
||
!isRequestInFlightStatus(representativeRequest.status);
|
||
const canCompleteVerificationFromCard =
|
||
representativeRequest != null &&
|
||
representativePromptTargets.length === 0 &&
|
||
representativeHasPendingVerificationBadge &&
|
||
!isRequestInFlightStatus(representativeRequest.status);
|
||
const canReplyToCardResponse =
|
||
representativeRequest != null &&
|
||
representativeRequest.hasResponse &&
|
||
representativePromptTargets.length === 0 &&
|
||
!isRequestInFlightStatus(representativeRequest.status);
|
||
const isCardReplyReferenceActive =
|
||
representativeRequest != null &&
|
||
canReplyToCardResponse &&
|
||
replyReferenceRequestId.trim() === representativeRequest.requestId.trim();
|
||
|
||
if (!representativeRequest) {
|
||
return null;
|
||
}
|
||
|
||
if (roomShareVisibleGroupIdSet && !roomShareVisibleGroupIdSet.has(entry.groupId)) {
|
||
return null;
|
||
}
|
||
|
||
if (visibleMessages.length === 0) {
|
||
return (
|
||
<SharedRoomsRequestCard
|
||
key={entry.groupId}
|
||
request={representativeRequest}
|
||
attentionState={representativeAttentionState}
|
||
canCompletePrompt={canCompletePromptFromCard}
|
||
canCompleteVerification={canCompleteVerificationFromCard}
|
||
canReplyToResponse={canReplyToCardResponse}
|
||
isManualCompletionSaving={isRepresentativeManualCompletionSaving}
|
||
isReplyReferenceActive={isCardReplyReferenceActive}
|
||
onCompletePrompt={() => {
|
||
void completeManualBadge({
|
||
requestId: representativeRequest.requestId,
|
||
type: 'prompt',
|
||
});
|
||
}}
|
||
onCompleteVerification={() => {
|
||
void completeManualBadge({
|
||
requestId: representativeRequest.requestId,
|
||
type: 'verification',
|
||
});
|
||
}}
|
||
onReplyToResponse={() => {
|
||
activateReplyReference(representativeRequest.requestId, {
|
||
focusComposer: false,
|
||
});
|
||
}}
|
||
onSelect={() => {
|
||
setSelectedRoomShareGroupId(entry.groupId);
|
||
scrollToRoomShareGroup(entry.groupId);
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Fragment key={entry.groupId}>
|
||
{visibleMessages.map((message) => (
|
||
<Fragment key={message.id}>
|
||
{renderConversationMessage(message, {
|
||
defaultCollapsedPrompt: message.id === collapsedVisibleFinalMessage?.id,
|
||
})}
|
||
</Fragment>
|
||
))}
|
||
</Fragment>
|
||
);
|
||
})
|
||
: messageEntries.map((entry) => {
|
||
if (
|
||
entry.kind === 'group' &&
|
||
roomShareVisibleGroupIdSet &&
|
||
!roomShareVisibleGroupIdSet.has(entry.groupId)
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
if (entry.kind === 'single') {
|
||
return renderConversationMessage(entry.message);
|
||
}
|
||
|
||
const groupedRequests = Array.from(
|
||
new Map(
|
||
entry.requestIds
|
||
.map((requestId) => requestStateMap.get(requestId))
|
||
.filter((item): item is ChatConversationRequest => item != null)
|
||
.map((request) => [request.requestId, request] as const),
|
||
).values(),
|
||
);
|
||
const representativeGroupedRequest =
|
||
entry.request && groupedRequests.some((request) => request.requestId === entry.request?.requestId)
|
||
? entry.request
|
||
: groupedRequests[0] ?? entry.request;
|
||
const collapseSummary = resolveCollapsedGroupSummary(
|
||
entry.request,
|
||
groupedRequests,
|
||
entry.messages,
|
||
systemExecutionAttentionStateByRequestId,
|
||
{
|
||
preferredMessageId: representativeGroupedRequest?.responseMessageId ?? null,
|
||
},
|
||
);
|
||
const isGroupExpanded = expandedGroupIds.includes(entry.groupId);
|
||
const canToggleGroup = collapseSummary.hiddenMessageCount > 0;
|
||
const shouldCollapseGroup = canToggleGroup && collapseSummary.shouldCollapseByDefault && !isGroupExpanded;
|
||
const representativeMessage =
|
||
entry.messages.find((message) => message.author === 'user') ??
|
||
entry.messages.find((message) => !isActivityLogMessage(message)) ??
|
||
entry.messages[0];
|
||
const renderedMessages = shouldCollapseGroup ? collapseSummary.displayedMessages : entry.messages;
|
||
const visibleGroupMessages = renderedMessages.filter((message) => !isActivityLogMessage(message));
|
||
const collapsedVisibleFinalMessage =
|
||
shouldCollapseGroup && visibleGroupMessages.length > 0
|
||
? visibleGroupMessages[visibleGroupMessages.length - 1]
|
||
: null;
|
||
|
||
if (useSharedRoomsBubbleFlow) {
|
||
return (
|
||
<section key={entry.key} className="app-chat-message-group app-chat-message-group--rooms-share">
|
||
<div className="app-chat-message-group__body">
|
||
{visibleGroupMessages.map((message) => (
|
||
<Fragment key={message.id}>
|
||
{renderConversationMessage(message, {
|
||
defaultCollapsedPrompt: message.id === collapsedVisibleFinalMessage?.id,
|
||
})}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
const groupStatusSummary = resolveAggregatedRequestStatusSummary(
|
||
groupedRequests.length > 0 ? groupedRequests : entry.request ? [entry.request] : [],
|
||
systemExecutionAttentionStateByRequestId,
|
||
);
|
||
const rootGroupRequest = representativeGroupedRequest ?? entry.request ?? groupedRequests[0] ?? null;
|
||
const groupActivityOverviewTarget = resolveVisibleActivityOverviewRequests(
|
||
groupedRequests.length > 0 ? groupedRequests : rootGroupRequest ? [rootGroupRequest] : [],
|
||
systemExecutionActivityOverviewByRequestId,
|
||
systemExecutionAttentionStateByRequestId,
|
||
).sort((left, right) => compareRepresentativeRequests(left, right, systemExecutionAttentionStateByRequestId))[0] ?? null;
|
||
const groupTitleSource =
|
||
entry.request?.userText?.trim() ||
|
||
representativeMessage?.text?.trim() ||
|
||
`요청 ${entry.groupId.slice(0, 8)}`;
|
||
const groupTitle = summarizeQueuedText(groupTitleSource, isMobileViewport ? 52 : 88);
|
||
const groupDetailText = getRequestDetailText(entry.request);
|
||
const parentRequestId = entry.request?.parentRequestId?.trim() || '';
|
||
const parentRequest = parentRequestId ? requestStateMap.get(parentRequestId) : null;
|
||
const groupRelationText = parentRequest
|
||
? `부모 요청: ${summarizeQueuedText(parentRequest.userText || parentRequest.responseText || parentRequestId, 52)}`
|
||
: null;
|
||
const childComposerGroupId = entry.groupId;
|
||
const childComposerParentRequestId = resolveChildComposerParentRequestId(entry.request, requestStateMap);
|
||
const childComposerDraft = childComposerDraftsByGroupId[childComposerGroupId] ?? '';
|
||
const isChildComposerOpen = openedChildComposerGroupIds.includes(childComposerGroupId);
|
||
const isChildComposerSubmitting = submittingChildComposerGroupIds.includes(childComposerGroupId);
|
||
const canSubmitChildComposer =
|
||
childComposerParentRequestId.length > 0 && childComposerDraft.trim().length > 0 && !isChildComposerSubmitting;
|
||
const renderedRequestSectionIds = new Set<string>();
|
||
const shouldHideGroupChrome = useSharedRoomsBubbleFlow;
|
||
|
||
if (shouldHideGroupChrome) {
|
||
return (
|
||
<Fragment key={entry.key}>
|
||
{visibleGroupMessages.map((message) => (
|
||
<Fragment key={message.id}>
|
||
{renderConversationMessage(message, {
|
||
defaultCollapsedPrompt: message.id === collapsedVisibleFinalMessage?.id,
|
||
})}
|
||
</Fragment>
|
||
))}
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section key={entry.key} className="app-chat-message-group">
|
||
<header className="app-chat-message-group__header">
|
||
<div className="app-chat-message-group__header-row">
|
||
<div className="app-chat-message-group__header-meta">
|
||
<span className="app-chat-message-group__eyebrow">요청 묶음</span>
|
||
{groupStatusSummary.label ? (
|
||
<span
|
||
className={`app-chat-message-group__status app-chat-message-group__status--${groupStatusSummary.tone}`}
|
||
aria-label={`요청 상태 ${groupStatusSummary.label}`}
|
||
>
|
||
{groupStatusSummary.label}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="app-chat-message-group__header-actions">
|
||
{groupActivityOverviewTarget ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message-group__toggle"
|
||
icon={<ProfileOutlined />}
|
||
aria-label="요청묶음 요약 보기"
|
||
onClick={() => {
|
||
setExpandedSystemExecutionActivityRequestId(groupActivityOverviewTarget.requestId);
|
||
setSystemExecutionDisplayMode('expanded');
|
||
}}
|
||
>
|
||
요약
|
||
</Button>
|
||
) : null}
|
||
{childComposerParentRequestId ? (
|
||
<Button
|
||
type={isChildComposerOpen ? 'default' : 'text'}
|
||
size="small"
|
||
className="app-chat-message-group__child-action"
|
||
icon={<PlusOutlined />}
|
||
aria-label={isChildComposerOpen ? '자식 요청 입력 닫기' : '자식 요청 입력 열기'}
|
||
onClick={() => {
|
||
if (isChildComposerOpen) {
|
||
closeChildComposer(childComposerGroupId);
|
||
return;
|
||
}
|
||
|
||
openChildComposer(childComposerGroupId);
|
||
}}
|
||
>
|
||
자식 요청
|
||
</Button>
|
||
) : null}
|
||
{canToggleGroup ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-message-group__toggle"
|
||
icon={isGroupExpanded ? <UpOutlined /> : <DownOutlined />}
|
||
aria-label={isGroupExpanded ? '중간 대화 접기' : '중간 대화 펼치기'}
|
||
onClick={() => {
|
||
setExpandedGroupIds((current) =>
|
||
current.includes(entry.groupId)
|
||
? current.filter((groupId) => groupId !== entry.groupId)
|
||
: [...current, entry.groupId],
|
||
);
|
||
}}
|
||
>
|
||
{isGroupExpanded ? '요약 보기' : `숨김 대화 ${collapseSummary.hiddenMessageCount}개 펼치기`}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<strong className="app-chat-message-group__title">{groupTitle}</strong>
|
||
{groupRelationText ? <span className="app-chat-message-group__detail">{groupRelationText}</span> : null}
|
||
{groupDetailText ? <span className="app-chat-message-group__detail">{groupDetailText}</span> : null}
|
||
{isChildComposerOpen ? (
|
||
<div className="app-chat-message-group__child-composer">
|
||
<Input.TextArea
|
||
ref={(instance) => {
|
||
setChildComposerRef(childComposerGroupId, instance);
|
||
}}
|
||
value={childComposerDraft}
|
||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||
placeholder="이 요청 자식으로 바로 이어서 처리할 내용을 입력하세요."
|
||
disabled={isChildComposerSubmitting}
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value;
|
||
setChildComposerDraftsByGroupId((current) => ({
|
||
...current,
|
||
[childComposerGroupId]: nextValue,
|
||
}));
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
||
return;
|
||
}
|
||
|
||
const hasSubmitModifier = event.ctrlKey || event.metaKey;
|
||
if (!hasSubmitModifier) {
|
||
event.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
if (!canSubmitChildComposer) {
|
||
return;
|
||
}
|
||
|
||
void submitChildComposer(childComposerGroupId, childComposerParentRequestId);
|
||
}}
|
||
/>
|
||
<div className="app-chat-message-group__child-composer-actions">
|
||
<span className="app-chat-message-group__child-composer-hint">Ctrl+Enter / Cmd+Enter 전송, Enter 줄바꿈</span>
|
||
<div className="app-chat-message-group__child-composer-buttons">
|
||
<Button
|
||
size="small"
|
||
onClick={() => {
|
||
closeChildComposer(childComposerGroupId);
|
||
}}
|
||
disabled={isChildComposerSubmitting}
|
||
>
|
||
닫기
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<ThunderboltOutlined />}
|
||
loading={isChildComposerSubmitting}
|
||
disabled={!canSubmitChildComposer}
|
||
onClick={() => {
|
||
void submitChildComposer(childComposerGroupId, childComposerParentRequestId);
|
||
}}
|
||
>
|
||
즉시 전송
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</header>
|
||
<div className="app-chat-message-group__body">
|
||
{visibleGroupMessages.map((message) => {
|
||
const messageRequestId = message.clientRequestId?.trim() || '';
|
||
|
||
if (messageRequestId) {
|
||
renderedRequestSectionIds.add(messageRequestId);
|
||
}
|
||
|
||
return (
|
||
<Fragment key={message.id}>
|
||
{renderConversationMessage(message, {
|
||
defaultCollapsedPrompt: message.id === collapsedVisibleFinalMessage?.id,
|
||
})}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
{useSharedRoomsBubbleFlow && roomShareExpandMode === 'latest' && roomShareHiddenAfterCount > 0 ? (
|
||
<div className="app-chat-panel__messages-omission" aria-label={`이후 채팅 ${roomShareHiddenAfterCount}건 숨김`}>
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
<span className="app-chat-panel__messages-omission-text">생략 {roomShareHiddenAfterCount}건</span>
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
</div>
|
||
) : null}
|
||
{useSharedRoomsBubbleFlow && roomShareExpandMode === 'pending' && roomShareNavigableGroups.length === 0 ? (
|
||
<div className="app-chat-panel__messages-omission app-chat-panel__messages-omission--empty">
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
<span className="app-chat-panel__messages-omission-text">현재 처리중이거나 확인이 필요한 건이 없습니다.</span>
|
||
<span className="app-chat-panel__messages-omission-line" aria-hidden="true" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{!shouldHideSystemStatusPanel && !useSharedRoomsSimplifiedView && visibleSystemExecutionRequests.length > 0 ? systemExecutionDisplayMode === 'hidden' ? (
|
||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||
<div
|
||
className={`app-chat-panel__system-status app-chat-panel__system-status--hidden-summary${
|
||
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||
}`}
|
||
>
|
||
<span className="app-chat-panel__system-status-label">시스템</span>
|
||
{hiddenSystemStatusSummaryText ? (
|
||
<span className="app-chat-panel__system-status-summary-inline">{hiddenSystemStatusSummaryText}</span>
|
||
) : null}
|
||
{isSystemStatusPending ? (
|
||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
</div>
|
||
) : null}
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<DownOutlined />}
|
||
aria-label="실행 기록 펼치기"
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode('expanded');
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||
<section
|
||
className={`app-chat-panel__system-status app-chat-panel__system-status--records${
|
||
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||
}`}
|
||
>
|
||
<div className="app-chat-panel__system-status-records-header">
|
||
<div className="app-chat-panel__system-status-records-heading">
|
||
<span className="app-chat-panel__system-status-label">시스템</span>
|
||
{systemExecutionMeta ? (
|
||
<span className="app-chat-panel__system-status-records-meta">{systemExecutionMeta}</span>
|
||
) : null}
|
||
{isSystemStatusPending ? (
|
||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="app-chat-panel__system-status-records-actions">
|
||
{hasExpandedSystemExecutionActivityContent ? (
|
||
<>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<ArrowLeftOutlined />}
|
||
aria-label="돌아가기"
|
||
onClick={() => {
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
}}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<CloseOutlined />}
|
||
aria-label="시스템 숨기기"
|
||
onClick={() => {
|
||
setExpandedSystemExecutionActivityRequestId(null);
|
||
setSystemExecutionDisplayMode('hidden');
|
||
}}
|
||
/>
|
||
</>
|
||
) : (
|
||
<>
|
||
{!showRoomsShareHeader ? (
|
||
<>
|
||
<Select
|
||
size="small"
|
||
variant="borderless"
|
||
popupMatchSelectWidth={false}
|
||
className={`app-chat-panel__system-status-records-sort-select${
|
||
useCompactSystemExecutionControls ? ' app-chat-panel__system-status-records-sort-select--compact' : ''
|
||
}`}
|
||
value={systemExecutionSort}
|
||
options={systemExecutionSortOptions}
|
||
aria-label="실행 기록 정렬 기준"
|
||
onChange={(value) => {
|
||
setSystemExecutionSort(value);
|
||
}}
|
||
/>
|
||
{useIconSystemExecutionFilterToggle ? (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-filter-cycle"
|
||
icon={renderSystemExecutionFilterIcon(currentSystemExecutionFilterOption.value)}
|
||
aria-label={`${currentSystemExecutionFilterOption.ariaLabel}. 누르면 ${mobileSystemExecutionNextFilterOption.label} 필터로 전환합니다.`}
|
||
title={`${currentSystemExecutionFilterOption.label} 필터 적용 중`}
|
||
onClick={() => {
|
||
setSystemExecutionFilter(mobileSystemExecutionNextFilterOption.value);
|
||
}}
|
||
>
|
||
{currentSystemExecutionFilterOption.compactLabel}
|
||
</Button>
|
||
) : useCompactSystemExecutionControls ? (
|
||
<Segmented
|
||
size="small"
|
||
className="app-chat-panel__system-status-filter-segmented"
|
||
aria-label="실행 기록 필터"
|
||
options={systemExecutionFilterOptions.map((option) => ({
|
||
label: option.compactLabel,
|
||
title: option.ariaLabel,
|
||
value: option.value,
|
||
}))}
|
||
value={systemExecutionFilter}
|
||
onChange={(value) => {
|
||
setSystemExecutionFilter(value as SystemExecutionFilter);
|
||
}}
|
||
/>
|
||
) : (
|
||
<div className="app-chat-panel__system-status-filter-group" role="tablist" aria-label="실행 기록 필터">
|
||
{systemExecutionFilterOptions.map((option) => (
|
||
<Button
|
||
key={option.value}
|
||
type="text"
|
||
size="small"
|
||
className={`app-chat-panel__system-status-filter-toggle${
|
||
systemExecutionFilter === option.value
|
||
? ' app-chat-panel__system-status-filter-toggle--active'
|
||
: ''
|
||
}`}
|
||
aria-label={option.ariaLabel}
|
||
aria-pressed={systemExecutionFilter === option.value}
|
||
onClick={() => {
|
||
setSystemExecutionFilter(option.value);
|
||
}}
|
||
>
|
||
{option.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
) : null}
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={systemExecutionDisplayMode === 'expanded' ? <UpOutlined /> : <DownOutlined />}
|
||
aria-label={systemExecutionDisplayMode === 'expanded' ? '실행 기록 접기' : '실행 기록 펼치기'}
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode((current) => (current === 'expanded' ? 'collapsed' : 'expanded'));
|
||
}}
|
||
/>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<CloseOutlined />}
|
||
aria-label="시스템 숨기기"
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode('hidden');
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{systemExecutionDisplayMode === 'expanded' ? (
|
||
<div
|
||
ref={systemExecutionBodyRef}
|
||
className={`app-chat-panel__system-status-records-body${
|
||
hasExpandedSystemExecutionActivityContent
|
||
? ' app-chat-panel__system-status-records-body--activity-focus'
|
||
: ''
|
||
}`}
|
||
onScroll={handleSystemExecutionBodyScroll}
|
||
>
|
||
{hasExpandedSystemExecutionActivityContent ? (
|
||
<div className="app-chat-panel__system-execution-record-activity app-chat-panel__system-execution-record-activity--full">
|
||
{expandedSystemExecutionActivityPanels.map((panel, index) => (
|
||
<Fragment key={`${panel.request.requestId}:${index}`}>
|
||
{panel.summaryText ? (
|
||
<div className="app-chat-panel__system-execution-record-activity-focus">
|
||
{panel.summaryText}
|
||
</div>
|
||
) : null}
|
||
{renderActivityOverviewBody(panel.overview, -1 - index, panel.request, {
|
||
attentionState: panel.attentionState ?? undefined,
|
||
})}
|
||
</Fragment>
|
||
))}
|
||
</div>
|
||
) : canLoadOlderSystemExecutionRequests && (hasOlderMessages || isLoadingOlderMessages) ? (
|
||
<div className="app-chat-panel__system-status-history-loader">
|
||
<Spin size="small" spinning={isLoadingOlderMessages} />
|
||
<span>
|
||
{isLoadingOlderMessages
|
||
? '오래된 실행 내역을 가져오는 중입니다.'
|
||
: '상단까지 올리면 오래된 실행 내역을 더 가져옵니다.'}
|
||
</span>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-history-loader-button"
|
||
disabled={!hasOlderMessages || isLoadingOlderMessages}
|
||
onClick={() => {
|
||
triggerOlderSystemExecutionLoad();
|
||
}}
|
||
>
|
||
더 가져오기
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{!hasExpandedSystemExecutionActivityContent
|
||
? useFlatSystemExecutionFilterView
|
||
? orderedFilteredSystemExecutionRequests.map((request) =>
|
||
renderSystemExecutionRequestSummary(request, { depth: 0 }),
|
||
)
|
||
: orderedSystemExecutionRootRequests.flatMap((request) =>
|
||
renderSystemExecutionRootEntries(request, { filterToVisibleSet: true }),
|
||
)
|
||
: null}
|
||
</div>
|
||
) : collapsedVisibleSystemExecutionRequest ? (
|
||
<div className="app-chat-panel__system-status-records-body">
|
||
{useFlatSystemExecutionFilterView
|
||
? renderSystemExecutionRequestSummary(collapsedVisibleSystemExecutionRequest, {
|
||
isCollapsed: true,
|
||
depth: 0,
|
||
})
|
||
: renderSystemExecutionRootEntries(collapsedVisibleSystemExecutionRequest, {
|
||
isCollapsed: true,
|
||
filterToVisibleSet: true,
|
||
})}
|
||
</div>
|
||
) : filteredSystemExecutionRequests.length === 0 ? (
|
||
<div className="app-chat-panel__system-status-records-empty">
|
||
<FilterOutlined aria-hidden="true" />
|
||
<span>표시할 실행 기록이 없습니다.</span>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
</div>
|
||
) : !shouldHideSystemStatusPanel && !useSharedRoomsSimplifiedView && activeSystemStatusText ? systemExecutionDisplayMode === 'hidden' ? (
|
||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||
<div
|
||
className={`app-chat-panel__system-status app-chat-panel__system-status--hidden-summary${
|
||
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||
}`}
|
||
>
|
||
<span className="app-chat-panel__system-status-label">시스템</span>
|
||
<span className="app-chat-panel__system-status-summary-inline">{hiddenSystemStatusSummaryText}</span>
|
||
{isSystemStatusPending ? (
|
||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
</div>
|
||
) : null}
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<DownOutlined />}
|
||
aria-label="시스템 보기"
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode('collapsed');
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
|
||
<div
|
||
className={`app-chat-panel__system-status${
|
||
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
|
||
}`}
|
||
>
|
||
<span className="app-chat-panel__system-status-label">시스템</span>
|
||
<span className="app-chat-panel__system-status-summary-inline">{activeSystemStatusText}</span>
|
||
{isSystemStatusPending ? (
|
||
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
<span className="app-chat-panel__system-status-dot" />
|
||
</div>
|
||
) : null}
|
||
<div className="app-chat-panel__system-status-records-actions">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__system-status-records-toggle app-chat-panel__system-status-records-toggle--icon-only"
|
||
icon={<CloseOutlined />}
|
||
aria-label="시스템 숨기기"
|
||
onClick={() => {
|
||
setSystemExecutionDisplayMode('hidden');
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{showScrollToBottom ? (
|
||
<div className="app-chat-panel__scroll-jump">
|
||
<Button type="primary" shape="circle" icon={<DownOutlined />} aria-label="최하단으로 이동" onClick={onScrollToBottom} />
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="app-chat-panel__composer">
|
||
{useSharedComposerChrome ? (
|
||
<div className="app-chat-panel__composer-topline app-chat-panel__composer-topline--shared">
|
||
<div className="app-chat-panel__composer-utility-buttons">
|
||
<Button
|
||
icon={<PlusOutlined />}
|
||
aria-label="파일 첨부"
|
||
onClick={() => {
|
||
fileInputRef.current?.click();
|
||
}}
|
||
disabled={isComposerDisabled}
|
||
loading={isComposerAttachmentUploading}
|
||
/>
|
||
</div>
|
||
<div className="app-chat-panel__composer-type app-chat-panel__composer-type--readonly">
|
||
<Select
|
||
value={selectedChatTypeOption?.value ?? '__shared-readonly__'}
|
||
options={[
|
||
{
|
||
value: selectedChatTypeOption?.value ?? '__shared-readonly__',
|
||
label: (
|
||
<div className="app-chat-panel__type-option">
|
||
<span>{sharedComposerChatTypeLabel}</span>
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||
disabled
|
||
/>
|
||
</div>
|
||
<div className="app-chat-panel__composer-type">
|
||
<Select
|
||
value={selectedCodexModel}
|
||
placeholder="Codex 모델 선택"
|
||
options={codexModelOptions.map((option) => ({
|
||
value: option.value,
|
||
label: (
|
||
<div className="app-chat-panel__type-option">
|
||
<span>{option.label}</span>
|
||
</div>
|
||
),
|
||
}))}
|
||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||
disabled={isComposerDisabled || codexModelOptions.length === 0}
|
||
onChange={onSelectCodexModel}
|
||
/>
|
||
</div>
|
||
<div className="app-chat-panel__composer-model-token" title={sharedComposerModelTokenLimitLabel}>
|
||
<span>{sharedComposerModelTokenLimitLabel}</span>
|
||
</div>
|
||
<div className="app-chat-panel__composer-actions app-chat-panel__composer-actions--shared">
|
||
<div className="app-chat-panel__composer-action-buttons">
|
||
<Button
|
||
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
|
||
className={`app-chat-panel__composer-contextless-toggle${
|
||
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
|
||
}${useSharedComposerIconActions ? ' app-chat-panel__composer-toggle--icon-only' : ''}`}
|
||
icon={<DisconnectOutlined />}
|
||
title={
|
||
isSendWithoutContextEnabled
|
||
? '다음 1회만 문맥 없이 보냄'
|
||
: '다음 전송을 문맥 없이 보내기'
|
||
}
|
||
aria-label={
|
||
isSendWithoutContextEnabled
|
||
? '다음 1회만 문맥 없이 보냄'
|
||
: '다음 전송을 문맥 없이 보내기'
|
||
}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={onToggleSendWithoutContext}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
<Button
|
||
icon={<ThunderboltOutlined />}
|
||
type={isImmediateSendPinned ? 'primary' : 'default'}
|
||
className={`app-chat-panel__composer-immediate-toggle${
|
||
isImmediateSendPinned ? ' app-chat-panel__composer-immediate-toggle--active' : ''
|
||
}${useSharedComposerIconActions ? ' app-chat-panel__composer-toggle--icon-only' : ''}`}
|
||
title={isImmediateSendPinned ? '즉시 전송 자동 실행 켜짐' : '즉시 요청'}
|
||
aria-label={isImmediateSendPinned ? '즉시 전송 자동 실행 켜짐' : '즉시 요청'}
|
||
onPointerDown={startImmediateSendHoldTimer}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onPointerUp={clearImmediateSendHoldTimer}
|
||
onPointerLeave={clearImmediateSendHoldTimer}
|
||
onPointerCancel={clearImmediateSendHoldTimer}
|
||
onClick={handleImmediateSendButtonClick}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
aria-label="답변 전송"
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
handleComposerSendRequest();
|
||
}}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="app-chat-panel__composer-topline">
|
||
<div className="app-chat-panel__composer-utility-buttons">
|
||
<Button
|
||
icon={<PlusOutlined />}
|
||
aria-label="파일 첨부"
|
||
onClick={() => {
|
||
fileInputRef.current?.click();
|
||
}}
|
||
disabled={isComposerDisabled}
|
||
loading={isComposerAttachmentUploading}
|
||
/>
|
||
</div>
|
||
<div className="app-chat-panel__composer-type">
|
||
<Select
|
||
value={selectedChatTypeId ?? undefined}
|
||
placeholder="컨텍스트를 선택하세요."
|
||
options={chatTypeOptions.map((option) => ({
|
||
value: option.value,
|
||
disabled: option.disabled,
|
||
label: (
|
||
<div className="app-chat-panel__type-option">
|
||
<span>{option.label}</span>
|
||
</div>
|
||
),
|
||
}))}
|
||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
|
||
onChange={onSelectChatType}
|
||
/>
|
||
</div>
|
||
{codexModelOptions.length > 1 ? (
|
||
<div className="app-chat-panel__composer-type">
|
||
<Select
|
||
value={selectedCodexModel}
|
||
placeholder="Codex 모델 선택"
|
||
options={codexModelOptions.map((option) => ({
|
||
value: option.value,
|
||
label: (
|
||
<div className="app-chat-panel__type-option">
|
||
<span>{option.label}</span>
|
||
</div>
|
||
),
|
||
}))}
|
||
getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body}
|
||
disabled={codexModelOptions.length === 0}
|
||
onChange={onSelectCodexModel}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
<div className="app-chat-panel__composer-actions">
|
||
<div className="app-chat-panel__composer-action-buttons">
|
||
<Button
|
||
type={isSendWithoutContextEnabled ? 'primary' : 'default'}
|
||
className={`app-chat-panel__composer-contextless-toggle${
|
||
isSendWithoutContextEnabled ? ' app-chat-panel__composer-contextless-toggle--active' : ''
|
||
} app-chat-panel__composer-toggle--icon-only`}
|
||
icon={<DisconnectOutlined />}
|
||
title={
|
||
isSendWithoutContextEnabled
|
||
? '다음 1회만 문맥 없이 보냄'
|
||
: '다음 전송을 문맥 없이 보내기'
|
||
}
|
||
aria-label={
|
||
isSendWithoutContextEnabled
|
||
? '다음 1회만 문맥 없이 보냄'
|
||
: '다음 전송을 문맥 없이 보내기'
|
||
}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={onToggleSendWithoutContext}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
<Button
|
||
icon={<ThunderboltOutlined />}
|
||
type={isImmediateSendPinned ? 'primary' : 'default'}
|
||
className={`app-chat-panel__composer-immediate-toggle${
|
||
isImmediateSendPinned ? ' app-chat-panel__composer-immediate-toggle--active' : ''
|
||
} app-chat-panel__composer-toggle--icon-only`}
|
||
title={isImmediateSendPinned ? '즉시 전송 자동 실행 켜짐' : '즉시 요청'}
|
||
aria-label={isImmediateSendPinned ? '즉시 전송 자동 실행 켜짐' : '즉시 요청'}
|
||
onPointerDown={startImmediateSendHoldTimer}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onPointerUp={clearImmediateSendHoldTimer}
|
||
onPointerLeave={clearImmediateSendHoldTimer}
|
||
onPointerCancel={clearImmediateSendHoldTimer}
|
||
onClick={handleImmediateSendButtonClick}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
icon={<SendOutlined />}
|
||
title="큐로 보내기"
|
||
aria-label="큐로 보내기"
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
}}
|
||
onClick={() => {
|
||
handleComposerSendRequest();
|
||
}}
|
||
disabled={isComposerDisabled || isComposerAttachmentUploading}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{chatTypeOptions.length === 0 ? (
|
||
<Alert
|
||
showIcon
|
||
type="warning"
|
||
message="사용 가능한 컨텍스트가 없습니다."
|
||
description="관리 페이지에서 현재 사용자 권한에 맞는 컨텍스트를 등록하거나 권한을 부여하세요."
|
||
/>
|
||
) : null}
|
||
|
||
{composerAttachmentStrip}
|
||
|
||
{replyReferenceRequest ? (
|
||
<div className="app-chat-panel__reply-reference" aria-live="polite">
|
||
<div className="app-chat-panel__reply-reference-copy">
|
||
<span className="app-chat-panel__reply-reference-label">답변 참조 중</span>
|
||
<span className="app-chat-panel__reply-reference-text">{replyReferenceSummary}</span>
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
className="app-chat-panel__reply-reference-clear"
|
||
onClick={() => {
|
||
setReplyReferenceRequestId('');
|
||
}}
|
||
>
|
||
해제
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
<ChatComposerInput
|
||
composerRef={composerRef}
|
||
draft={draft}
|
||
draftVersion={draftVersion}
|
||
forceDraftSyncVersion={composerForceDraftSyncVersion}
|
||
composerPlaceholder={composerPlaceholder}
|
||
enableAutoSize={enableSharedComposerAutoSize}
|
||
isComposerDisabled={isComposerDisabled}
|
||
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
||
queuedRequests={queuedRequests}
|
||
composerAssistActions={composerAssistActions}
|
||
composerAssistModalTitle={composerAssistModalTitle}
|
||
hideAssistTrigger={useSharedComposerChrome}
|
||
hideClearButton={useSharedComposerChrome}
|
||
onPromoteQueuedRequest={onPromoteQueuedRequest}
|
||
onRemoveQueuedRequest={onRemoveQueuedRequest}
|
||
onPaste={handleComposerPaste}
|
||
onCommitDraft={syncComposerDraft}
|
||
onLocalDraftChange={setComposerDraftValue}
|
||
onSend={handleComposerSendRequest}
|
||
onClear={handleComposerClear}
|
||
/>
|
||
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
|
||
className="app-chat-panel__composer-file-input"
|
||
onChange={handleComposerFileChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|