feat: update codex live chat workflow
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user