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

2191 lines
74 KiB
TypeScript
Executable File

import {
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DisconnectOutlined,
DownloadOutlined,
DownOutlined,
ExclamationCircleOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
RedoOutlined,
SendOutlined,
SyncOutlined,
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
startTransition,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type ReactNode,
type RefObject,
type TouchEvent,
} from 'react';
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import {
ChatPreviewBody,
resolveChatPreviewGlyph,
resolveChatPreviewKindLabel,
type ChatPreviewKind,
} from './ChatPreviewBody';
import { triggerResourceDownload } from './downloadUtils';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText, isExecutionFailureMessage, isMissingRequestMessage, isPreparingChatReplyText } from './chatUtils';
import { ChatLinkCardPreview } from './ChatLinkCardPreview';
import { openChatExternalLink } from './linkNavigation';
import { ChatRankedLinkPreview, type RankedLinkPreviewTarget } from './ChatRankedLinkPreview';
import { extractChatMessageParts } from './messageParts';
import type { ChatComposerAttachment, ChatConversationRequest, 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 PreviewOption = {
id: string;
label: string;
url: string;
kind: string;
};
type QueuedRequestOption = {
requestId: string;
order: number;
text: string;
};
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 = {
key: string;
name: string;
status: 'uploading' | 'uploaded' | 'failed';
reason?: string;
};
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 COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
type MessageRenderPayload = {
previewSourceText: string;
visibleText: string;
diffBlocks: string[];
rankedLinkTargets: RankedLinkPreviewTarget[];
linkCardTargets: Extract<ChatMessagePart, { type: 'link_card' }>[];
};
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 classifyInlinePreviewKind(url: string): InlinePreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
return 'file';
}
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 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 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 buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
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+)?\.(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 matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
for (const matchedUrl of matches) {
const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl);
const kind = classifyInlinePreviewKind(normalizedUrl);
if (kind === 'file') {
continue;
}
if (seen.has(normalizedUrl)) {
continue;
}
seen.add(normalizedUrl);
targets.push({
url: normalizedUrl,
label: buildInlinePreviewLabel(normalizedUrl),
kind,
});
}
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>
);
});
}
function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload {
const structuredParts = Array.isArray(message.parts) ? message.parts : [];
const extractedMessageParts = extractChatMessageParts(message.text);
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 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 = stripHiddenPreviewTags(previewSourceText);
return {
previewSourceText,
visibleText,
diffBlocks,
rankedLinkTargets,
linkCardTargets,
};
}
function summarizeQueuedText(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
}
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 extractActivityLines(message: ChatMessage) {
return message.text
.slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length)
.split('\n\n')
.map((line) => line.trim())
.filter(Boolean);
}
function summarizeActivityLines(lines: string[]) {
for (let index = lines.length - 1; index >= 0; index -= 1) {
const summary = lines[index]
.split('\n')
.map((line) => line.trim())
.find((line) => line.startsWith('# 이유:') || line.startsWith('# 진행:') || line.startsWith('# 상태:'));
if (!summary) {
continue;
}
return summary.replace(/^#\s*(||):\s*/u, '').trim();
}
return lines.at(-1) ?? '';
}
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 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 formatRequestStatusLabel(request: ChatConversationRequest | undefined) {
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 isTerminalRequestStatus(status: ChatConversationRequest['status'] | undefined) {
return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed';
}
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 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('');
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(target.url, {
cache: 'no-store',
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, target.kind, target.url]);
const handleCopyPreview = () => {
void copyPreviewContent({
kind: target.kind,
url: target.url,
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 내용을 복사하지 못했습니다.');
});
};
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={<DownloadOutlined />}
aria-label="preview 다운로드"
onClick={() => {
void triggerResourceDownload(target.url, 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를 복사하지 못했습니다.');
});
};
return (
<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}
expandAll={isFullscreen}
summary={`파일 ${fileCount}개 diff preview`}
/>
</div>
</div>
) : null}
</section>
);
}
type ChatConversationViewProps = {
viewportRef: RefObject<HTMLDivElement | null>;
composerRef: RefObject<TextAreaRef | null>;
visibleMessages: ChatMessage[];
activeSystemStatus: string | null;
isSystemStatusPending: boolean;
showScrollToBottom: boolean;
copiedMessageId: number | null;
draft: string;
composerAttachments: ChatComposerAttachment[];
requestStateMap: Map<string, ChatConversationRequest>;
isConversationLoading: boolean;
conversationLoadingLabel: string;
hasOlderMessages: boolean;
isLoadingOlderMessages: boolean;
isPullToLoadArmed: boolean;
pullToLoadDistance: number;
selectedChatTypeId: string | null;
queuedRequests: QueuedRequestOption[];
chatTypeOptions: ChatTypeOption[];
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
isComposerDisabled: boolean;
isMobileViewport: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
isSendWithoutContextEnabled: 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;
onSend: (draftText?: string) => void;
onSendImmediate: (draftText?: string) => void;
onToggleSendWithoutContext: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
onToggleResourceStrip: () => void;
onOpenPreview: (preview: OpenPreviewTarget, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void;
onDeleteRequest: (message: ChatMessage) => void;
onRemoveQueuedRequest: (requestId: string) => void;
};
export function ChatConversationView({
viewportRef,
composerRef,
visibleMessages,
activeSystemStatus,
isSystemStatusPending,
showScrollToBottom,
copiedMessageId,
draft,
composerAttachments,
requestStateMap,
isConversationLoading,
conversationLoadingLabel,
hasOlderMessages,
isLoadingOlderMessages,
isPullToLoadArmed,
pullToLoadDistance,
selectedChatTypeId,
queuedRequests,
chatTypeOptions,
previewItems,
isResourceStripOpen,
isComposerDisabled,
isMobileViewport,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
isSendWithoutContextEnabled,
onViewportScroll,
onViewportTouchEnd,
onViewportTouchMove,
onViewportTouchStart,
onDraftChange,
onPickComposerFiles,
onRemoveComposerAttachment,
onSelectChatType,
onSend,
onSendImmediate,
onToggleSendWithoutContext,
onClearDraft,
onScrollToBottom,
onToggleResourceStrip,
onOpenPreview,
onCopyMessage,
onRetryMessage,
onCancelMessage,
onDeleteRequest,
onRemoveQueuedRequest,
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
const [composerDraft, setComposerDraft] = useState(draft);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
const autoCollapsedActivityRequestIdsRef = useRef(new Set<string>());
const lastReportedDraftRef = useRef(draft);
useEffect(() => {
if (draft === lastReportedDraftRef.current) {
return;
}
setComposerDraft(draft);
}, [draft]);
useEffect(() => {
if (composerDraft === lastReportedDraftRef.current) {
return;
}
const timeoutId = window.setTimeout(() => {
lastReportedDraftRef.current = composerDraft;
startTransition(() => {
onDraftChange(composerDraft);
});
}, 120);
return () => {
window.clearTimeout(timeoutId);
};
}, [composerDraft, onDraftChange]);
const orderedMessages = useMemo(() => {
const shouldDisplayActivityMessage = (activityMessage: ChatMessage) => {
const requestId = activityMessage.clientRequestId?.trim();
if (!requestId) {
return true;
}
const requestState = requestStateMap.get(requestId);
const hasCodexResponse = visibleMessages.some(
(candidate) =>
candidate.clientRequestId?.trim() === requestId &&
candidate.author === 'codex' &&
candidate.text.trim().length > 0 &&
!isPreparingChatReplyText(candidate.text),
);
return !isTerminalRequestStatus(requestState?.status) || !hasCodexResponse;
};
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;
}
if (shouldDisplayActivityMessage(message)) {
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]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const isChatTypeReadonly = isChatTypeSelectionLocked;
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 (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 setActivitySectionRef = (requestId: string, element: HTMLElement | null) => {
if (element) {
activitySectionRefs.current.set(requestId, element);
return;
}
activitySectionRefs.current.delete(requestId);
};
const setMessageBodyRef = (messageId: number, element: HTMLDivElement | null) => {
if (element) {
messageBodyRefs.current.set(messageId, element);
return;
}
messageBodyRefs.current.delete(messageId);
};
const scrollActivitySectionIntoView = (requestId: string) => {
const section = activitySectionRefs.current.get(requestId);
if (!section || typeof window === 'undefined') {
return;
}
window.requestAnimationFrame(() => {
section.scrollIntoView({
block: 'nearest',
behavior: 'smooth',
});
});
};
useEffect(() => {
const requestIdsToCollapse = new Set<string>();
orderedMessages.forEach((message) => {
if (!isActivityLogMessage(message)) {
return;
}
const requestId = message.clientRequestId?.trim();
if (!requestId || autoCollapsedActivityRequestIdsRef.current.has(requestId)) {
return;
}
const requestState = requestStateMap.get(requestId);
const hasCodexResponse = orderedMessages.some(
(candidate) => candidate.clientRequestId === requestId && candidate.author === 'codex' && candidate.text.trim().length > 0,
);
if (isTerminalRequestStatus(requestState?.status) || hasCodexResponse) {
requestIdsToCollapse.add(requestId);
}
});
if (requestIdsToCollapse.size === 0) {
return;
}
requestIdsToCollapse.forEach((requestId) => {
autoCollapsedActivityRequestIdsRef.current.add(requestId);
});
setCollapsedActivityRequestIds((current) => {
const next = new Set(current);
requestIdsToCollapse.forEach((requestId) => {
next.add(requestId);
});
return Array.from(next);
});
}, [orderedMessages, requestStateMap]);
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(() => {
if (pendingComposerUploads.length === 0) {
return;
}
const uploadedAttachmentNames = new Set(
composerAttachments.map((attachment) => normalizeAttachmentName(attachment.name)).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter((item) => {
const normalizedName = normalizeAttachmentName(item.name);
if (!normalizedName || !uploadedAttachmentNames.has(normalizedName)) {
return false;
}
return item.status === 'uploaded' || item.status === 'failed';
});
if (resolvedUploads.length > 0) {
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
}
}, [composerAttachments, pendingComposerUploads]);
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const syncPendingComposerUploads = async (files: File[]) => {
const nextPendingNames = new Set(files.map((file) => normalizeAttachmentName(file.name)).filter(Boolean));
const nextPendingUploads = files.map((file) => ({
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 (!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 [{ ...item, status: 'uploaded', reason: undefined }];
}),
);
};
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 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 ?? '업로드 실패'
: upload.status === 'uploaded'
? '첨부 반영 중'
: '업로드 중'}
</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 composerPlaceholder = isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: isMobileViewport
? '메시지를 입력하세요.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
const lines = extractActivityLines(message);
const liveStatusLine = summarizeActivityLines(lines) || '활동 로그를 불러오는 중입니다.';
const activityCountLabel = `${lines.length}개 로그`;
return (
<div key={`activity-${message.id}`} className="app-chat-message-stack app-chat-message-stack--system">
<section
ref={(element) => {
setActivitySectionRef(requestId, element);
}}
className={`app-chat-preview-card app-chat-preview-card--activity${
isExpanded ? ' app-chat-preview-card--activity-expanded' : ' app-chat-preview-card--activity-collapsed'
}`}
>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph app-chat-preview-card__glyph--activity" aria-hidden="true">
<MessageOutlined />
</span>
<div className="app-chat-preview-card__titles">
<div className="app-chat-activity-card__title-row">
<span className="app-chat-preview-card__label"> </span>
<span className="app-chat-activity-card__badge">{activityCountLabel}</span>
</div>
<span className="app-chat-preview-card__kind app-chat-activity-card__request-id">{requestId}</span>
</div>
</div>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded ? '활동 로그 접기' : '활동 로그 펼치기'}
iconPosition="end"
onClick={() => {
setCollapsedActivityRequestIds((current) => {
if (current.includes(requestId)) {
scrollActivitySectionIntoView(requestId);
return current.filter((currentRequestId) => currentRequestId !== requestId);
}
return [...current, requestId];
});
}}
>
{isExpanded ? '접기' : '펼치기'}
</Button>
</div>
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity app-chat-preview-card__body--activity-summary">
<div className="app-chat-activity-card__summary" aria-label="실시간 상태">
<span className="app-chat-activity-card__summary-label"> </span>
<span className="app-chat-message__activity-status">{liveStatusLine}</span>
</div>
</div>
{isExpanded ? (
<div className="app-chat-preview-card__body app-chat-preview-card__body--activity">
<div className="app-chat-activity-log" role="log" aria-live="polite" aria-label="활동 로그 상세">
{lines.map((line, lineIndex) => (
<div key={`${requestId}-${lineIndex}`} className="app-chat-activity-log__item">
<span className="app-chat-activity-log__index" aria-hidden="true">
{lineIndex + 1}
</span>
<p className="app-chat-activity-log__line">{line}</p>
</div>
))}
</div>
</div>
) : null}
</section>
</div>
);
};
return (
<div className="app-chat-panel__conversation-view">
{isConversationLoading ? (
<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${isConversationLoading ? ' is-loading' : ''}${
showBusyOverlay ? ' is-busy' : ''
}`}
>
<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>
{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={() => {
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={`${resolveChatPreviewKindLabel(item.kind as ChatPreviewKind)} 형식`}
>
{buildResourceChipMeta(item)}
</span>
</button>
))}
</div>
</>
) : (
<span className="app-chat-panel__resource-strip-empty">
.
</span>
)}
</div>
) : null}
<div
ref={viewportRef}
className="app-chat-panel__messages"
onScroll={onViewportScroll}
onTouchEnd={onViewportTouchEnd}
onTouchMove={onViewportTouchMove}
onTouchStart={onViewportTouchStart}
>
{hasOlderMessages || 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}
{orderedMessages.map((message) => {
const canCollapseMessage = collapsibleMessageIds.includes(message.id);
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 } = extractMessageRenderPayload(message);
const renderedText = isRecoveredMissingRequest
? getMissingRequestMessageText(message)
: isRecoveredExecutionFailure
? getExecutionFailureMessageText(message)
: visibleText;
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
const hasPreviewCards =
diffBlocks.length > 0 || inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0;
const shouldRenderStandalonePreview =
hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system');
const stackClassName = [
`app-chat-message-stack app-chat-message-stack--${message.author}`,
shouldRenderStandalonePreview ? 'app-chat-message-stack--artifact-only' : '',
]
.filter(Boolean)
.join(' ');
const requestState = message.clientRequestId ? requestStateMap.get(message.clientRequestId) : undefined;
const requestStatusLabel = formatRequestStatusLabel(requestState);
const requestDetailText = getRequestDetailText(requestState);
return (
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article
className={`app-chat-message ${
isRecoveredMissingRequest || isRecoveredExecutionFailure
? 'app-chat-message--system-inline'
: `app-chat-message--${message.author}`
}`}
>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>
{isRecoveredMissingRequest
? '원문 누락'
: isRecoveredExecutionFailure
? '실행 실패'
: 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' && 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' &&
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' ? (
<Button
type="text"
size="small"
className="app-chat-message__header-action"
icon={<CopyOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
onClick={() => {
onCopyMessage(message);
}}
/>
) : null}
</div>
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={baseMessageBodyClassName}
>
{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={() => {
setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}}
>
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null}
</article>
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{linkCardTargets.map((target) => (
<ChatLinkCardPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{rankedLinkTargets.map((target) => (
<ChatRankedLinkPreview key={`${message.id}-${target.url}-${target.title}`} target={target} />
))}
{diffBlocks.map((diffText, index) => {
const previewKey = `${message.id}-diff-${index}`;
return (
<DiffMessagePreview
key={previewKey}
diffText={diffText}
fileCount={Math.max(1, Array.from(diffText.matchAll(/^diff --git /gm)).length)}
isExpanded={expandedPreviewKey === previewKey}
isFullscreen={fullscreenPreviewKey === previewKey}
onToggle={() => {
setExpandedPreviewKey((current) => {
if (fullscreenPreviewKey === previewKey) {
setFullscreenPreviewKey(null);
return null;
}
return current === previewKey ? null : previewKey;
});
}}
onToggleFullscreen={() => {
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={() => {
onOpenPreview(
matchedPreview
? matchedPreview.id
: {
id: previewKey,
label: target.label,
url: target.url,
kind: target.kind,
source: 'message',
},
{ fullscreen: true },
);
}}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
}}
/>
);
})}
</div>
) : null}
</div>
);
})}
</div>
{activeSystemStatus ? (
<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>{activeSystemStatus}</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>
</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">
<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>
<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' : ''
}`}
icon={<DisconnectOutlined />}
aria-label={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
title={
isSendWithoutContextEnabled
? '다음 1회만 문맥 없이 보냄'
: '다음 전송을 문맥 없이 보내기'
}
onClick={onToggleSendWithoutContext}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
icon={<ThunderboltOutlined />}
aria-label="즉시 요청"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSendImmediate(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
<Button
type="primary"
icon={<SendOutlined />}
aria-label="큐로 보내기"
onClick={() => {
lastReportedDraftRef.current = composerDraft;
onDraftChange(composerDraft);
onSend(composerDraft);
}}
disabled={isComposerDisabled || isComposerAttachmentUploading}
/>
</div>
</div>
</div>
{chatTypeOptions.length === 0 ? (
<Alert
showIcon
type="warning"
message="사용 가능한 컨텍스트가 없습니다."
description="관리 페이지에서 현재 사용자 권한에 맞는 컨텍스트를 등록하거나 권한을 부여하세요."
/>
) : null}
{composerAttachmentStrip}
<div
className={`app-chat-panel__composer-input-shell${
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
}`}
>
{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"
danger
icon={<CloseOutlined />}
aria-label="대기 요청 취소"
onClick={() => {
onRemoveQueuedRequest(item.requestId);
}}
/>
</div>
</div>
))}
</div>
</div>
) : null}
<Input.TextArea
ref={composerRef}
value={composerDraft}
autoSize={false}
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
setComposerDraft(event.target.value);
}}
onPaste={handleComposerPaste}
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();
lastReportedDraftRef.current = event.currentTarget.value;
onDraftChange(event.currentTarget.value);
onSend(event.currentTarget.value);
}}
/>
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${composerDraft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!composerDraft.trim()}
>
clear
</Button>
<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>
</div>
);
}