feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -1,10 +1,13 @@
import {
CodeOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
DownOutlined,
ExclamationCircleOutlined,
LinkOutlined,
FullscreenExitOutlined,
FullscreenOutlined,
MessageOutlined,
PaperClipOutlined,
PlusOutlined,
@@ -14,11 +17,25 @@ import {
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Input, Select, Spin } from 'antd';
import { Alert, Button, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type RefObject, type TouchEvent } from 'react';
import { ChatPreviewBody } from './ChatPreviewBody';
import {
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 { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
@@ -44,6 +61,7 @@ type ChatTypeOption = {
type PreviewOption = {
id: string;
label: string;
url: string;
kind: string;
};
@@ -53,7 +71,7 @@ type QueuedRequestOption = {
text: string;
};
type InlinePreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
type InlinePreviewKind = ChatPreviewKind;
type InlinePreviewTarget = {
url: string;
@@ -66,9 +84,17 @@ type PreviewFetchError = Error & {
};
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g;
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
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 = {
visibleText: string;
diffBlocks: string[];
};
function normalizeInlinePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
@@ -89,6 +115,10 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
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';
}
@@ -104,6 +134,22 @@ function classifyInlinePreviewKind(url: string): InlinePreviewKind {
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);
@@ -143,7 +189,7 @@ async function createPreviewFetchError(response: Response): Promise<PreviewFetch
}
function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
const matches = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
const matches = [...(text.match(INLINE_PREVIEW_URL_PATTERN) ?? []), ...extractHiddenPreviewUrls(text)];
const seen = new Set<string>();
const targets: InlinePreviewTarget[] = [];
@@ -170,6 +216,81 @@ function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] {
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">
{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(text: string): MessageRenderPayload {
const diffBlocks = Array.from(text.matchAll(DIFF_CODE_BLOCK_PATTERN))
.map((match) => match[1]?.trim())
.filter((value): value is string => Boolean(value));
const visibleText = stripHiddenPreviewTags(text.replace(DIFF_CODE_BLOCK_PATTERN, ''));
return {
visibleText,
diffBlocks,
};
}
function summarizeQueuedText(text: string) {
const normalized = text.replace(/\s+/g, ' ').trim();
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}...` : normalized;
@@ -289,10 +410,14 @@ function getRequestDetailText(request: ChatConversationRequest | undefined) {
function InlineMessagePreview({
target,
isExpanded,
hasModalPreview,
onOpenModalPreview,
onToggle,
}: {
target: InlinePreviewTarget;
isExpanded: boolean;
hasModalPreview: boolean;
onOpenModalPreview: () => void;
onToggle: () => void;
}) {
const [textPreview, setTextPreview] = useState('');
@@ -347,26 +472,77 @@ function InlineMessagePreview({
};
}, [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">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<LinkOutlined />
{resolveChatPreviewGlyph(target.kind)}
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.label}</span>
<span className="app-chat-preview-card__kind">{target.kind}</span>
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
</div>
</div>
<Button
type="link"
size="small"
className="app-chat-preview-card__toggle"
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpanded ? 'preview 접기' : 'preview 펼치기'}
onClick={onToggle}
/>
<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 다운로드"
href={target.url}
download
/>
<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 ? (
@@ -384,6 +560,100 @@ function InlineMessagePreview({
);
}
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">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<CodeOutlined />
</span>
<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>;
@@ -418,9 +688,10 @@ type ChatConversationViewProps = {
onSelectChatType: (value: string) => void;
onSend: () => void;
onSendImmediate: () => void;
onClearDraft: () => void;
onScrollToBottom: () => void;
onToggleResourceStrip: () => void;
onOpenPreview: (previewId: string) => void;
onOpenPreview: (previewId: string, options?: { fullscreen?: boolean }) => void;
onCopyMessage: (message: ChatMessage) => void;
onRetryMessage: (message: ChatMessage) => void;
onCancelMessage: (message: ChatMessage) => void;
@@ -462,6 +733,7 @@ export function ChatConversationView({
onSelectChatType,
onSend,
onSendImmediate,
onClearDraft,
onScrollToBottom,
onToggleResourceStrip,
onOpenPreview,
@@ -473,34 +745,88 @@ export function ChatConversationView({
}: 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 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 orderedMessages = useMemo(() => {
const lastActivityIndexByKey = new Map<string, number>();
visibleMessages.forEach((message, index) => {
if (!isActivityLogMessage(message)) {
return;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
lastActivityIndexByKey.set(activityKey, index);
});
return visibleMessages.filter((message, index) => {
const latestActivityByRequestId = new Map<string, ChatMessage>();
const orphanActivityMessages: ChatMessage[] = [];
const baseMessages = visibleMessages.filter((message) => {
if (!isActivityLogMessage(message)) {
return true;
}
const activityKey = message.clientRequestId?.trim() || `activity-${message.id}`;
return lastActivityIndexByKey.get(activityKey) === index;
const activityKey = message.clientRequestId?.trim();
if (!activityKey) {
orphanActivityMessages.push(message);
return false;
}
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];
}, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
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) {
@@ -651,6 +977,28 @@ export function ChatConversationView({
};
}, [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]);
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
@@ -662,6 +1010,32 @@ export function ChatConversationView({
void onPickComposerFiles(files);
};
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
const clipboardData = event.clipboardData;
if (!clipboardData) {
return;
}
const itemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => Boolean(file));
const files = itemFiles.length > 0 ? itemFiles : Array.from(clipboardData.files ?? []);
if (files.length === 0) {
return;
}
event.preventDefault();
const uniqueFiles = Array.from(
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void onPickComposerFiles(uniqueFiles);
};
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
@@ -748,7 +1122,19 @@ export function ChatConversationView({
</div>
) : null}
<div className={`app-chat-panel__conversation-view-inner${isConversationLoading ? ' is-loading' : ''}`}>
{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'}
@@ -821,141 +1207,198 @@ export function ChatConversationView({
const isExpandedMessage = expandedMessageIds.includes(message.id);
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
if (isActivityLogMessage(message)) {
return renderActivityCard(message);
}
const inlinePreviewTargets = extractInlinePreviewTargets(message.text);
const inlinePreviewTargets = extractInlinePreviewTargets(visibleText);
const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.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={`app-chat-message-stack app-chat-message-stack--${message.author}`}>
<article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{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' ? (
<>
<div key={message.id} className={stackClassName}>
{shouldRenderStandalonePreview ? null : (
<article className={`app-chat-message app-chat-message--${message.author}`}>
<div className="app-chat-message__header">
<div className="app-chat-message__header-meta">
<strong>{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"
className="app-chat-message__retry"
icon={<RedoOutlined />}
danger
className="app-chat-message__cancel"
icon={<CloseOutlined />}
onClick={() => {
onRetryMessage(message);
onCancelMessage(message);
}}
>
</Button>
</>
) : null}
{message.author === 'user' &&
requestState?.canDelete &&
requestState.status !== 'accepted' ? (
) : 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="link"
type="text"
size="small"
className="app-chat-message__retry app-chat-message__delete"
icon={<DeleteOutlined />}
aria-label="메시지 삭제"
className="app-chat-message__header-action"
icon={<CopyOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '내 메시지 복사' : '답변 복사'}
onClick={() => {
onDeleteRequest(message);
onCopyMessage(message);
}}
/>
) : null}
</div>
{message.author !== 'system' ? (
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{visibleText ? renderMessageBody(visibleText) : 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__header-action"
icon={<CopyOutlined />}
aria-label={copiedMessageId === message.id ? '복사됨' : message.author === 'user' ? '메시지 복사' : '답변 복사'}
className="app-chat-message__expand"
icon={isExpandedMessage ? <UpOutlined /> : <DownOutlined />}
aria-label={isExpandedMessage ? '메시지 접기' : '메시지 펼치기'}
onClick={() => {
onCopyMessage(message);
setExpandedMessageIds((current) =>
current.includes(message.id) ? current.filter((id) => id !== message.id) : [...current, message.id],
);
}}
/>
>
{isExpandedMessage ? '접기' : '펼치기'}
</Button>
) : null}
</div>
<div
ref={(element) => {
setMessageBodyRef(message.id, element);
}}
className={messageBodyClassName}
>
{message.text}
</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>
{inlinePreviewTargets.length > 0 ? (
</article>
)}
{hasPreviewCards ? (
<div className="app-chat-message-stack__previews">
{inlinePreviewTargets.map((target) => (
<InlineMessagePreview
key={`${message.id}-${target.url}`}
target={target}
isExpanded={expandedPreviewKey === `${message.id}-${target.url}`}
onToggle={() => {
const nextKey = `${message.id}-${target.url}`;
setExpandedPreviewKey((current) => (current === nextKey ? null : nextKey));
}}
/>
))}
{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={Boolean(matchedPreview)}
onOpenModalPreview={() => {
if (matchedPreview) {
onOpenPreview(matchedPreview.id, { fullscreen: true });
return;
}
}}
onToggle={() => {
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
}}
/>
);
})}
</div>
) : null}
</div>
@@ -1094,6 +1537,7 @@ export function ChatConversationView({
onChange={(event) => {
onDraftChange(event.target.value);
}}
onPaste={handleComposerPaste}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
return;
@@ -1113,6 +1557,16 @@ export function ChatConversationView({
onSend();
}}
/>
<Button
type="text"
size="small"
className={`app-chat-panel__composer-clear${draft.trim() ? ' app-chat-panel__composer-clear--visible' : ''}`}
aria-label="입력창 비우기"
onClick={onClearDraft}
disabled={!draft.trim()}
>
clear
</Button>
<input
ref={fileInputRef}