chore: update live chat and work server changes

This commit is contained in:
2026-04-26 16:37:06 +09:00
parent 63e5d263a7
commit 20a6333ed2
38 changed files with 2078 additions and 2281 deletions

View File

@@ -1,5 +1,4 @@
import {
CodeOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
@@ -17,7 +16,7 @@ import {
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd';
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
useEffect,
@@ -32,7 +31,8 @@ import {
} from 'react';
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
@@ -40,7 +40,6 @@ import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
const { Text } = Typography;
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,
@@ -81,6 +80,13 @@ type InlinePreviewTarget = {
kind: InlinePreviewKind;
};
type PendingComposerUpload = {
key: string;
name: string;
status: 'uploading' | 'uploaded' | 'failed';
reason?: string;
};
type PreviewFetchError = Error & {
status?: number;
};
@@ -171,19 +177,8 @@ function buildPreviewFileName(item: PreviewOption) {
}
}
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
switch (kind) {
case 'image':
case 'video':
case 'markdown':
case 'code':
case 'diff':
case 'document':
case 'pdf':
return kind;
default:
return 'file';
}
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
@@ -459,7 +454,7 @@ function InlineMessagePreview({
return;
}
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
return;
}
@@ -529,9 +524,6 @@ function InlineMessagePreview({
<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>
@@ -623,9 +615,6 @@ function DiffMessagePreview({
>
<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>
@@ -707,6 +696,7 @@ type ChatConversationViewProps = {
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
isComposerDisabled: boolean;
isMobileViewport: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
onViewportScroll: () => void;
@@ -714,7 +704,7 @@ type ChatConversationViewProps = {
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
onDraftChange: (value: string) => void;
onPickComposerFiles: (files: File[]) => void | Promise<void>;
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
onRemoveComposerAttachment: (attachmentId: string) => void;
onSelectChatType: (value: string) => void;
onSend: () => void;
@@ -753,6 +743,7 @@ export function ChatConversationView({
previewItems,
isResourceStripOpen,
isComposerDisabled,
isMobileViewport,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
onViewportScroll,
@@ -777,12 +768,12 @@ export function ChatConversationView({
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = 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 fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
@@ -841,11 +832,6 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const selectedChatTypeOption = useMemo(
() => chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null,
[chatTypeOptions, selectedChatTypeId],
);
const normalizedSelectedChatTypeLabel = selectedChatTypeOption?.label?.trim() ?? '';
const isChatTypeReadonly = useMemo(() => {
if (isChatTypeSelectionLocked) {
return true;
@@ -1064,8 +1050,74 @@ export function ChatConversationView({
};
}, [isComposerAttachmentUploading, isConversationLoading]);
useEffect(() => {
if (pendingComposerUploads.length === 0) {
return;
}
const uploadedAttachmentNames = new Set(
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter(
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
);
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 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)),
...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 = '';
@@ -1074,7 +1126,7 @@ export function ChatConversationView({
return;
}
void onPickComposerFiles(files);
void syncPendingComposerUploads(files);
};
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
@@ -1100,9 +1152,69 @@ export function ChatConversationView({
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void onPickComposerFiles(uniqueFiles);
void syncPendingComposerUploads(uniqueFiles);
};
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);
@@ -1229,22 +1341,17 @@ export function ChatConversationView({
</label>
<div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => (
<InlineMessagePreview
<button
key={item.id}
target={{
label: item.label,
url: item.url,
kind: normalizePreviewOptionKind(item.kind),
type="button"
className="app-chat-panel__resource-chip"
onClick={() => {
onOpenPreview(item.id);
}}
isExpanded={expandedResourcePreviewKey === item.id}
hasModalPreview
onOpenModalPreview={() => {
onOpenPreview(item.id, { fullscreen: true });
}}
onToggle={() => {
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
}}
/>
>
<span title={item.label}>{item.label}</span>
<span>{item.kind}</span>
</button>
))}
</div>
</>
@@ -1491,22 +1598,24 @@ export function ChatConversationView({
</div>
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
<div
className={`app-chat-panel__system-status${
activeSystemStatus ? '' : ' app-chat-panel__system-status--hidden'
}${isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''}`}
>
<span>{activeSystemStatus ?? ''}</span>
{activeSystemStatus && 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}
{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>
</div>
) : null}
{showScrollToBottom ? (
<div className="app-chat-panel__scroll-jump">
@@ -1544,11 +1653,6 @@ export function ChatConversationView({
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
onChange={onSelectChatType}
/>
{normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? (
<Text type="secondary" className="app-chat-panel__composer-type-note">
: {normalizedSelectedChatTypeLabel}
</Text>
) : null}
</div>
<div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons">
@@ -1578,6 +1682,8 @@ export function ChatConversationView({
/>
) : null}
{composerAttachmentStrip}
<div
className={`app-chat-panel__composer-input-shell${
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
@@ -1616,12 +1722,8 @@ export function ChatConversationView({
<Input.TextArea
ref={composerRef}
value={draft}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={
isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.'
}
autoSize={false}
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
onDraftChange(event.target.value);
@@ -1632,7 +1734,8 @@ export function ChatConversationView({
return;
}
if (!event.ctrlKey) {
const hasSubmitModifier = event.ctrlKey || event.metaKey;
if (!hasSubmitModifier) {
event.stopPropagation();
return;
}
@@ -1661,30 +1764,11 @@ export function ChatConversationView({
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>
{composerAttachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{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}
</div>
</div>
</div>