Files
ai-code-app/src/app/main/mainChatPanel/ChatConversationView.tsx

7899 lines
282 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { ChatActivityChecklist, buildChatActivityChecklistEntries } from './ChatActivityChecklist';
import { describeExecutorCommand } from './executorActivitySummary';
import { buildComposerFilePickKey } from './composerFilePickKey';
import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from './ChatPromptCard';
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 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' }>[];
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 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 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 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,
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;
if (hasAnsweredRequest(request)) {
if (request?.status === "completed") {
if (hideFinalizedLabel) {
return null;
}
return attentionState?.hasPendingPromptBadge || attentionState?.hasPendingVerificationBadge ? "확인대기" : "완료";
}
return hideFinalizedLabel ? null : "답변도착";
}
switch (request?.status) {
case 'accepted':
return '접수됨';
case 'queued':
return '대기중';
case 'started':
return request.hasResponse ? '응답작성중' : '처리중';
case 'completed':
return '완료';
case 'failed':
return '실패';
case 'cancelled':
return '취소됨';
case 'removed':
return '삭제됨';
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 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;
}): 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;
}): SystemExecutionBadge | null {
const {
request,
activityLines,
promptTargets,
hasVerificationTarget,
hasConfirmedVerificationTarget,
isManuallyCompleted,
} = options;
if (!request || promptTargets.length > 0 || !hasVerificationTarget) {
return null;
}
const verificationText = [request.userText, request.responseText, ...activityLines].join('\n');
const usesVerificationLabel = VERIFICATION_REQUEST_PATTERN.test(verificationText);
return isManuallyCompleted
? {
label: usesVerificationLabel ? '검증 확인' : '응답 확인',
shortLabel: '확인',
tone: 'completed',
}
: {
label: usesVerificationLabel ? '검증 미확인' : '응답 미확인',
shortLabel: '미확인',
tone: 'attention',
};
}
function hasPendingVerificationState(options: {
request: ChatConversationRequest | undefined;
activityLines: string[];
promptTargets: Extract<ChatMessagePart, { type: 'prompt' }>[];
hasVerificationTarget: boolean;
hasConfirmedVerificationTarget: boolean;
isManuallyCompleted?: 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[];
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,
onSelect,
}: {
request: ChatConversationRequest;
onSelect?: (() => void) | null;
}) {
const questionText = (request.userText ?? "").trim() || "-";
const answerText = (request.responseText ?? "").trim() || request.statusMessage?.trim() || "아직 답변이 없습니다.";
const requestStatusLabel = formatRequestStatusLabel(request);
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>
</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,
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>(
useRoomsShareBubbleFlow ? 'latest' : '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(useRoomsShareBubbleFlow ? 'latest' : '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 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) =>
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) => {
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,
});
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,
localSubmittedPromptCountByRequestId,
promptFollowupCountByParentRequestId,
promptTargetsByRequestId,
requestIdsWithPreviewArtifacts,
visibleSystemExecutionRequests,
]);
const filteredSystemExecutionRequests = useMemo(
() => {
if (systemExecutionFilter === 'all') {
return visibleSystemExecutionRequests;
}
if (systemExecutionFilter === 'active') {
return visibleSystemExecutionRequests.filter(
(request) => request.status === 'accepted' || request.status === 'queued' || request.status === 'started',
);
}
if (systemExecutionFilter === 'active-attention') {
return visibleSystemExecutionRequests.filter((request) => {
if (request.status === 'accepted' || request.status === 'queued' || request.status === 'started') {
return true;
}
return systemExecutionAttentionStateByRequestId.get(request.requestId)?.hasOwnAttentionState === true;
});
}
return visibleSystemExecutionRequests.filter(
(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,
};
});
}
return messageEntries
.filter((entry): entry is Extract<ConversationMessageEntry, { kind: 'group' }> => entry.kind === 'group')
.map((entry) => {
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 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,
};
})
.filter((entry) => entry.representativeRequest != null);
},
[messageEntries, orderedMessages, 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),
);
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,
});
const hasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
const manualCompletionTypes = buildManualCompletionTypes({
hasPendingPromptBadge,
hasPendingVerificationBadge,
});
const checklistBadge = buildChecklistStageBadge(activityLines, representativeRequest);
const readStateBadge =
verificationStateBadge ? null : buildReadStateBadge(representativeRequest, lastReadResponseMessageId);
const secondaryBadges = [
hierarchyBadge,
rootRelationshipBadge,
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 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, 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 ||
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,
});
const responseHasPendingVerificationBadge = attentionState?.hasPendingVerificationBadge === true;
const responseSecondaryBadges = [responsePromptStateBadge, responseVerificationStateBadge].filter(
(badge): badge is SystemExecutionBadge => badge != null,
);
const responsePrimaryManualCompletionType = resolvePrimaryManualCompletionType({
secondaryBadges: responseSecondaryBadges,
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 hasChildRequest =
Boolean(message.clientRequestId) &&
(childRequestIdsByParentRequestId.get(message.clientRequestId ?? '')?.length ?? 0) > 0;
const showResponseManualCompleteAction =
enableExecutionReviewUi &&
message.author === 'codex' &&
Boolean(message.clientRequestId) &&
Boolean(responsePrimaryManualCompletionType);
const canCompletePromptFromResponse =
showRoomsShareHeader &&
message.author === 'codex' &&
Boolean(message.clientRequestId) &&
responsePromptTargets.length > 0 &&
!isResponsePromptManuallyCompleted;
const canCompleteVerificationFromResponse =
showRoomsShareHeader &&
message.author === 'codex' &&
Boolean(message.clientRequestId) &&
responsePromptTargets.length === 0 &&
responsePrimaryManualCompletionType === 'verification' &&
!hasChildRequest;
const canReplyToResponse =
showRoomsShareHeader &&
message.author === 'codex' &&
Boolean(message.clientRequestId) &&
responsePromptTargets.length === 0;
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={() => {
setReplyReferenceRequestId(message.clientRequestId?.trim() ?? '');
composerRef.current?.focus({ cursor: 'end' });
}}
>
{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);
}}
/>
))}
{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;
if (!representativeRequest) {
return null;
}
if (roomShareVisibleGroupIdSet && !roomShareVisibleGroupIdSet.has(entry.groupId)) {
return null;
}
if (visibleMessages.length === 0) {
return (
<SharedRoomsRequestCard
key={entry.groupId}
request={representativeRequest}
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
? `${
entry.request?.requestOrigin === 'prompt' ? '상위 질의' : '상위 요청 연결'
}: ${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-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>
);
}