feat: update codex live chat workflow
This commit is contained in:
@@ -1,4 +1,25 @@
|
||||
import { Empty, Spin, Typography } from 'antd';
|
||||
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 { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl';
|
||||
import { copyPreviewContent, copyText } from '../../mainChatPanel/chatUtils';
|
||||
import type { ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -12,6 +33,460 @@ type ConversationRoomPaneProps = {
|
||||
errorMessage: string;
|
||||
};
|
||||
|
||||
const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/;
|
||||
const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
const INLINE_PREVIEW_URL_PATTERN = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/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 = {
|
||||
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 = text.match(INLINE_PREVIEW_URL_PATTERN) ?? [];
|
||||
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 visibleText = text
|
||||
.replace(DIFF_CODE_BLOCK_PATTERN, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return { 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}
|
||||
/>
|
||||
</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,
|
||||
@@ -20,6 +495,30 @@ export function ConversationRoomPane({
|
||||
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">
|
||||
@@ -69,15 +568,122 @@ export function ConversationRoomPane({
|
||||
<Empty description="메시지가 없습니다." />
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<article key={`${message.id}-${message.timestamp}`} className={`chat-v2__message chat-v2__message--${message.author}`}>
|
||||
<div className="chat-v2__message-meta">
|
||||
<Text strong>{message.author}</Text>
|
||||
<Text type="secondary">{message.timestamp}</Text>
|
||||
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 { visibleText, diffBlocks } = extractMessageRenderPayload(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(' ');
|
||||
|
||||
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 className="chat-v2__message-body">{message.text}</div>
|
||||
</article>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ChatGateway = {
|
||||
createConversation: (args: {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -43,7 +44,7 @@ export type ChatGateway = {
|
||||
updateConversation: (
|
||||
sessionId: string,
|
||||
payload: Partial<
|
||||
Pick<ChatConversationSummary, 'title' | 'notifyOffline' | 'hasUnreadResponse'>
|
||||
Pick<ChatConversationSummary, 'title' | 'chatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
deleteConversation: (sessionId: string) => Promise<void>;
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import type { ChatConversationSummary } from '../../mainChatPanel/types';
|
||||
import {
|
||||
CHAT_CONVERSATIONS_UPDATED_EVENT,
|
||||
readChatConversationsUpdatedEvent,
|
||||
} from '../data/chatClientEvents';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
|
||||
type UseConversationListDataOptions = {
|
||||
@@ -19,6 +16,33 @@ type UseConversationListDataResult = {
|
||||
setConversationSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
function mergeConversationItemsPreservingRequestedSession(
|
||||
nextItems: ChatConversationSummary[],
|
||||
previousItems: ChatConversationSummary[],
|
||||
requestedSessionId: string,
|
||||
) {
|
||||
const normalizedRequestedSessionId = requestedSessionId.trim();
|
||||
|
||||
if (!normalizedRequestedSessionId) {
|
||||
return sortChatConversationSummaries(nextItems);
|
||||
}
|
||||
|
||||
const hasRequestedSession = nextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
|
||||
|
||||
if (hasRequestedSession) {
|
||||
return sortChatConversationSummaries(nextItems);
|
||||
}
|
||||
|
||||
const preservedRequestedSession =
|
||||
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
|
||||
|
||||
if (!preservedRequestedSession) {
|
||||
return sortChatConversationSummaries(nextItems);
|
||||
}
|
||||
|
||||
return sortChatConversationSummaries([preservedRequestedSession, ...nextItems]);
|
||||
}
|
||||
|
||||
export function useConversationListData({
|
||||
requestedSessionId,
|
||||
}: UseConversationListDataOptions): UseConversationListDataResult {
|
||||
@@ -31,9 +55,11 @@ export function useConversationListData({
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
setConversationItems(items);
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
} catch {
|
||||
setConversationItems([]);
|
||||
setConversationItems((previous) => previous);
|
||||
} finally {
|
||||
setIsConversationListLoading(false);
|
||||
}
|
||||
@@ -46,12 +72,14 @@ export function useConversationListData({
|
||||
.listConversations()
|
||||
.then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems(items);
|
||||
setConversationItems((previous) =>
|
||||
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems([]);
|
||||
setConversationItems((previous) => previous);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -67,63 +95,6 @@ export function useConversationListData({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedSessionId || isConversationListLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
const loadRequestedConversation = async () => {
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId);
|
||||
|
||||
if (isCancelled || response.item.sessionId !== requestedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
} catch {
|
||||
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
|
||||
}
|
||||
};
|
||||
|
||||
void loadRequestedConversation();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [conversationItems, isConversationListLoading, requestedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleConversationsUpdated = (event: Event) => {
|
||||
const detail = readChatConversationsUpdatedEvent(event);
|
||||
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversationItems(detail.items);
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversationItems,
|
||||
setConversationItems,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
|
||||
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
|
||||
import { sortChatConversationSummaries } from '../../mainChatPanel';
|
||||
import { chatGateway } from '../data/chatGateway';
|
||||
import type {
|
||||
ChatConversationRequest,
|
||||
@@ -7,39 +8,28 @@ import type {
|
||||
ChatMessage,
|
||||
} from '../../mainChatPanel/types';
|
||||
|
||||
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3;
|
||||
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20;
|
||||
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
|
||||
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
|
||||
|
||||
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
return cache.get(normalizedSessionId) ?? [];
|
||||
}
|
||||
|
||||
function getBestAvailableSessionMessages(
|
||||
cache: Map<string, ChatMessage[]>,
|
||||
function mergeConversationRequests(
|
||||
previous: ChatConversationRequest[],
|
||||
incoming: ChatConversationRequest[],
|
||||
sessionId: string,
|
||||
currentSessionId: string,
|
||||
currentMessages: ChatMessage[],
|
||||
) {
|
||||
const cachedMessages = getCachedSessionMessages(cache, sessionId);
|
||||
const previousSessionItems = previous.filter((item) => item.sessionId === sessionId);
|
||||
const incomingRequestIds = new Set(incoming.map((item) => item.requestId));
|
||||
const preservedLocalItems = previousSessionItems.filter((item) => !incomingRequestIds.has(item.requestId));
|
||||
|
||||
if (sessionId !== currentSessionId || currentMessages.length === 0) {
|
||||
return cachedMessages;
|
||||
}
|
||||
|
||||
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
|
||||
return [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
type UseConversationRoomDataOptions = {
|
||||
activeSessionId: string;
|
||||
oldestLoadedMessageId: number | null;
|
||||
reloadKey: number;
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected';
|
||||
shouldBlockConversationWhileLoading: (sessionId: string) => boolean;
|
||||
captureViewportRestoreSnapshot: () => void;
|
||||
captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
|
||||
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
|
||||
messagesRef: MutableRefObject<ChatMessage[]>;
|
||||
pendingViewportRestoreRef: MutableRefObject<boolean>;
|
||||
@@ -59,8 +49,9 @@ type UseConversationRoomDataOptions = {
|
||||
|
||||
export function useConversationRoomData({
|
||||
activeSessionId,
|
||||
oldestLoadedMessageId,
|
||||
reloadKey,
|
||||
connectionState,
|
||||
shouldBlockConversationWhileLoading,
|
||||
captureViewportRestoreSnapshot,
|
||||
sessionMessageCacheRef,
|
||||
messagesRef,
|
||||
@@ -78,8 +69,11 @@ export function useConversationRoomData({
|
||||
queueViewportPrependRestore,
|
||||
viewportRef,
|
||||
}: UseConversationRoomDataOptions) {
|
||||
const previousSessionIdRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId.trim()) {
|
||||
previousSessionIdRef.current = '';
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setIsConversationContentLoading(false);
|
||||
@@ -92,53 +86,95 @@ export function useConversationRoomData({
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const waitForRetry = (delayMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, delayMs);
|
||||
});
|
||||
|
||||
const loadConversationDetail = async () => {
|
||||
captureViewportRestoreSnapshot();
|
||||
const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
|
||||
|
||||
previousSessionIdRef.current = requestedSessionId;
|
||||
captureViewportRestoreSnapshot({
|
||||
forceStickToBottom: isSessionChanged,
|
||||
});
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId));
|
||||
const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
|
||||
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
}
|
||||
|
||||
setIsConversationContentLoading(true);
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT,
|
||||
});
|
||||
let response: Awaited<ReturnType<typeof chatGateway.getConversationDetail>> | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const delayMs of CONVERSATION_DETAIL_RETRY_DELAYS_MS) {
|
||||
if (delayMs > 0) {
|
||||
await waitForRetry(delayMs);
|
||||
}
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: INITIAL_CONVERSATION_REQUEST_PAGE_SIZE,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw lastError ?? new Error('대화 내용을 불러오지 못했습니다.');
|
||||
}
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
return sortChatConversationSummaries([response.item, ...previous]);
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
return sortChatConversationSummaries(
|
||||
previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)),
|
||||
);
|
||||
});
|
||||
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
const baseMessages =
|
||||
isSessionChanged
|
||||
? []
|
||||
: requestedSessionId === activeSessionId
|
||||
? messagesRef.current
|
||||
: (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
|
||||
const mergedMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, mergedMessages);
|
||||
setMessages(mergedMessages);
|
||||
setRequestItems((previous) => {
|
||||
const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId);
|
||||
return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)];
|
||||
});
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
const cachedMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
setMessages(cachedMessages);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
|
||||
setConversationLoadingLabel(
|
||||
cachedMessages.length > 0
|
||||
? '저장된 대화 내용을 먼저 보여주고 있습니다. 서버 연결을 다시 확인해 주세요.'
|
||||
: '대화 내용을 다시 불러오지 못했습니다.',
|
||||
);
|
||||
|
||||
if (cachedMessages.length > 0) {
|
||||
setMessages(cachedMessages);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
@@ -158,6 +194,7 @@ export function useConversationRoomData({
|
||||
captureViewportRestoreSnapshot,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
reloadKey,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
@@ -167,105 +204,17 @@ export function useConversationRoomData({
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldBlockConversationWhileLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) {
|
||||
return;
|
||||
if (connectionState === 'connected') {
|
||||
shouldRestoreConversationAfterReconnectRef.current = false;
|
||||
}
|
||||
|
||||
let isCancelled = false;
|
||||
const requestedSessionId = activeSessionId;
|
||||
|
||||
const restoreConversationAfterReconnect = async () => {
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0),
|
||||
});
|
||||
|
||||
if (!isCancelled && response.item.sessionId === requestedSessionId) {
|
||||
const baseMessages = getBestAvailableSessionMessages(
|
||||
sessionMessageCacheRef.current,
|
||||
requestedSessionId,
|
||||
activeSessionId,
|
||||
messagesRef.current,
|
||||
);
|
||||
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
|
||||
const hasMessageDiff = nextMessages !== baseMessages;
|
||||
|
||||
if (hasMessageDiff) {
|
||||
captureViewportRestoreSnapshot();
|
||||
pendingViewportRestoreRef.current = true;
|
||||
setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.');
|
||||
setIsConversationContentLoading(true);
|
||||
}
|
||||
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
|
||||
if (!exists) {
|
||||
return [response.item, ...previous];
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
|
||||
});
|
||||
setRequestItems(response.requests);
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
|
||||
if (hasMessageDiff) {
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
shouldRestoreConversationAfterReconnectRef.current = false;
|
||||
if (!pendingViewportRestoreRef.current) {
|
||||
setIsConversationContentLoading(false);
|
||||
}
|
||||
setIsDeferringAuxiliaryChatRequests(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void restoreConversationAfterReconnect();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [
|
||||
activeSessionId,
|
||||
captureViewportRestoreSnapshot,
|
||||
connectionState,
|
||||
messagesRef,
|
||||
pendingViewportRestoreRef,
|
||||
sessionMessageCacheRef,
|
||||
setConversationItems,
|
||||
setConversationLoadingLabel,
|
||||
setIsConversationContentLoading,
|
||||
setIsDeferringAuxiliaryChatRequests,
|
||||
setHasOlderMessages,
|
||||
setMessages,
|
||||
setOldestLoadedMessageId,
|
||||
setRequestItems,
|
||||
shouldRestoreConversationAfterReconnectRef,
|
||||
]);
|
||||
}, [connectionState, shouldRestoreConversationAfterReconnectRef]);
|
||||
|
||||
const loadOlderMessages = async () => {
|
||||
const requestedSessionId = activeSessionId.trim();
|
||||
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null;
|
||||
const oldestVisibleMessageId = oldestLoadedMessageId;
|
||||
|
||||
if (!requestedSessionId || oldestVisibleMessageId == null) {
|
||||
return;
|
||||
@@ -275,11 +224,14 @@ export function useConversationRoomData({
|
||||
|
||||
try {
|
||||
const response = await chatGateway.getConversationDetail(requestedSessionId, {
|
||||
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE,
|
||||
limit: OLDER_CONVERSATION_REQUEST_PAGE_SIZE,
|
||||
beforeMessageId: oldestVisibleMessageId,
|
||||
});
|
||||
|
||||
if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) {
|
||||
if (
|
||||
response.item.sessionId !== requestedSessionId ||
|
||||
(response.messages.length === 0 && response.requests.length === 0)
|
||||
) {
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
return;
|
||||
@@ -293,7 +245,10 @@ export function useConversationRoomData({
|
||||
queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
|
||||
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
|
||||
setMessages(nextMessages);
|
||||
setRequestItems(response.requests);
|
||||
setRequestItems((previous) => {
|
||||
const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId);
|
||||
return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)];
|
||||
});
|
||||
setHasOlderMessages(response.hasOlderMessages);
|
||||
setOldestLoadedMessageId(response.oldestLoadedMessageId);
|
||||
} finally {
|
||||
|
||||
@@ -15,7 +15,6 @@ type UseConversationViewControllerOptions = {
|
||||
previewItems: PreviewItem[];
|
||||
selectedChatTypeId: string | null;
|
||||
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
|
||||
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
|
||||
setActiveSystemStatus: (value: string | null) => void;
|
||||
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
|
||||
setCopiedMessageId: (value: number | null) => void;
|
||||
@@ -31,7 +30,6 @@ export function useConversationViewController({
|
||||
composerRef,
|
||||
previewItems,
|
||||
selectedChatTypeId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
@@ -59,7 +57,7 @@ export function useConversationViewController({
|
||||
|
||||
previousSessionIdRef.current = activeSessionId;
|
||||
|
||||
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []);
|
||||
setMessages([]);
|
||||
setDraft('');
|
||||
setComposerAttachments([]);
|
||||
setCopiedMessageId(null);
|
||||
@@ -70,7 +68,6 @@ export function useConversationViewController({
|
||||
setIsResourceStripOpen(false);
|
||||
}, [
|
||||
activeSessionId,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
|
||||
@@ -132,7 +132,16 @@ export function useConversationViewportController({
|
||||
setShowScrollToBottom(!isNearBottom);
|
||||
}, [viewportRef]);
|
||||
|
||||
const captureViewportRestoreSnapshot = useCallback(() => {
|
||||
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
|
||||
if (options?.forceStickToBottom) {
|
||||
viewportRestoreSnapshotRef.current = {
|
||||
shouldStickToBottom: true,
|
||||
offsetFromBottom: 0,
|
||||
};
|
||||
shouldStickToBottomRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
|
||||
if (!viewport) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchNotificationMessages,
|
||||
updateNotificationMessageReadState,
|
||||
type NotificationMessageItem,
|
||||
type NotificationMessageListStatus,
|
||||
} from '../../notificationApi';
|
||||
import { useUnreadCounts } from './useUnreadCounts';
|
||||
|
||||
@@ -20,6 +21,7 @@ function mergeMessageItem(items: NotificationMessageItem[], nextItem: Notificati
|
||||
}
|
||||
|
||||
export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
const [listStatus, setListStatus] = useState<NotificationMessageListStatus>('unread');
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
|
||||
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
|
||||
@@ -40,7 +42,7 @@ export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchNotificationMessages({ limit: 30 });
|
||||
const response = await fetchNotificationMessages({ status: listStatus, limit: 30 });
|
||||
setMessages(response.items);
|
||||
} catch (error) {
|
||||
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
|
||||
@@ -53,7 +55,10 @@ export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
setSelectedMessage(nextItem);
|
||||
setMessages((current) => {
|
||||
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false;
|
||||
const nextItems = mergeMessageItem(current, nextItem);
|
||||
const nextItems =
|
||||
listStatus === 'unread' && nextItem.read
|
||||
? current.filter((item) => item.id !== nextItem.id)
|
||||
: mergeMessageItem(current, nextItem);
|
||||
|
||||
if (wasUnread !== nextItem.read) {
|
||||
void refreshNotificationUnreadCount();
|
||||
@@ -149,9 +154,11 @@ export function useNotificationCenterData(drawerOpen: boolean) {
|
||||
}
|
||||
|
||||
void loadMessages();
|
||||
}, [drawerOpen]);
|
||||
}, [drawerOpen, listStatus]);
|
||||
|
||||
return {
|
||||
listStatus,
|
||||
setListStatus,
|
||||
unreadCount,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
|
||||
Reference in New Issue
Block a user