693 lines
23 KiB
TypeScript
693 lines
23 KiB
TypeScript
import {
|
|
CodeOutlined,
|
|
CopyOutlined,
|
|
DownloadOutlined,
|
|
DownOutlined,
|
|
FullscreenExitOutlined,
|
|
FullscreenOutlined,
|
|
UpOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Button, Empty, Spin, Typography, message as antdMessage } from 'antd';
|
|
import { useEffect, useState, type ReactNode } from 'react';
|
|
import { InlineImage } from '../../../../components/common/InlineImage';
|
|
import { CodexDiffBlock } from '../../../../components/previewer';
|
|
import {
|
|
ChatPreviewBody,
|
|
resolveChatPreviewGlyph,
|
|
resolveChatPreviewKindLabel,
|
|
type ChatPreviewKind,
|
|
type ChatPreviewTarget,
|
|
} from '../../mainChatPanel/ChatPreviewBody';
|
|
import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls';
|
|
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers';
|
|
import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
|
|
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
|
|
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
|
|
|
const { Text } = Typography;
|
|
|
|
type ConversationRoomPaneProps = {
|
|
sessionId: string;
|
|
messages: ChatMessage[];
|
|
requests: ChatConversationRequest[];
|
|
isLoading: boolean;
|
|
loadingLabel: string;
|
|
errorMessage: string;
|
|
};
|
|
|
|
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
|
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g;
|
|
const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6;
|
|
const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280;
|
|
|
|
type MessageRenderPayload = {
|
|
previewSourceText: string;
|
|
visibleText: string;
|
|
diffBlocks: string[];
|
|
};
|
|
|
|
function formatChatTimestamp(timestamp: string) {
|
|
const normalized = String(timestamp ?? '').trim();
|
|
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
const parsed = new Date(normalized);
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return normalized;
|
|
}
|
|
|
|
return new Intl.DateTimeFormat('sv-SE', {
|
|
timeZone: 'Asia/Seoul',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
})
|
|
.format(parsed)
|
|
.replace(',', '');
|
|
}
|
|
|
|
function classifyInlinePreviewKind(url: string): ChatPreviewKind | 'file' {
|
|
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 buildInlinePreviewLabel(url: string) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
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 extractInlinePreviewTargets(text: string): ChatPreviewTarget[] {
|
|
const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)];
|
|
const seen = new Set<string>();
|
|
const targets: ChatPreviewTarget[] = [];
|
|
|
|
for (const matchedUrl of matches) {
|
|
const normalizedUrl = normalizeChatResourceUrl(matchedUrl);
|
|
const kind = classifyInlinePreviewKind(normalizedUrl);
|
|
|
|
if (kind === 'file' || 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 = normalizeChatResourceUrl(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) {
|
|
return text.split('\n').map((line, index) => {
|
|
const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN);
|
|
|
|
if (imageMatch) {
|
|
const [, alt, rawSrc] = imageMatch;
|
|
const src = normalizeChatResourceUrl(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 previewSourceText = text.replace(DIFF_CODE_BLOCK_PATTERN, '');
|
|
const visibleText = stripHiddenPreviewTags(previewSourceText);
|
|
|
|
return { previewSourceText, visibleText, diffBlocks };
|
|
}
|
|
|
|
function isLikelyCollapsibleMessage(text: string) {
|
|
const normalizedText = String(text ?? '').trim();
|
|
|
|
if (!normalizedText) {
|
|
return false;
|
|
}
|
|
|
|
if (normalizedText.length > COLLAPSIBLE_MESSAGE_CHAR_COUNT) {
|
|
return true;
|
|
}
|
|
|
|
return normalizedText
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean).length > COLLAPSIBLE_MESSAGE_LINE_COUNT;
|
|
}
|
|
|
|
async function createPreviewFetchError(response: Response) {
|
|
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
|
|
let responseMessage = '';
|
|
|
|
try {
|
|
responseMessage = contentType.includes('application/json')
|
|
? String(((await response.json()) as { message?: string }).message ?? '').trim()
|
|
: (await response.text()).trim();
|
|
} catch {
|
|
responseMessage = '';
|
|
}
|
|
|
|
const statusLabel =
|
|
response.status === 403
|
|
? '이 문서는 현재 권한으로 열 수 없습니다.'
|
|
: response.status === 404
|
|
? '이 문서를 찾을 수 없습니다.'
|
|
: response.status === 401
|
|
? '이 문서를 열기 위한 인증이 필요합니다.'
|
|
: `preview 요청이 실패했습니다. (${response.status})`;
|
|
|
|
return new Error(responseMessage ? `${statusLabel} ${responseMessage}` : statusLabel);
|
|
}
|
|
|
|
function InlineMessagePreview({
|
|
target,
|
|
isExpanded,
|
|
onToggle,
|
|
}: {
|
|
target: ChatPreviewTarget;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const [previewText, setPreviewText] = useState('');
|
|
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
|
const [previewError, setPreviewError] = useState('');
|
|
const [previewContentType, setPreviewContentType] = useState('');
|
|
|
|
useEffect(() => {
|
|
if (!isExpanded || target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setIsPreviewLoading(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') ?? '');
|
|
setPreviewText((await response.text()).slice(0, 1600));
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (controller.signal.aborted) {
|
|
return;
|
|
}
|
|
|
|
setPreviewText('');
|
|
setPreviewContentType('');
|
|
setPreviewError(error instanceof Error ? error.message : 'preview를 불러오지 못했습니다.');
|
|
})
|
|
.finally(() => {
|
|
if (!controller.signal.aborted) {
|
|
setIsPreviewLoading(false);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
controller.abort();
|
|
};
|
|
}, [isExpanded, target.kind, target.url]);
|
|
|
|
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">
|
|
{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">{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={() => {
|
|
void copyPreviewContent({
|
|
kind: target.kind,
|
|
url: target.url,
|
|
fallbackText: previewText,
|
|
})
|
|
.then((result) => {
|
|
if (result === 'image') {
|
|
antdMessage.success('preview 이미지를 복사했습니다.');
|
|
return;
|
|
}
|
|
|
|
if (result === 'url') {
|
|
antdMessage.success('preview 이미지 URL을 복사했습니다.');
|
|
return;
|
|
}
|
|
|
|
antdMessage.success('preview 내용을 복사했습니다.');
|
|
})
|
|
.catch((error: unknown) =>
|
|
antdMessage.error(error instanceof Error ? error.message : 'preview 내용을 복사하지 못했습니다.'),
|
|
);
|
|
}}
|
|
/>
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-preview-card__action"
|
|
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
|
aria-label={isExpanded ? 'preview 최대화 해제' : 'preview 최대화'}
|
|
onClick={onToggle}
|
|
/>
|
|
<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={previewText}
|
|
isPreviewLoading={isPreviewLoading}
|
|
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;
|
|
}) {
|
|
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={() => {
|
|
void copyText(diffText)
|
|
.then(() => antdMessage.success('diff를 복사했습니다.'))
|
|
.catch((error: unknown) => antdMessage.error(error instanceof Error ? error.message : 'diff를 복사하지 못했습니다.'));
|
|
}}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
export function ConversationRoomPane({
|
|
sessionId,
|
|
messages,
|
|
requests,
|
|
isLoading,
|
|
loadingLabel,
|
|
errorMessage,
|
|
}: ConversationRoomPaneProps) {
|
|
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
|
|
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
|
|
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
|
|
|
|
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]);
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<section className="chat-v2__pane chat-v2__pane--room">
|
|
<div className="chat-v2__state">
|
|
<Empty description="채팅방을 선택해 주세요." />
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<section className="chat-v2__pane chat-v2__pane--room">
|
|
<div className="chat-v2__state">
|
|
<Spin />
|
|
<Text type="secondary">{loadingLabel}</Text>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return (
|
|
<section className="chat-v2__pane chat-v2__pane--room">
|
|
<div className="chat-v2__state">
|
|
<Text type="danger">{errorMessage}</Text>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="chat-v2__pane chat-v2__pane--room">
|
|
<div className="chat-v2__pane-header">
|
|
<div>
|
|
<Text strong>{sessionId}</Text>
|
|
<br />
|
|
<Text type="secondary">
|
|
메시지 {messages.length}개 · 요청 {requests.length}개
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chat-v2__room-stream">
|
|
{messages.length === 0 ? (
|
|
<div className="chat-v2__state">
|
|
<Empty description="메시지가 없습니다." />
|
|
</div>
|
|
) : (
|
|
messages.map((message) => {
|
|
const canCollapseMessage = isLikelyCollapsibleMessage(message.text);
|
|
const isExpandedMessage = expandedMessageIds.includes(message.id);
|
|
const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage;
|
|
const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`;
|
|
const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text);
|
|
const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText);
|
|
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(' ');
|
|
|
|
return (
|
|
<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>
|
|
</div>
|
|
{message.author !== 'system' ? (
|
|
<Button
|
|
type="text"
|
|
size="small"
|
|
className="app-chat-message__header-action"
|
|
icon={<CopyOutlined />}
|
|
aria-label="메시지 복사"
|
|
onClick={() => {
|
|
void copyText(message.text)
|
|
.then(() => antdMessage.success('메시지를 복사했습니다.'))
|
|
.catch((error: unknown) =>
|
|
antdMessage.error(error instanceof Error ? error.message : '메시지를 복사하지 못했습니다.'),
|
|
);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className={messageBodyClassName}>{visibleText ? renderMessageBody(visibleText) : null}</div>
|
|
{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">
|
|
{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}`;
|
|
return (
|
|
<InlineMessagePreview
|
|
key={previewKey}
|
|
target={target}
|
|
isExpanded={expandedPreviewKey === previewKey}
|
|
onToggle={() => {
|
|
setExpandedPreviewKey((current) => (current === previewKey ? null : previewKey));
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|