feat: update codex live chat workflow
This commit is contained in:
@@ -1,447 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { chatConnectionGateway, chatGateway } from './chatV2';
|
||||
import {
|
||||
createNotificationMessage,
|
||||
sendClientNotification,
|
||||
shouldFallbackToLocalNotification,
|
||||
showLocalClientNotification,
|
||||
} from './notificationApi';
|
||||
import {
|
||||
getChatClientSessionId,
|
||||
loadStoredChatMessages,
|
||||
persistStoredChatMessages,
|
||||
} from './mainChatPanel';
|
||||
import type { ChatConversationSummary, ChatJobEvent, ChatMessage, ChatViewContext } from './mainChatPanel/types';
|
||||
|
||||
const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function createConversationPreviewText(text: string) {
|
||||
const normalized = String(text ?? '').replace(/\s+/g, ' ').trim();
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
function createChatNotificationBody(text: string, fallback: string) {
|
||||
const preview = createConversationPreviewText(text);
|
||||
return preview || fallback;
|
||||
}
|
||||
|
||||
function createChatQuestionAnswerNotificationBody(args: {
|
||||
questionText?: string | null;
|
||||
answerText?: string | null;
|
||||
fallback: string;
|
||||
}) {
|
||||
const questionPreview = createConversationPreviewText(args.questionText ?? '');
|
||||
const answerPreview = createConversationPreviewText(args.answerText ?? '');
|
||||
|
||||
if (questionPreview && answerPreview) {
|
||||
return `질문: ${questionPreview}\n답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (answerPreview) {
|
||||
return `답변: ${answerPreview}`;
|
||||
}
|
||||
|
||||
if (questionPreview) {
|
||||
return `질문: ${questionPreview}`;
|
||||
}
|
||||
|
||||
return args.fallback;
|
||||
}
|
||||
|
||||
function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) {
|
||||
const questionPreview = createConversationPreviewText(questionText ?? '');
|
||||
return questionPreview ? `질문: ${questionPreview}` : fallback ?? '';
|
||||
}
|
||||
|
||||
function normalizeNotificationDetailText(text?: string | null) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function buildChatNotificationLink(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId || typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`;
|
||||
}
|
||||
|
||||
async function tryShowLocalChatNotification(args: {
|
||||
title: string;
|
||||
body: string;
|
||||
threadId: string;
|
||||
data: Record<string, string>;
|
||||
}) {
|
||||
await showLocalClientNotification({
|
||||
title: args.title,
|
||||
body: args.body,
|
||||
threadId: args.threadId,
|
||||
data: args.data,
|
||||
}).catch(() => false);
|
||||
}
|
||||
|
||||
function findQuestionText(messages: ChatMessage[], requestId?: string | null) {
|
||||
if (!requestId) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return candidate.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function findLatestCodexMessage(messages: ChatMessage[]) {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = messages[index];
|
||||
|
||||
if (candidate.author === 'codex') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ChatNotificationBridge() {
|
||||
const { currentPage, focusedComponentId } = useAppStore();
|
||||
const [sessionId] = useState(() => getChatClientSessionId());
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => loadStoredChatMessages(getChatClientSessionId()));
|
||||
const [conversation, setConversation] = useState<ChatConversationSummary | null>(null);
|
||||
const notifiedIncomingMessageKeysRef = useRef<string[]>([]);
|
||||
const notifiedFailedJobKeysRef = useRef<string[]>([]);
|
||||
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
|
||||
const conversationPollInFlightRef = useRef(false);
|
||||
const messagesRef = useRef(messages);
|
||||
const currentContext: ChatViewContext = useMemo(
|
||||
() => ({
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
persistStoredChatMessages(sessionId, messages);
|
||||
}, [messages, sessionId]);
|
||||
|
||||
const createChatNotification = ({
|
||||
targetSessionId,
|
||||
conversationTitle,
|
||||
title,
|
||||
body,
|
||||
previewText,
|
||||
priority,
|
||||
metadata,
|
||||
}: {
|
||||
targetSessionId: string;
|
||||
conversationTitle?: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
previewText?: string;
|
||||
priority: 'normal' | 'high';
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => {
|
||||
const resolvedConversationTitle = conversationTitle || '현재 채팅방';
|
||||
const linkUrl = buildChatNotificationLink(targetSessionId);
|
||||
const notificationData = {
|
||||
category: 'chat',
|
||||
priority,
|
||||
sessionId: targetSessionId,
|
||||
conversationTitle: resolvedConversationTitle,
|
||||
targetUrl: linkUrl,
|
||||
linkUrl,
|
||||
...metadata,
|
||||
};
|
||||
const serializedNotificationData = Object.fromEntries(
|
||||
Object.entries(notificationData).flatMap(([key, value]) => {
|
||||
if (value == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[key, String(value)]];
|
||||
}),
|
||||
);
|
||||
const pushPayload = {
|
||||
title,
|
||||
body,
|
||||
threadId: `chat:${targetSessionId}`,
|
||||
data: serializedNotificationData,
|
||||
};
|
||||
|
||||
return Promise.allSettled([
|
||||
createNotificationMessage({
|
||||
title,
|
||||
body,
|
||||
category: 'chat',
|
||||
source: 'codex-live',
|
||||
priority,
|
||||
metadata: {
|
||||
...notificationData,
|
||||
previewText,
|
||||
linkLabel: '채팅 바로 열기',
|
||||
},
|
||||
}),
|
||||
sendClientNotification(pushPayload),
|
||||
]).then(async ([storedResult, pushResult]) => {
|
||||
if (pushResult.status === 'rejected') {
|
||||
await tryShowLocalChatNotification(pushPayload);
|
||||
} else if (shouldFallbackToLocalNotification(pushResult.value)) {
|
||||
await tryShowLocalChatNotification(pushPayload);
|
||||
}
|
||||
|
||||
if (storedResult.status === 'fulfilled') {
|
||||
return storedResult.value;
|
||||
}
|
||||
|
||||
if (pushResult.status === 'fulfilled') {
|
||||
return pushResult.value;
|
||||
}
|
||||
|
||||
throw storedResult.reason;
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleIncomingMessageEvent = (incomingMessage: ChatMessage) => {
|
||||
if (incomingMessage.author !== 'codex' || conversation?.notifyOffline !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationKey = `${sessionId}:${incomingMessage.id}:${incomingMessage.timestamp}`;
|
||||
|
||||
if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120);
|
||||
|
||||
const questionText = findQuestionText(messagesRef.current, incomingMessage.clientRequestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: sessionId,
|
||||
conversationTitle: conversation?.title,
|
||||
title: 'Codex Live 새 메시지',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: incomingMessage.text,
|
||||
fallback: createChatNotificationBody(
|
||||
incomingMessage.text,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
createChatNotificationBody(
|
||||
incomingMessage.text,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
),
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
messageId: incomingMessage.id,
|
||||
messageTimestamp: incomingMessage.timestamp,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(incomingMessage.text),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleJobEvent = (event: ChatJobEvent) => {
|
||||
if (event.status !== 'failed' || conversation?.notifyOffline !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationKey = `${sessionId}:${event.requestId}:${event.status}`;
|
||||
|
||||
if (notifiedFailedJobKeysRef.current.includes(notificationKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80);
|
||||
|
||||
const questionText = findQuestionText(messagesRef.current, event.requestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: sessionId,
|
||||
conversationTitle: conversation?.title,
|
||||
title: 'Codex Live 요청 실패',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: event.message,
|
||||
fallback: `${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`,
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
`${conversation?.title || '현재 채팅방'} 채팅방의 요청 처리 중 오류가 발생했습니다.`,
|
||||
),
|
||||
priority: 'high',
|
||||
metadata: {
|
||||
requestId: event.requestId,
|
||||
status: event.status,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(event.message),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const pollConversations = async (seedOnly: boolean) => {
|
||||
if (conversationPollInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
conversationPollInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const items = await chatGateway.listConversations();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConversation(items.find((item) => item.sessionId === sessionId) ?? null);
|
||||
|
||||
const enabledItems = items.filter((item) => item.notifyOffline === true);
|
||||
|
||||
if (enabledItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailResults = await Promise.allSettled(
|
||||
enabledItems.map(async (item) => ({
|
||||
item,
|
||||
detail: await chatGateway.getConversationDetail(item.sessionId),
|
||||
})),
|
||||
);
|
||||
|
||||
for (const result of detailResults) {
|
||||
if (cancelled || result.status !== 'fulfilled') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { item, detail } = result.value;
|
||||
const latestCodexMessage = findLatestCodexMessage(detail.messages);
|
||||
|
||||
if (!latestCodexMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previousMessageId = lastPolledCodexMessageIdBySessionRef.current[item.sessionId];
|
||||
lastPolledCodexMessageIdBySessionRef.current[item.sessionId] = latestCodexMessage.id;
|
||||
|
||||
if (seedOnly || previousMessageId == null || latestCodexMessage.id <= previousMessageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const notificationKey = `${item.sessionId}:${latestCodexMessage.id}:${latestCodexMessage.timestamp}`;
|
||||
|
||||
if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120);
|
||||
|
||||
const questionText = findQuestionText(detail.messages, latestCodexMessage.clientRequestId);
|
||||
|
||||
void createChatNotification({
|
||||
targetSessionId: item.sessionId,
|
||||
conversationTitle: item.title,
|
||||
title: 'Codex Live 새 메시지',
|
||||
body: createChatQuestionAnswerNotificationBody({
|
||||
questionText,
|
||||
answerText: latestCodexMessage.text,
|
||||
fallback: createChatNotificationBody(
|
||||
latestCodexMessage.text,
|
||||
`${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
}),
|
||||
previewText: createChatQuestionOnlyNotificationPreview(
|
||||
questionText,
|
||||
createChatNotificationBody(
|
||||
latestCodexMessage.text,
|
||||
`${item.title || '현재 채팅방'} 채팅방에 새 응답이 도착했습니다.`,
|
||||
),
|
||||
),
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
messageId: latestCodexMessage.id,
|
||||
messageTimestamp: latestCodexMessage.timestamp,
|
||||
questionText: normalizeNotificationDetailText(questionText),
|
||||
answerText: normalizeNotificationDetailText(latestCodexMessage.text),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors and retry on the next cycle.
|
||||
} finally {
|
||||
conversationPollInFlightRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
void pollConversations(true);
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollConversations(false);
|
||||
}, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS);
|
||||
|
||||
const handleResume = () => {
|
||||
void pollConversations(false);
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleResume);
|
||||
window.addEventListener('pageshow', handleResume);
|
||||
document.addEventListener('visibilitychange', handleResume);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener('focus', handleResume);
|
||||
window.removeEventListener('pageshow', handleResume);
|
||||
document.removeEventListener('visibilitychange', handleResume);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
chatConnectionGateway.useConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
onMessageEvent: handleIncomingMessageEvent,
|
||||
onJobEvent: handleJobEvent,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -57,7 +57,10 @@ function buildChatNotificationLink(sessionId: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`;
|
||||
const targetUrl = new URL('/chat/live', window.location.origin);
|
||||
targetUrl.searchParams.set('topMenu', 'chat');
|
||||
targetUrl.searchParams.set('sessionId', normalizedSessionId);
|
||||
return targetUrl.toString();
|
||||
}
|
||||
|
||||
async function tryShowLocalChatNotification(args: {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import {
|
||||
fetchChatRuntimeSnapshot,
|
||||
getChatClientSessionId,
|
||||
setSharedChatRuntimeSnapshot,
|
||||
useChatConnection,
|
||||
} from './mainChatPanel';
|
||||
import type { ChatMessage, ChatViewContext } from './mainChatPanel/types';
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatRuntimeBridge() {
|
||||
const { currentPage, focusedComponentId } = useAppStore();
|
||||
const [sessionId] = useState(() => getChatClientSessionId());
|
||||
const [, setMessages] = useState<ChatMessage[]>([]);
|
||||
|
||||
const currentContext: ChatViewContext = useMemo(
|
||||
() => ({
|
||||
pageId: currentPage.id,
|
||||
pageTitle: currentPage.title,
|
||||
topMenu: currentPage.topMenu,
|
||||
focusedComponentId,
|
||||
pageUrl: typeof window !== 'undefined' ? window.location.href : '',
|
||||
isStandaloneMode: isStandaloneDisplayMode(),
|
||||
pageVisibilityState:
|
||||
typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible',
|
||||
chatTypeId: null,
|
||||
chatTypeLabel: '',
|
||||
chatTypeDescription: '',
|
||||
chatTypeIsTemplate: false,
|
||||
}),
|
||||
[currentPage, focusedComponentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void fetchChatRuntimeSnapshot()
|
||||
.then((snapshot) => {
|
||||
if (!cancelled) {
|
||||
setSharedChatRuntimeSnapshot(snapshot);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useChatConnection({
|
||||
sessionId,
|
||||
currentContext,
|
||||
setMessages,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { fetchPlanActionHistories, fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { fetchServerCommands } from '../../features/serverCommand/api';
|
||||
import type { ServerCommandItem } from '../../features/serverCommand/types';
|
||||
import { chatGateway } from './chatV2';
|
||||
import type { ChatMessage } from './mainChatPanel/types';
|
||||
import type { ChatConversationRequest } from './mainChatPanel/types';
|
||||
import type { ChatMessage, ChatConversationRequest } from './mainChatPanel/types';
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
@@ -11,6 +11,8 @@ type ChatSourceChangeEntry = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
conversationTitle: string;
|
||||
chatTypeId: string | null;
|
||||
chatTypeLabel: string;
|
||||
requestId: string;
|
||||
requestTitle: string;
|
||||
questionText: string;
|
||||
@@ -148,6 +150,12 @@ function extractCurrentSourceFiles(text: string) {
|
||||
...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []),
|
||||
...(text.match(/\[[^\]]*]\((\/api\/chat\/resources\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []),
|
||||
...(text.match(/\[[^\]]*]\((\/?(?:public\/)?\.codex_chat\/[^)\s]+\/resource\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []),
|
||||
]
|
||||
.map((item) => normalizeWorkspaceFilePath(item))
|
||||
.filter((path) => path && isCurrentSourcePath(path));
|
||||
@@ -160,22 +168,12 @@ function extractCurrentSourceFiles(text: string) {
|
||||
}
|
||||
|
||||
function extractChangedFiles(text: string) {
|
||||
const diffPathMatches = Array.from(
|
||||
const matches = Array.from(
|
||||
text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm),
|
||||
)
|
||||
.flatMap((match) => [match[1], match[2], match[3]])
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
const matches = [
|
||||
...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? [])
|
||||
.map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')),
|
||||
...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []),
|
||||
...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []),
|
||||
...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []),
|
||||
...(text.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []),
|
||||
...diffPathMatches,
|
||||
];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
matches
|
||||
@@ -248,29 +246,20 @@ function appendUniqueText(target: string[], value: string | null | undefined) {
|
||||
target.push(normalized);
|
||||
}
|
||||
|
||||
function resolveDeploymentStatus(updatedAt: string, latestReleaseCompletedAt: string | null): DeploymentFilterValue {
|
||||
if (!latestReleaseCompletedAt) {
|
||||
function resolveDeploymentStatus(
|
||||
updatedAt: string,
|
||||
latestTestServerBuiltAt: string | null,
|
||||
testServerCommand: Pick<ServerCommandItem, 'buildRequired' | 'updateAvailable'> | null,
|
||||
): DeploymentFilterValue {
|
||||
if (testServerCommand && !testServerCommand.buildRequired && !testServerCommand.updateAvailable) {
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
if (!latestTestServerBuiltAt) {
|
||||
return 'pre-deploy';
|
||||
}
|
||||
|
||||
return getTimeValue(updatedAt) > getTimeValue(latestReleaseCompletedAt) ? 'pre-deploy' : 'deployed';
|
||||
}
|
||||
|
||||
function isTestReleaseTarget(value: string | null | undefined) {
|
||||
const normalized = String(value ?? '').trim().toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
normalized === 'test' ||
|
||||
normalized.includes('test.sm-home.cloud') ||
|
||||
normalized.includes('/test') ||
|
||||
normalized.startsWith('test-') ||
|
||||
normalized.endsWith('-test') ||
|
||||
normalized.includes(' test ')
|
||||
);
|
||||
return getTimeValue(updatedAt) > getTimeValue(latestTestServerBuiltAt) ? 'pre-deploy' : 'deployed';
|
||||
}
|
||||
|
||||
function resolveSourceChangedAt(
|
||||
@@ -446,10 +435,15 @@ function resolveRequestQuestion(request: ChatConversationRequest, messages: Chat
|
||||
function buildSourceChangeEntry(
|
||||
conversationTitle: string,
|
||||
sessionId: string,
|
||||
conversation: {
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
},
|
||||
request: ChatConversationRequest,
|
||||
messages: ChatMessage[],
|
||||
nextRequest: ChatConversationRequest | undefined,
|
||||
latestReleaseCompletedAt: string | null,
|
||||
testServerCommand: Pick<ServerCommandItem, 'buildRequired' | 'updateAvailable'> | null,
|
||||
latestTestServerBuiltAt: string | null,
|
||||
) {
|
||||
const questionText = resolveRequestQuestion(request, messages);
|
||||
const answerText = resolveRequestExplanation(request, messages, nextRequest);
|
||||
@@ -468,6 +462,8 @@ function buildSourceChangeEntry(
|
||||
id: `${sessionId}:${request.requestId}`,
|
||||
sessionId,
|
||||
conversationTitle,
|
||||
chatTypeId: String(conversation.chatTypeId ?? '').trim() || null,
|
||||
chatTypeLabel: String(conversation.contextLabel ?? '').trim(),
|
||||
requestId: request.requestId,
|
||||
requestTitle: createRequestTitle(questionText, request.requestId),
|
||||
questionText,
|
||||
@@ -480,7 +476,7 @@ function buildSourceChangeEntry(
|
||||
currentSourceFiles,
|
||||
diffBlocks,
|
||||
deploymentStatus: currentSourceFiles.length > 0
|
||||
? resolveDeploymentStatus(sourceChangedAt, latestReleaseCompletedAt)
|
||||
? resolveDeploymentStatus(sourceChangedAt, latestTestServerBuiltAt, testServerCommand)
|
||||
: 'pre-deploy',
|
||||
currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied',
|
||||
} satisfies ChatSourceChangeEntry;
|
||||
@@ -490,9 +486,9 @@ export function ChatSourceChangesPage() {
|
||||
const [entries, setEntries] = useState<ChatSourceChangeEntry[]>([]);
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [deploymentFilter, setDeploymentFilter] = useState<DeploymentFilterValue>('pre-deploy');
|
||||
const [currentSourceFilter, setCurrentSourceFilter] = useState<CurrentSourceFilterValue>('all');
|
||||
const [latestReleaseCompletedAt, setLatestReleaseCompletedAt] = useState<string | null>(null);
|
||||
const [deploymentSearchCondition, setDeploymentSearchCondition] = useState<DeploymentFilterValue>('pre-deploy');
|
||||
const [currentSourceSearchCondition, setCurrentSourceSearchCondition] = useState<CurrentSourceFilterValue>('applied');
|
||||
const [latestTestServerBuiltAt, setLatestTestServerBuiltAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -504,19 +500,9 @@ export function ChatSourceChangesPage() {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const planItems = await fetchPlanItems('all');
|
||||
const testPlanItems = planItems.filter((item) => isTestReleaseTarget(item.releaseTarget));
|
||||
const actionResults = await Promise.allSettled(planItems.map((item) => fetchPlanActionHistories(item.id)));
|
||||
const nextLatestReleaseCompletedAt =
|
||||
actionResults
|
||||
.flatMap((result, index) =>
|
||||
result.status === 'fulfilled' && testPlanItems.some((item) => item.id === planItems[index]?.id)
|
||||
? result.value
|
||||
: [],
|
||||
)
|
||||
.filter((history) => history.actionType === 'release반영완료')
|
||||
.map((history) => history.createdAt)
|
||||
.sort((left, right) => getTimeValue(right) - getTimeValue(left))[0] ?? null;
|
||||
const serverCommands = await fetchServerCommands();
|
||||
const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null;
|
||||
const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null;
|
||||
const conversations = await chatGateway.listConversations();
|
||||
const details = await Promise.allSettled(
|
||||
conversations.map(async (conversation) => ({
|
||||
@@ -541,17 +527,19 @@ export function ChatSourceChangesPage() {
|
||||
buildSourceChangeEntry(
|
||||
conversation.title || '새 대화',
|
||||
conversation.sessionId,
|
||||
detail.item,
|
||||
request,
|
||||
detail.messages,
|
||||
requests[index + 1],
|
||||
nextLatestReleaseCompletedAt,
|
||||
testServerCommand,
|
||||
nextLatestTestServerBuiltAt,
|
||||
),
|
||||
)
|
||||
.filter((item): item is ChatSourceChangeEntry => Boolean(item));
|
||||
})
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
||||
|
||||
setLatestReleaseCompletedAt(nextLatestReleaseCompletedAt);
|
||||
setLatestTestServerBuiltAt(nextLatestTestServerBuiltAt);
|
||||
setEntries(nextEntries);
|
||||
setSelectedEntryId((previous) => {
|
||||
if (previous && nextEntries.some((entry) => entry.id === previous)) {
|
||||
@@ -582,11 +570,11 @@ export function ChatSourceChangesPage() {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
|
||||
return entries.filter((entry) => {
|
||||
if (deploymentFilter !== 'all' && entry.deploymentStatus !== deploymentFilter) {
|
||||
if (deploymentSearchCondition !== 'all' && entry.deploymentStatus !== deploymentSearchCondition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSourceFilter !== 'all' && entry.currentSourceStatus !== currentSourceFilter) {
|
||||
if (currentSourceSearchCondition !== 'all' && entry.currentSourceStatus !== currentSourceSearchCondition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -607,7 +595,7 @@ export function ChatSourceChangesPage() {
|
||||
.toLowerCase()
|
||||
.includes(keyword);
|
||||
});
|
||||
}, [currentSourceFilter, deploymentFilter, entries, searchText]);
|
||||
}, [currentSourceSearchCondition, deploymentSearchCondition, entries, searchText]);
|
||||
|
||||
const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null;
|
||||
|
||||
@@ -619,7 +607,7 @@ export function ChatSourceChangesPage() {
|
||||
Codex Live 변경 이력
|
||||
</Title>
|
||||
<Paragraph className="chat-source-changes-page__copy">
|
||||
채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 test 도메인 배포 반영 여부 기준으로 확인합니다.
|
||||
채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 현재 소스 반영 여부와 test 도메인 배포 상태를 검색조건으로 확인합니다.
|
||||
</Paragraph>
|
||||
<Input
|
||||
value={searchText}
|
||||
@@ -629,24 +617,25 @@ export function ChatSourceChangesPage() {
|
||||
}}
|
||||
/>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text type="secondary">검색조건</Text>
|
||||
<Select
|
||||
value={deploymentFilter}
|
||||
value={deploymentSearchCondition}
|
||||
options={DEPLOYMENT_FILTER_OPTIONS}
|
||||
onChange={(value) => {
|
||||
setDeploymentFilter(value);
|
||||
setDeploymentSearchCondition(value);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={currentSourceFilter}
|
||||
value={currentSourceSearchCondition}
|
||||
options={CURRENT_SOURCE_FILTER_OPTIONS}
|
||||
onChange={(values) => {
|
||||
setCurrentSourceFilter(values);
|
||||
onChange={(value) => {
|
||||
setCurrentSourceSearchCondition(value);
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
{latestReleaseCompletedAt
|
||||
? `최근 test 도메인 배포 완료 시각 기준: ${formatDateTime(latestReleaseCompletedAt)}`
|
||||
: '기록된 test 도메인 배포 완료 이력이 없어 현재 항목은 모두 배포 전으로 표시됩니다.'}
|
||||
{latestTestServerBuiltAt
|
||||
? `현재 TEST 서버 빌드 시각 기준: ${formatDateTime(latestTestServerBuiltAt)}`
|
||||
: 'TEST 서버 빌드 시각을 읽지 못해 현재 항목은 모두 배포 전으로 표시됩니다.'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
@@ -668,7 +657,7 @@ export function ChatSourceChangesPage() {
|
||||
description={
|
||||
entries.length === 0
|
||||
? '소스 수정 흔적이 있는 Codex Live 요청이 아직 없습니다.'
|
||||
: '현재 검색어 또는 필터에 맞는 변경 이력이 없습니다.'
|
||||
: '현재 검색어 또는 검색조건에 맞는 변경 이력이 없습니다.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
canUseChatType,
|
||||
CHAT_PERMISSION_ROLE_LABELS,
|
||||
deleteChatType,
|
||||
resolveCurrentChatPermissionRoles,
|
||||
upsertChatType,
|
||||
useChatTypeRegistry,
|
||||
@@ -49,10 +50,12 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue {
|
||||
|
||||
export function ChatTypeManagementPage() {
|
||||
const { hasAccess } = useTokenAccess();
|
||||
const { chatTypes, setChatTypes } = useChatTypeRegistry();
|
||||
const { chatTypes, setChatTypes, isLoading, errorMessage } = useChatTypeRegistry();
|
||||
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(chatTypes[0]?.id ?? null);
|
||||
const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveErrorMessage, setSaveErrorMessage] = useState('');
|
||||
const [form] = Form.useForm<ChatTypeFormValue>();
|
||||
const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]);
|
||||
|
||||
@@ -104,7 +107,7 @@ export function ChatTypeManagementPage() {
|
||||
setDetailMode('list');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
const handleDelete = async () => {
|
||||
if (!selectedChatType) {
|
||||
return;
|
||||
}
|
||||
@@ -113,13 +116,22 @@ export function ChatTypeManagementPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextChatTypes = chatTypes.filter((item) => item.id !== selectedChatType.id);
|
||||
setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(nextChatTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
const nextChatTypes = deleteChatType(chatTypes, selectedChatType.id);
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
setSelectedChatTypeId(savedChatTypes[0]?.id ?? null);
|
||||
setIsCreating(false);
|
||||
setDetailMode('list');
|
||||
form.resetFields();
|
||||
form.setFieldsValue(EMPTY_FORM_VALUE);
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
@@ -148,9 +160,11 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__list">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>등록 컨텍스트</Title>
|
||||
<Text type="secondary">{chatTypes.length}건</Text>
|
||||
<Text type="secondary">{isLoading ? '불러오는 중' : `${chatTypes.length}건`}</Text>
|
||||
</div>
|
||||
|
||||
{chatTypes.length > 0 ? (
|
||||
@@ -170,13 +184,14 @@ export function ChatTypeManagementPage() {
|
||||
openDetail(item.id);
|
||||
}}
|
||||
actions={[
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
<Button
|
||||
key="edit"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
disabled={isSaving}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openDetail(item.id);
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
@@ -215,7 +230,7 @@ export function ChatTypeManagementPage() {
|
||||
extra={
|
||||
<Space wrap>
|
||||
{!isCreating && selectedChatType ? (
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDelete}>
|
||||
<Button danger icon={<DeleteOutlined />} loading={isSaving} onClick={() => void handleDelete()}>
|
||||
삭제
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -226,6 +241,8 @@ export function ChatTypeManagementPage() {
|
||||
}
|
||||
>
|
||||
<div className="chat-type-management-page__editor">
|
||||
{errorMessage ? <Alert showIcon type="error" message={errorMessage} /> : null}
|
||||
{saveErrorMessage ? <Alert showIcon type="error" message={saveErrorMessage} /> : null}
|
||||
<div className="chat-type-management-page__list-header">
|
||||
<Title level={5}>{isCreating ? '신규 컨텍스트 등록' : selectedChatType?.name ?? '컨텍스트 수정'}</Title>
|
||||
<Text type="secondary">이름과 기본 문맥 설명, 권한 대상만 관리하면 채팅에 그대로 반영됩니다.</Text>
|
||||
@@ -235,14 +252,22 @@ export function ChatTypeManagementPage() {
|
||||
layout="vertical"
|
||||
form={form}
|
||||
initialValues={EMPTY_FORM_VALUE}
|
||||
onFinish={(values) => {
|
||||
onFinish={async (values) => {
|
||||
const nextChatTypes = upsertChatType(chatTypes, values);
|
||||
setChatTypes(nextChatTypes);
|
||||
setIsSaving(true);
|
||||
setSaveErrorMessage('');
|
||||
|
||||
const savedChatType = nextChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
setIsCreating(false);
|
||||
setSelectedChatTypeId(savedChatType?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
try {
|
||||
const savedChatTypes = await setChatTypes(nextChatTypes);
|
||||
const savedChatType = savedChatTypes.find((item) => item.id === values.id || item.name === values.name);
|
||||
setIsCreating(false);
|
||||
setSelectedChatTypeId(savedChatType?.id ?? null);
|
||||
setDetailMode('detail');
|
||||
} catch (error) {
|
||||
setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="id" hidden>
|
||||
@@ -276,10 +301,12 @@ export function ChatTypeManagementPage() {
|
||||
<Switch checkedChildren="사용" unCheckedChildren="중지" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" htmlType="submit">
|
||||
<Button type="primary" htmlType="submit" loading={isSaving}>
|
||||
{isCreating ? '등록' : '수정 저장'}
|
||||
</Button>
|
||||
<Button onClick={openCreateForm}>새 입력</Button>
|
||||
<Button onClick={openCreateForm} disabled={isSaving}>
|
||||
새 입력
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.header-message-center__tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-message-center__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BellOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { Alert, Badge, Button, Drawer, Empty, List, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { Alert, Badge, Button, Drawer, Empty, List, Modal, Segmented, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
type NotificationMessageItem,
|
||||
type NotificationMessageListStatus,
|
||||
type NotificationMessagePriority,
|
||||
} from './notificationApi';
|
||||
import { useNotificationController } from './chatV2/hooks/useNotificationController';
|
||||
@@ -142,6 +143,8 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
|
||||
const [draggingMessageId, setDraggingMessageId] = useState<number | null>(null);
|
||||
const [dragOffsetX, setDragOffsetX] = useState(0);
|
||||
const {
|
||||
listStatus,
|
||||
setListStatus,
|
||||
unreadCount,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
@@ -159,6 +162,11 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
|
||||
handleDeleteMessage: deleteMessage,
|
||||
} = useNotificationController(drawerOpen);
|
||||
|
||||
const listStatusOptions: Array<{ value: NotificationMessageListStatus; label: string }> = [
|
||||
{ value: 'unread', label: `안읽음 ${unreadCount}` },
|
||||
{ value: 'all', label: '전체' },
|
||||
];
|
||||
|
||||
const resetSwipeState = () => {
|
||||
swipeStartXRef.current = null;
|
||||
swipeStartYRef.current = null;
|
||||
@@ -333,10 +341,20 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo
|
||||
>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<div className="header-message-center__summary">
|
||||
<Text strong>미확인 메시지 {unreadCount}건</Text>
|
||||
<Text strong>{listStatus === 'unread' ? `안읽음 메시지 ${unreadCount}건` : '전체 알림 목록'}</Text>
|
||||
<Text type="secondary">최신순으로 최대 30건까지 표시합니다.</Text>
|
||||
</div>
|
||||
|
||||
<Segmented
|
||||
block
|
||||
value={listStatus}
|
||||
options={listStatusOptions}
|
||||
className="header-message-center__tabs"
|
||||
onChange={(value) => {
|
||||
setListStatus(value as NotificationMessageListStatus);
|
||||
}}
|
||||
/>
|
||||
|
||||
{listError ? <Alert type="error" showIcon message={listError} /> : null}
|
||||
|
||||
{listLoading ? (
|
||||
|
||||
@@ -30,6 +30,10 @@
|
||||
|
||||
.app-chat-panel__preview-modal.ant-modal {
|
||||
z-index: 1400;
|
||||
max-width: 100vw;
|
||||
margin: 0;
|
||||
top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal.ant-modal .ant-modal-mask {
|
||||
@@ -99,6 +103,15 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__stack--chat {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -116,8 +129,10 @@
|
||||
.app-chat-panel__conversation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
flex: 0 0 280px;
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
max-width: 280px;
|
||||
min-height: 0;
|
||||
border-right: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
@@ -136,6 +151,19 @@
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper,
|
||||
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:hover,
|
||||
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus,
|
||||
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper:focus-within {
|
||||
border-color: rgba(148, 163, 184, 0.12);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-list-search .ant-input-affix-wrapper {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-list-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -220,7 +248,7 @@
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-radius: 14px;
|
||||
transition:
|
||||
@@ -231,13 +259,13 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--active {
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
border-color: rgba(59, 130, 246, 0.16);
|
||||
background: rgba(239, 246, 255, 0.95);
|
||||
box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08);
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--processing {
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
border-color: rgba(245, 158, 11, 0.14);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%),
|
||||
#fff;
|
||||
@@ -247,7 +275,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--unread {
|
||||
border-color: rgba(37, 99, 235, 0.7);
|
||||
border-color: rgba(37, 99, 235, 0.18);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(147, 197, 253, 0.98), rgba(219, 234, 254, 0.98) 22%, rgba(239, 246, 255, 0.98) 46%, rgba(255, 255, 255, 0.99) 68%),
|
||||
#fff;
|
||||
@@ -257,7 +285,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--unread-section {
|
||||
border-color: rgba(37, 99, 235, 0.82);
|
||||
border-color: rgba(37, 99, 235, 0.2);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%),
|
||||
#fff;
|
||||
@@ -296,7 +324,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread {
|
||||
border-color: rgba(29, 78, 216, 0.78);
|
||||
border-color: rgba(29, 78, 216, 0.2);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(147, 197, 253, 1), rgba(191, 219, 254, 0.99) 24%, rgba(219, 234, 254, 0.99) 46%, rgba(239, 246, 255, 1) 70%),
|
||||
#fff;
|
||||
@@ -528,9 +556,10 @@
|
||||
|
||||
.app-chat-panel__conversation-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
@@ -793,7 +822,7 @@
|
||||
|
||||
.app-chat-panel__title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -1206,6 +1235,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-message-stack--artifact-only {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-chat-message-stack--artifact-only .app-chat-message-stack__previews {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-chat-message--codex {
|
||||
--app-chat-message-fade-end: rgba(248, 251, 255, 0.96);
|
||||
margin-left: 8px;
|
||||
@@ -1325,11 +1362,37 @@
|
||||
max-width: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.app-chat-message__block {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.app-chat-message__block + .app-chat-message__block {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.app-chat-message__block--spacer {
|
||||
min-height: calc(1.45em * 0.7);
|
||||
}
|
||||
|
||||
.app-chat-message__block--image {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-chat-message__inline-image {
|
||||
display: block;
|
||||
width: min(100%, 560px);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.app-chat-message__body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-chat-message__body--collapsed {
|
||||
position: relative;
|
||||
max-height: calc(1.45em * 6);
|
||||
@@ -1370,7 +1433,7 @@
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 8px 10px 10px;
|
||||
padding: 8px 0 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
@@ -1388,13 +1451,18 @@
|
||||
|
||||
.app-chat-message-stack--codex .app-chat-preview-card,
|
||||
.app-chat-message-stack--system .app-chat-preview-card {
|
||||
margin-left: 8px;
|
||||
margin-right: 24px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-chat-message-stack--user .app-chat-preview-card {
|
||||
margin-left: 24px;
|
||||
margin-right: 8px;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-chat-message-stack--artifact-only .app-chat-preview-card {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__header {
|
||||
@@ -1405,6 +1473,13 @@
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--collapsed .app-chat-preview-card__header {
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92));
|
||||
@@ -1429,6 +1504,8 @@
|
||||
margin-top: 1px;
|
||||
color: #475569;
|
||||
background: rgba(226, 232, 240, 0.9);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__titles {
|
||||
@@ -1440,12 +1517,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__label,
|
||||
.app-chat-preview-card__kind,
|
||||
.app-chat-preview-card__label.ant-typography,
|
||||
.app-chat-preview-card__kind.ant-typography {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__label,
|
||||
.app-chat-preview-card__label.ant-typography {
|
||||
font-size: 12px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
@@ -1455,10 +1535,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__kind,
|
||||
.app-chat-preview-card__kind.ant-typography {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #64748b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1471,6 +1553,13 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app-chat-preview-card__action.ant-btn {
|
||||
height: 22px;
|
||||
min-width: 22px;
|
||||
padding: 0;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.app-chat-panel {
|
||||
height: 100%;
|
||||
@@ -1496,6 +1585,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-shell,
|
||||
.app-chat-panel__stack--chat,
|
||||
.app-chat-panel__conversation-main,
|
||||
.app-chat-panel__conversation-view,
|
||||
.app-chat-panel__conversation-view-inner,
|
||||
@@ -1527,9 +1617,15 @@
|
||||
.app-chat-message-stack--codex .app-chat-preview-card,
|
||||
.app-chat-message-stack--system .app-chat-preview-card,
|
||||
.app-chat-message-stack--user .app-chat-preview-card {
|
||||
margin-left: 4px;
|
||||
margin-right: 4px;
|
||||
max-width: calc(100% - 8px);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-message-stack--artifact-only .app-chat-preview-card {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-queue {
|
||||
@@ -1537,6 +1633,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) and (max-width: 1366px) {
|
||||
.app-chat-panel__conversation-list {
|
||||
flex: 0 0 clamp(208px, 19vw, 240px);
|
||||
width: clamp(208px, 19vw, 240px);
|
||||
min-width: clamp(208px, 19vw, 240px);
|
||||
max-width: clamp(208px, 19vw, 240px);
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-main,
|
||||
.app-chat-panel__conversation-view,
|
||||
.app-chat-panel__conversation-view-inner {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.app-chat-preview-card__body {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
@@ -1545,6 +1656,84 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
width: 100vw;
|
||||
min-width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
margin: 0 !important;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: #f8fafc;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.26);
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .app-chat-preview-card__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.96));
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .app-chat-preview-card__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich,
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer,
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list,
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section,
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-body,
|
||||
.app-chat-preview-card--fullscreen .previewer-ui,
|
||||
.app-chat-preview-card--fullscreen .previewer-ui__editor,
|
||||
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-list {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-section {
|
||||
border-width: 0 0 1px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .codex-diff-previewer__diff-toggle {
|
||||
padding-inline: 16px 88px;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .previewer-ui__editor {
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-preview-card--fullscreen .previewer-ui__editor-body {
|
||||
max-height: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -1556,6 +1745,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich .codex-diff-previewer,
|
||||
.app-chat-panel__preview-rich .codex-diff-previewer__diff-body,
|
||||
.app-chat-panel__preview-rich .previewer-ui,
|
||||
.app-chat-panel__preview-rich .previewer-ui__body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich .previewer-ui__editor {
|
||||
border-color: rgba(15, 23, 42, 0.58);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
@@ -1583,6 +1779,10 @@
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich .codex-diff-previewer__diff-list--expand-all .codex-diff-previewer__diff-section {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich--markdown {
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
@@ -1762,7 +1962,7 @@
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
min-height: 88px;
|
||||
padding: 8px 14px 12px;
|
||||
padding: 8px 76px 16px 14px;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea {
|
||||
@@ -1806,6 +2006,37 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-clear.ant-btn {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 2;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
color: rgba(71, 85, 105, 0.88);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-clear.ant-btn:disabled {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__composer-attachment-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1943,6 +2174,7 @@
|
||||
|
||||
.app-chat-panel__preview-stage > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-loading {
|
||||
@@ -1973,6 +2205,10 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-view-inner.is-busy {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1994,6 +2230,34 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-chat-panel__busy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 28px 24px;
|
||||
border-radius: 24px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(147, 197, 253, 0.26), transparent 48%),
|
||||
linear-gradient(180deg, rgba(248, 250, 252, 0.64), rgba(241, 245, 249, 0.74));
|
||||
backdrop-filter: blur(4px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-chat-panel__busy-overlay strong {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.app-chat-panel__busy-overlay span {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-image,
|
||||
.app-chat-panel__preview-video,
|
||||
.app-chat-panel__preview-frame {
|
||||
@@ -2034,13 +2298,50 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .ant-modal-body {
|
||||
padding-top: 12px;
|
||||
padding: 12px 0 0;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal {
|
||||
z-index: 1600;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .ant-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .ant-modal-header {
|
||||
margin-bottom: 0;
|
||||
padding: 16px 20px 12px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .ant-modal-title {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .ant-modal-footer {
|
||||
margin-top: 0;
|
||||
padding: 0 20px 16px;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-stage--modal {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-panel__delete-confirm-modal {
|
||||
z-index: 1700 !important;
|
||||
}
|
||||
@@ -2052,13 +2353,59 @@
|
||||
|
||||
.app-chat-panel__preview-modal-body {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-meta {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-rich,
|
||||
.app-chat-panel__preview-modal .previewer-ui,
|
||||
.app-chat-panel__preview-modal .previewer-ui__editor,
|
||||
.app-chat-panel__preview-modal .previewer-ui__editor-body,
|
||||
.app-chat-panel__preview-modal .codex-diff-previewer,
|
||||
.app-chat-panel__preview-modal .codex-diff-previewer__diff-list,
|
||||
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
|
||||
.app-chat-panel__preview-modal .codex-diff-previewer__diff-body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .previewer-ui__editor,
|
||||
.app-chat-panel__preview-modal .codex-diff-previewer__diff-section,
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-image,
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-video,
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-frame {
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .previewer-ui__editor-body {
|
||||
max-height: none;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-chat-panel__preview-modal-footer {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.app-chat-panel__connection-dot--connecting {
|
||||
@@ -2429,6 +2776,14 @@
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-chat-panel__conversation-list {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.app-chat-runtime {
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -2560,6 +2915,63 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-v2__pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-v2__pane--list {
|
||||
flex: 0 0 320px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.chat-v2__pane--room,
|
||||
.chat-v2__pane--runtime,
|
||||
.chat-v2__pane--errors {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.chat-v2__pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.9);
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
}
|
||||
|
||||
.chat-v2__pane > .ant-input-search,
|
||||
.chat-v2__pane > .ant-input-affix-wrapper,
|
||||
.chat-v2__pane > .ant-input-group-wrapper {
|
||||
margin: 12px 16px 0;
|
||||
}
|
||||
|
||||
.chat-v2__state {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-v2__conversation-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px 10px;
|
||||
}
|
||||
|
||||
.chat-v2__conversation-list .ant-list-item {
|
||||
padding: 0;
|
||||
border-block-end: 0;
|
||||
@@ -2604,6 +3016,13 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.chat-v2__pane--list {
|
||||
flex-basis: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1181px) {
|
||||
.app-chat-panel {
|
||||
background:
|
||||
@@ -2640,18 +3059,19 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item {
|
||||
border-color: rgba(148, 163, 184, 0.14);
|
||||
border-color: transparent;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--active {
|
||||
border-color: rgba(100, 116, 139, 0.26);
|
||||
border-color: transparent;
|
||||
background: rgba(248, 250, 252, 0.98);
|
||||
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--unread {
|
||||
border-color: rgba(148, 163, 184, 0.28);
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%),
|
||||
#fff;
|
||||
@@ -2661,7 +3081,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--unread-section {
|
||||
border-color: rgba(148, 163, 184, 0.32);
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%),
|
||||
#fff;
|
||||
@@ -2679,7 +3099,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread {
|
||||
border-color: rgba(100, 116, 139, 0.34);
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 0.99) 34%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%),
|
||||
#fff;
|
||||
@@ -2689,7 +3109,7 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section {
|
||||
border-color: rgba(100, 116, 139, 0.38);
|
||||
border-color: transparent;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 1) 36%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%),
|
||||
#fff;
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
BellFilled,
|
||||
BellOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
LinkOutlined,
|
||||
MessageOutlined,
|
||||
PaperClipOutlined,
|
||||
PlusOutlined,
|
||||
@@ -23,7 +23,6 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useAppConfig } from './appConfig';
|
||||
import { chatConnectionGateway, chatGateway } from './chatV2';
|
||||
import { emitChatConversationsUpdated } from './chatV2/data/chatClientEvents';
|
||||
import { useConversationComposerController } from './chatV2/hooks/useConversationComposerController';
|
||||
import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController';
|
||||
import { useConversationListController } from './chatV2/hooks/useConversationListController';
|
||||
@@ -33,12 +32,15 @@ import { useConversationViewController } from './chatV2/hooks/useConversationVie
|
||||
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
|
||||
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
|
||||
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
|
||||
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
|
||||
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
|
||||
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
|
||||
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
|
||||
import { useTokenAccess } from './tokenAccess';
|
||||
import {
|
||||
ChatConversationView,
|
||||
ChatRuntimeDashboard,
|
||||
copyPreviewContent,
|
||||
copyText,
|
||||
createActivityLogPlaceholder,
|
||||
createChatMessage,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
getStoredChatSessionLastTypeId,
|
||||
isPreparingChatReplyText,
|
||||
setStoredChatSessionLastTypeId,
|
||||
sortChatConversationSummaries,
|
||||
upsertChatMessage,
|
||||
useErrorLogs,
|
||||
} from './mainChatPanel';
|
||||
@@ -62,6 +65,7 @@ import type {
|
||||
ChatViewContext,
|
||||
MainChatPanelProps,
|
||||
} from './mainChatPanel/types';
|
||||
import { buildChatPath } from './routes';
|
||||
import './MainChatPanel.css';
|
||||
import './MainChatPanel.hotfix.css';
|
||||
|
||||
@@ -209,7 +213,7 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig
|
||||
}
|
||||
|
||||
function getConversationLatestActivityTime(item: ChatConversationSummary) {
|
||||
const latestTimestamp = item.lastMessageAt || item.updatedAt || item.createdAt;
|
||||
const latestTimestamp = item.lastMessageAt || item.createdAt;
|
||||
const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0;
|
||||
|
||||
return Number.isFinite(parsedTime) ? parsedTime : 0;
|
||||
@@ -446,7 +450,7 @@ function extractPreviewItems(messages: ChatMessage[]) {
|
||||
const orderedMessages = [...messages].reverse();
|
||||
|
||||
orderedMessages.forEach((message) => {
|
||||
const matches = message.text.match(urlPattern) ?? [];
|
||||
const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)];
|
||||
|
||||
matches.forEach((matchedUrl) => {
|
||||
const normalizedUrl = normalizePreviewUrl(matchedUrl);
|
||||
@@ -613,6 +617,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
|
||||
const requestedSessionId = getSessionIdFromSearch(location.search);
|
||||
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
|
||||
const requestedChatView = getRequestedChatViewFromSearch(location.search);
|
||||
const [activeSessionId, setActiveSessionId] = useState('');
|
||||
const sessionMessageCacheRef = useRef<Map<string, ChatMessage[]>>(new Map());
|
||||
const [isMobileConversationView, setIsMobileConversationView] = useState(false);
|
||||
@@ -621,15 +626,22 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [activeView, setActiveView] = useState<ChatPanelView>(initialView === 'errors' ? 'errors' : 'chat');
|
||||
const [activeView, setActiveView] = useState<ChatPanelView>(() => {
|
||||
if (requestedChatView) {
|
||||
return requestedChatView;
|
||||
}
|
||||
|
||||
return initialView === 'errors' ? 'errors' : 'chat';
|
||||
});
|
||||
const [copiedMessageId, setCopiedMessageId] = useState<number | null>(null);
|
||||
const [requestItemsState, setRequestItemsState] = useState<ChatConversationRequest[]>([]);
|
||||
const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState<string | null>(null);
|
||||
const [isConversationContentLoading, setIsConversationContentLoading] = useState(true);
|
||||
const [conversationLoadingLabel, setConversationLoadingLabel] = useState('대화 내용을 불러오는 중입니다.');
|
||||
const [conversationRoomReloadKey, setConversationRoomReloadKey] = useState(0);
|
||||
const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||
const [, setOldestLoadedMessageId] = useState<number | null>(null);
|
||||
const [oldestLoadedMessageId, setOldestLoadedMessageId] = useState<number | null>(null);
|
||||
const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
|
||||
@@ -728,19 +740,62 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
};
|
||||
const handleCreateConversation = async () => {
|
||||
const sessionId = createConversationSessionId();
|
||||
const now = new Date().toISOString();
|
||||
const optimisticItem: ChatConversationSummary = {
|
||||
sessionId,
|
||||
clientId: null,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name ?? null,
|
||||
contextDescription: selectedChatType?.description ?? null,
|
||||
notifyOffline: true,
|
||||
hasUnreadResponse: false,
|
||||
currentRequestId: null,
|
||||
currentJobStatus: null,
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
lastMessagePreview: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastMessageAt: null,
|
||||
};
|
||||
|
||||
setConversationItems((previous) =>
|
||||
sortChatConversationSummaries([optimisticItem, ...previous.filter((entry) => entry.sessionId !== sessionId)]),
|
||||
);
|
||||
openConversationSession(sessionId);
|
||||
|
||||
try {
|
||||
const item = await chatGateway.createConversation({
|
||||
sessionId,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name,
|
||||
contextDescription: selectedChatType?.description,
|
||||
notifyOffline: true,
|
||||
});
|
||||
|
||||
setConversationItems((previous) => [item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]);
|
||||
openConversationSession(item.sessionId);
|
||||
setConversationItems((previous) =>
|
||||
sortChatConversationSummaries([item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]),
|
||||
);
|
||||
} catch (error) {
|
||||
const currentUrlSessionId =
|
||||
typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('sessionId')?.trim() ?? '' : '';
|
||||
const shouldResetFailedSession = currentUrlSessionId === sessionId;
|
||||
|
||||
sessionMessageCacheRef.current.delete(sessionId);
|
||||
setConversationItems((previous) => previous.filter((entry) => entry.sessionId !== sessionId));
|
||||
|
||||
if (shouldResetFailedSession) {
|
||||
replaceChatSessionInUrl('');
|
||||
setActiveSessionId((current) => (current === sessionId ? '' : current));
|
||||
setMessages([]);
|
||||
setRequestItems([]);
|
||||
setIsConversationPaneClosed(false);
|
||||
setIsMobileConversationView(!isMobileViewport);
|
||||
}
|
||||
|
||||
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
|
||||
}
|
||||
};
|
||||
@@ -770,16 +825,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
title: buildConversationTitleFromRequestText(text, item.title),
|
||||
lastMessagePreview: nextPreview,
|
||||
lastMessageAt: requestedAt,
|
||||
updatedAt: requestedAt,
|
||||
}
|
||||
: item,
|
||||
sortChatConversationSummaries(
|
||||
previous.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
title: buildConversationTitleFromRequestText(text, item.title),
|
||||
lastMessagePreview: nextPreview,
|
||||
lastMessageAt: requestedAt,
|
||||
updatedAt: requestedAt,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -789,10 +846,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const exists = previous.some((item) => item.sessionId === sessionId);
|
||||
|
||||
if (!exists) {
|
||||
return [detail.item, ...previous];
|
||||
return sortChatConversationSummaries([detail.item, ...previous]);
|
||||
}
|
||||
|
||||
return previous.map((item) => (item.sessionId === sessionId ? detail.item : item));
|
||||
return sortChatConversationSummaries(
|
||||
previous.map((item) => (item.sessionId === sessionId ? detail.item : item)),
|
||||
);
|
||||
});
|
||||
|
||||
sessionMessageCacheRef.current.set(sessionId, detail.messages);
|
||||
@@ -980,37 +1039,39 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const responseTimestamp = new Date().toISOString();
|
||||
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
title:
|
||||
incomingMessage.author === 'user'
|
||||
? buildConversationTitleFromRequestText(incomingMessage.text, item.title)
|
||||
: item.title,
|
||||
lastMessagePreview: createConversationPreviewText(incomingMessage.text),
|
||||
lastMessageAt: responseTimestamp,
|
||||
updatedAt: responseTimestamp,
|
||||
hasUnreadResponse:
|
||||
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
|
||||
currentRequestId:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentRequestId,
|
||||
currentJobStatus:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentJobStatus,
|
||||
currentJobMessage:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentJobMessage,
|
||||
currentQueueSize:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? 0
|
||||
: item.currentQueueSize,
|
||||
}
|
||||
: item,
|
||||
sortChatConversationSummaries(
|
||||
previous.map((item) =>
|
||||
item.sessionId === sessionId
|
||||
? {
|
||||
...item,
|
||||
title:
|
||||
incomingMessage.author === 'user'
|
||||
? buildConversationTitleFromRequestText(incomingMessage.text, item.title)
|
||||
: item.title,
|
||||
lastMessagePreview: createConversationPreviewText(incomingMessage.text),
|
||||
lastMessageAt: responseTimestamp,
|
||||
updatedAt: responseTimestamp,
|
||||
hasUnreadResponse:
|
||||
hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse,
|
||||
currentRequestId:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentRequestId,
|
||||
currentJobStatus:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentJobStatus,
|
||||
currentJobMessage:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? null
|
||||
: item.currentJobMessage,
|
||||
currentQueueSize:
|
||||
incomingMessage.author === 'codex' && incomingMessage.clientRequestId
|
||||
? 0
|
||||
: item.currentQueueSize,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1033,25 +1094,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
};
|
||||
const shouldBlockConversationWhileLoading = useCallback(
|
||||
(sessionId: string) => {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cachedMessages = getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId);
|
||||
|
||||
if (cachedMessages.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const conversation = conversationItemsRef.current.find((item) => item.sessionId === normalizedSessionId) ?? null;
|
||||
return !(conversation?.currentJobStatus === 'queued' || conversation?.currentJobStatus === 'started');
|
||||
},
|
||||
[],
|
||||
);
|
||||
const { socketRef, connectionState } = chatConnectionGateway.useConnection({
|
||||
sessionId: activeSessionId,
|
||||
currentContext,
|
||||
@@ -1167,8 +1209,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
});
|
||||
const { loadOlderMessages } = useConversationRoomController({
|
||||
activeSessionId,
|
||||
oldestLoadedMessageId,
|
||||
reloadKey: conversationRoomReloadKey,
|
||||
connectionState,
|
||||
shouldBlockConversationWhileLoading,
|
||||
captureViewportRestoreSnapshot,
|
||||
sessionMessageCacheRef,
|
||||
messagesRef,
|
||||
@@ -1245,7 +1288,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
previewItems,
|
||||
selectedChatTypeId: selectedChatType?.id ?? null,
|
||||
composerRef,
|
||||
sessionMessageCacheRef,
|
||||
setActiveSystemStatus,
|
||||
setComposerAttachments,
|
||||
setCopiedMessageId,
|
||||
@@ -1254,6 +1296,53 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setIsSystemStatusPending,
|
||||
setMessages,
|
||||
});
|
||||
const openPreviewModal = useCallback(
|
||||
(previewId: string) => {
|
||||
setActivePreviewId(previewId);
|
||||
setIsPreviewModalOpen(true);
|
||||
},
|
||||
[setActivePreviewId, setIsPreviewModalOpen],
|
||||
);
|
||||
const handleCopyActivePreview = useCallback(async () => {
|
||||
if (!activePreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await copyPreviewContent({
|
||||
kind: activePreview.kind,
|
||||
url: activePreview.url,
|
||||
fallbackText: previewText,
|
||||
});
|
||||
|
||||
if (result === 'image') {
|
||||
message.success('이미지를 복사했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === 'url') {
|
||||
message.success('이미지 URL을 복사했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
message.success('복사했습니다.');
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '복사에 실패했습니다.');
|
||||
}
|
||||
}, [activePreview, previewText]);
|
||||
|
||||
const handleDownloadActivePreview = useCallback(() => {
|
||||
if (!activePreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = activePreview.url.split('/').pop()?.trim() || activePreview.label;
|
||||
triggerResourceDownload(activePreview.url, fileName);
|
||||
} catch {
|
||||
void messageApi.error('다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [activePreview, messageApi]);
|
||||
|
||||
const markConversationReadLocally = (sessionId: string) => {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
@@ -1321,7 +1410,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
<span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
|
||||
</span>
|
||||
<span className="app-chat-panel__conversation-item-time">
|
||||
{formatConversationListTimestamp(item.lastMessageAt || item.updatedAt)}
|
||||
{formatConversationListTimestamp(item.lastMessageAt || item.createdAt)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
|
||||
@@ -1360,6 +1449,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
};
|
||||
|
||||
const replaceChatSessionInUrl = (sessionId: string) => {
|
||||
if (location.pathname !== buildChatPath('live')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSessionId = sessionId.trim();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const currentSessionId = searchParams.get('sessionId')?.trim() ?? '';
|
||||
@@ -1388,7 +1481,37 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
);
|
||||
};
|
||||
|
||||
const replaceChatViewInUrl = useCallback(
|
||||
(view: ChatPanelView) => {
|
||||
if (location.pathname !== buildChatPath('live')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
if (view === 'chat') {
|
||||
searchParams.delete('chatView');
|
||||
} else {
|
||||
searchParams.set('chatView', view);
|
||||
}
|
||||
|
||||
const nextSearch = searchParams.toString();
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: nextSearch ? `?${nextSearch}` : '',
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[location.pathname, location.search, navigate],
|
||||
);
|
||||
|
||||
const clearRequestedRuntimeLogInUrl = useCallback(() => {
|
||||
if (location.pathname !== buildChatPath('live')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
|
||||
if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) {
|
||||
@@ -1410,6 +1533,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
const openConversationSession = (sessionId: string) => {
|
||||
replaceChatSessionInUrl(sessionId);
|
||||
const now = new Date().toISOString();
|
||||
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
|
||||
const hasCachedMessages = cachedMessages.length > 0;
|
||||
|
||||
if (sessionId === activeSessionId && !isConversationPaneClosed) {
|
||||
if (isMobileViewport) {
|
||||
@@ -1419,24 +1545,56 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
chatConnectionGateway.resetLastReceivedEventId(sessionId);
|
||||
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
|
||||
setIsConversationContentLoading(shouldBlockConversationWhileLoading(sessionId));
|
||||
setIsConversationContentLoading(true);
|
||||
setIsDeferringAuxiliaryChatRequests(true);
|
||||
setHasOlderMessages(false);
|
||||
setOldestLoadedMessageId(null);
|
||||
setIsLoadingOlderMessages(false);
|
||||
shouldStickToBottomRef.current = true;
|
||||
setShowScrollToBottom(false);
|
||||
setConversationItems((previous) => {
|
||||
const exists = previous.some((item) => item.sessionId === sessionId);
|
||||
|
||||
if (exists) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
clientId: null,
|
||||
title: '대화 내용을 불러오는 중입니다.',
|
||||
chatTypeId: null,
|
||||
contextLabel: null,
|
||||
contextDescription: null,
|
||||
notifyOffline: true,
|
||||
hasUnreadResponse: false,
|
||||
currentRequestId: null,
|
||||
currentJobStatus: null,
|
||||
currentJobMessage: null,
|
||||
currentQueueSize: 0,
|
||||
currentStatusUpdatedAt: null,
|
||||
lastMessagePreview: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastMessageAt: null,
|
||||
},
|
||||
...previous,
|
||||
];
|
||||
});
|
||||
setActiveSessionId(sessionId);
|
||||
if (sessionId === activeSessionId) {
|
||||
setConversationRoomReloadKey((previous) => previous + 1);
|
||||
}
|
||||
setIsConversationPaneClosed(false);
|
||||
setActiveView('chat');
|
||||
|
||||
if (isMobileViewport) {
|
||||
setIsMobileConversationView(true);
|
||||
}
|
||||
|
||||
setMessages(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId));
|
||||
setMessages(hasCachedMessages ? cachedMessages : []);
|
||||
setRequestItems((previous) => previous.filter((item) => item.sessionId === sessionId));
|
||||
setActivePreviewId(null);
|
||||
setIsPreviewModalOpen(false);
|
||||
setActiveSystemStatus(null);
|
||||
@@ -1486,10 +1644,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
conversationItemsRef.current = conversationItems;
|
||||
}, [conversationItems]);
|
||||
|
||||
useEffect(() => {
|
||||
emitChatConversationsUpdated(conversationItems);
|
||||
}, [conversationItems]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
@@ -1507,6 +1661,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isActiveChatSessionInForeground({
|
||||
sessionId: activeSessionId,
|
||||
@@ -1718,7 +1876,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [activeSessionId, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextView = initialView === 'errors' ? 'errors' : 'chat';
|
||||
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
||||
|
||||
if (nextView === 'errors' && !hasAccess) {
|
||||
setActiveView('chat');
|
||||
@@ -1726,7 +1884,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setActiveView(nextView);
|
||||
}, [hasAccess, initialView]);
|
||||
}, [hasAccess, initialView, requestedChatView]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1762,6 +1920,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [activeConversation?.sessionId, activeConversation?.title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.pathname !== buildChatPath('live')) {
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requestedSessionId) {
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
return;
|
||||
@@ -1771,12 +1934,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedConversationExists = conversationItems.some((item) => item.sessionId === requestedSessionId);
|
||||
|
||||
if (!requestedConversationExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) {
|
||||
return;
|
||||
}
|
||||
@@ -1787,14 +1944,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
if (isMobileViewport && !isConversationPaneClosed) {
|
||||
setIsMobileConversationView(true);
|
||||
}
|
||||
if (!isConversationPaneClosed) {
|
||||
setActiveView('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
openConversationSession(requestedSessionId);
|
||||
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, requestedSessionId]);
|
||||
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSessionId) {
|
||||
@@ -1839,7 +1993,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
if (!activeSessionId.trim()) {
|
||||
void chatGateway.listConversations().then((items) => {
|
||||
if (!isCancelled) {
|
||||
setConversationItems(items);
|
||||
setConversationItems(sortChatConversationSummaries(items));
|
||||
}
|
||||
}).catch(() => {
|
||||
// 재연결 직후 목록 재조회 실패는 현재 목록 상태를 유지한다.
|
||||
@@ -1879,13 +2033,23 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
const latestMessage = getLatestConversationPreviewMessage(messages);
|
||||
const nextTitle = buildConversationTitleFromMessages(messages, item.title);
|
||||
const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview;
|
||||
const nextLastMessageAt = latestMessage?.timestamp?.trim() || item.lastMessageAt;
|
||||
|
||||
if (
|
||||
item.title === nextTitle &&
|
||||
item.lastMessagePreview === nextPreview &&
|
||||
item.lastMessageAt === nextLastMessageAt
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
title: buildConversationTitleFromMessages(messages, item.title),
|
||||
title: nextTitle,
|
||||
lastMessagePreview: nextPreview,
|
||||
lastMessageAt: latestMessage ? new Date().toISOString() : item.lastMessageAt,
|
||||
lastMessageAt: nextLastMessageAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -1896,6 +2060,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeView !== 'chat') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isActiveChatSessionInForeground({
|
||||
sessionId: activeConversation.sessionId,
|
||||
@@ -2057,6 +2225,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}`}
|
||||
aria-label="대화 보기"
|
||||
onClick={() => {
|
||||
replaceChatViewInUrl('chat');
|
||||
setActiveView('chat');
|
||||
setIsTitleClusterOpen(false);
|
||||
}}
|
||||
@@ -2070,6 +2239,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}`}
|
||||
aria-label="런타임 보기"
|
||||
onClick={() => {
|
||||
replaceChatViewInUrl('runtime');
|
||||
setActiveView('runtime');
|
||||
setIsTitleClusterOpen(false);
|
||||
}}
|
||||
@@ -2084,6 +2254,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}`}
|
||||
aria-label="에러 로그 보기"
|
||||
onClick={() => {
|
||||
replaceChatViewInUrl('errors');
|
||||
setActiveView('errors');
|
||||
setIsTitleClusterOpen(false);
|
||||
}}
|
||||
@@ -2183,7 +2354,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}`}
|
||||
styles={{ body: { height: '100%' } }}
|
||||
>
|
||||
<div className="app-chat-panel__stack">
|
||||
<div className={`app-chat-panel__stack${activeView === 'chat' ? ' app-chat-panel__stack--chat' : ''}`}>
|
||||
{activeView === 'chat' ? (
|
||||
<>
|
||||
{!isMobileViewport || !isMobileConversationView || isConversationPaneClosed ? (
|
||||
@@ -2298,7 +2469,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
text: item.userText,
|
||||
}))}
|
||||
chatTypeOptions={chatTypeOptions}
|
||||
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, kind: item.kind }))}
|
||||
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
|
||||
isResourceStripOpen={isResourceStripOpen}
|
||||
isComposerDisabled={!selectedChatType}
|
||||
isComposerAttachmentUploading={isComposerAttachmentUploading}
|
||||
@@ -2314,6 +2485,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
onSelectChatType={setSelectedChatTypeId}
|
||||
onSend={handleSend}
|
||||
onSendImmediate={handleSendImmediate}
|
||||
onClearDraft={() => {
|
||||
setDraft('');
|
||||
}}
|
||||
onToggleResourceStrip={() => {
|
||||
setIsResourceStripOpen((current) => !current);
|
||||
}}
|
||||
@@ -2322,10 +2496,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
setShowScrollToBottom(false);
|
||||
scrollViewportToBottom();
|
||||
}}
|
||||
onOpenPreview={(previewId) => {
|
||||
setActivePreviewId(previewId);
|
||||
setIsPreviewModalOpen(true);
|
||||
}}
|
||||
onOpenPreview={openPreviewModal}
|
||||
onCopyMessage={(message) => {
|
||||
void handleCopyMessage(message);
|
||||
}}
|
||||
@@ -2411,8 +2582,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
>
|
||||
<Space direction="vertical" size={8}>
|
||||
<Text>
|
||||
최근 대화가 길어서 이번 요청에는 최근 {pendingContextConfirm?.includedContextCount ?? 0}개만 문맥으로 넣고,
|
||||
이전 {pendingContextConfirm?.omittedContextCount ?? 0}개는 제외합니다.
|
||||
최근 대화가 길어서 이번 요청은 저장된 문맥 제한인 최근 {appConfig.chat.maxContextMessages}개 메시지,
|
||||
최대 {appConfig.chat.maxContextChars}자 안에서만 참조됩니다. 이번 전송에는 최근{' '}
|
||||
{pendingContextConfirm?.includedContextCount ?? 0}개 메시지만 문맥에 포함되고, 이전{' '}
|
||||
{pendingContextConfirm?.omittedContextCount ?? 0}개 메시지는 제외됩니다.
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
전체 맥락이 꼭 필요하면 요청 내용을 더 구체화하거나 새로 정리한 뒤 보내는 편이 안전합니다.
|
||||
@@ -2424,25 +2597,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
title={activePreview ? `${activePreview.label} preview` : 'preview'}
|
||||
footer={
|
||||
activePreview ? (
|
||||
<Space wrap>
|
||||
<Button
|
||||
href={activePreview.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
icon={<LinkOutlined />}
|
||||
>
|
||||
열기
|
||||
</Button>
|
||||
<Button href={activePreview.url} download icon={<DownloadOutlined />}>
|
||||
다운로드
|
||||
</Button>
|
||||
</Space>
|
||||
<div className="app-chat-panel__preview-modal-footer">
|
||||
<Space wrap>
|
||||
<Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
|
||||
</Space>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onCancel={() => {
|
||||
setIsPreviewModalOpen(false);
|
||||
}}
|
||||
width={960}
|
||||
width="100vw"
|
||||
zIndex={1600}
|
||||
className="app-chat-panel__preview-modal"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
BellOutlined,
|
||||
ClockCircleOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FileMarkdownOutlined,
|
||||
LoadingOutlined,
|
||||
MenuFoldOutlined,
|
||||
@@ -23,13 +22,12 @@ import {
|
||||
InputNumber,
|
||||
Layout,
|
||||
Modal,
|
||||
Progress,
|
||||
Select,
|
||||
Segmented,
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { fetchPlanItems } from '../../features/planBoard/api';
|
||||
import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters';
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
type AppConfig,
|
||||
type PlanCostTimeUnit,
|
||||
} from './appConfig';
|
||||
import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate';
|
||||
import {
|
||||
fetchWebPushConfig,
|
||||
registerPwaNotificationToken,
|
||||
@@ -60,6 +57,7 @@ import {
|
||||
getSavedPwaNotificationToken,
|
||||
setSavedPwaNotificationToken,
|
||||
} from './notificationIdentity';
|
||||
import { resetNonAuthClientState } from './appMaintenance';
|
||||
import {
|
||||
ALLOWED_REGISTRATION_TOKEN,
|
||||
setRegisteredAccessToken,
|
||||
@@ -465,19 +463,6 @@ function getSettingsModalTitle(modal: SettingsModalKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAppUpdateStatusLabel(status: AppUpdateStatus) {
|
||||
switch (status) {
|
||||
case 'available':
|
||||
return '업데이트 가능';
|
||||
case 'updating':
|
||||
return '업데이트 중';
|
||||
case 'ready':
|
||||
return '최신 상태';
|
||||
default:
|
||||
return '확인 중';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTimeLabel(value: string | null) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
@@ -490,29 +475,36 @@ function formatDateTimeLabel(value: string | null) {
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
timeZone: 'Asia/Seoul',
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(parsed);
|
||||
}
|
||||
|
||||
function getWorkServerUpdateStatusLabel(item: ServerCommandItem | null) {
|
||||
function getServerVersionStatusClassName(item: ServerCommandItem | null) {
|
||||
if (!item) {
|
||||
return '확인 전';
|
||||
return 'app-header__server-version-indicator--stale';
|
||||
}
|
||||
|
||||
if (item.buildRequired) {
|
||||
return '소스 변경 감지됨';
|
||||
return item.buildRequired || item.updateAvailable
|
||||
? 'app-header__server-version-indicator--stale'
|
||||
: 'app-header__server-version-indicator--latest';
|
||||
}
|
||||
|
||||
function getServerLastSourceChangedDateLabel(item: ServerCommandItem | null) {
|
||||
return formatDateTimeLabel(item?.latestSourceChangeAt ?? null);
|
||||
}
|
||||
|
||||
function getServerVersionStatusTitle(item: ServerCommandItem | null, label: string) {
|
||||
if (!item) {
|
||||
return `${label} 최신 버전 확인 전`;
|
||||
}
|
||||
|
||||
if (item.updateAvailable) {
|
||||
return '새 빌드 대기 중';
|
||||
if (item.buildRequired || item.updateAvailable) {
|
||||
return `${label} 최신 버전 아님`;
|
||||
}
|
||||
|
||||
if (item.availability === 'online') {
|
||||
return '최신 빌드 실행 중';
|
||||
}
|
||||
|
||||
return '상태 확인 필요';
|
||||
return `${label} 최신 버전`;
|
||||
}
|
||||
|
||||
function getClientNotificationPermission(): ClientNotificationPermissionState {
|
||||
@@ -900,14 +892,6 @@ export function MainHeader({
|
||||
);
|
||||
const [webPushConfigured, setWebPushConfigured] = useState(false);
|
||||
const [isStandaloneMode, setIsStandaloneMode] = useState(false);
|
||||
const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus>(() => getAppUpdateSnapshot().status);
|
||||
const [appUpdateSupported, setAppUpdateSupported] = useState<boolean>(() => getAppUpdateSnapshot().supported);
|
||||
const [appUpdateProgressPercent, setAppUpdateProgressPercent] = useState<number | null>(
|
||||
() => getAppUpdateSnapshot().progressPercent,
|
||||
);
|
||||
const [appUpdateCurrentTaskLabel, setAppUpdateCurrentTaskLabel] = useState<string | null>(
|
||||
() => getAppUpdateSnapshot().currentTaskLabel,
|
||||
);
|
||||
const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot());
|
||||
const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(() =>
|
||||
chatConnectionGateway.getSharedRuntimeSnapshot(),
|
||||
@@ -921,14 +905,17 @@ export function MainHeader({
|
||||
const [runtimeLogLoading, setRuntimeLogLoading] = useState(false);
|
||||
const [runtimeLogError, setRuntimeLogError] = useState('');
|
||||
const [runtimeLogDetail, setRuntimeLogDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
const [appUpdateFeedback, setAppUpdateFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [appUpdateCopyFeedback, setAppUpdateCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const previousAppUpdateStatusRef = useRef<AppUpdateStatus>(getAppUpdateSnapshot().status);
|
||||
const [updateCheckFeedback, setUpdateCheckFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [updateCheckCopyFeedback, setUpdateCheckCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [clientResetting, setClientResetting] = useState(false);
|
||||
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
||||
const [workServerRestarting, setWorkServerRestarting] = useState(false);
|
||||
const [workServerUpdateFeedback, setWorkServerUpdateFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [workServerUpdateCopyFeedback, setWorkServerUpdateCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
|
||||
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const { registeredToken, hasAccess } = useTokenAccess();
|
||||
const appConfig = useAppConfig();
|
||||
const [appConfigDraft, setAppConfigDraft] = useState<AppConfig>(appConfig);
|
||||
@@ -946,22 +933,17 @@ export function MainHeader({
|
||||
const notificationStatusClassName = notificationEnabled
|
||||
? 'app-header__status-dot--active'
|
||||
: 'app-header__status-dot--inactive';
|
||||
const appUpdateStatusClassName =
|
||||
appUpdateStatus === 'available'
|
||||
? 'app-header__status-dot--warning'
|
||||
: appUpdateStatus === 'updating'
|
||||
? 'app-header__status-dot--progress'
|
||||
: 'app-header__status-dot--active';
|
||||
const chatConnectionStatusClassName =
|
||||
chatConnection.connectionState === 'connected'
|
||||
? 'app-header__status-dot--active'
|
||||
: chatConnection.connectionState === 'connecting'
|
||||
? 'app-header__status-dot--progress'
|
||||
: 'app-header__status-dot--inactive';
|
||||
const appPendingUpdateCount = appUpdateStatus === 'available' ? 1 : 0;
|
||||
const testServerPendingUpdateCount =
|
||||
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
|
||||
const workServerPendingUpdateCount =
|
||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||
const totalPendingUpdateCount = appPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const settingsStatusClassName =
|
||||
totalPendingUpdateCount >= 2
|
||||
? 'app-header__status-dot--inactive'
|
||||
@@ -1029,12 +1011,12 @@ export function MainHeader({
|
||||
setRuntimeLogLoading(false);
|
||||
}
|
||||
};
|
||||
const canApplyAppUpdate = appUpdateSupported && appUpdateStatus === 'available';
|
||||
const canRefreshWorkServerStatus = hasAccess && !workServerRestarting && !workServerStatusLoading;
|
||||
const canApplyWorkServerUpdate =
|
||||
const canRefreshWorkServerStatus = hasAccess && !workServerStatusLoading && !serverRestartingKey;
|
||||
const canResetClientState = !clientResetting;
|
||||
const canRestartServers =
|
||||
hasAccess &&
|
||||
!workServerRestarting &&
|
||||
!workServerStatusLoading;
|
||||
!workServerStatusLoading &&
|
||||
!serverRestartingKey;
|
||||
const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat);
|
||||
const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual(
|
||||
appConfig.worklogAutomation,
|
||||
@@ -1194,32 +1176,6 @@ export function MainHeader({
|
||||
};
|
||||
}, [isRuntimeModalOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeAppUpdate((nextSnapshot) => {
|
||||
setAppUpdateSupported(nextSnapshot.supported);
|
||||
setAppUpdateStatus(nextSnapshot.status);
|
||||
setAppUpdateProgressPercent(nextSnapshot.progressPercent);
|
||||
setAppUpdateCurrentTaskLabel(nextSnapshot.currentTaskLabel);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousStatus = previousAppUpdateStatusRef.current;
|
||||
|
||||
if (appUpdateStatus === 'available' && previousStatus !== 'available') {
|
||||
setAppUpdateFeedback({
|
||||
tone: 'info',
|
||||
message: import.meta.env.DEV
|
||||
? '개발 서버 변경 사항이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르면 반영됩니다.'
|
||||
: '새 앱 버전이 준비되었습니다. 설정 > 업데이트에서 앱 업데이트 적용을 누르세요.',
|
||||
});
|
||||
} else if (appUpdateStatus !== 'available' && previousStatus === 'available') {
|
||||
setAppUpdateFeedback(null);
|
||||
}
|
||||
|
||||
previousAppUpdateStatusRef.current = appUpdateStatus;
|
||||
}, [appUpdateStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
setTokenInput(registeredToken);
|
||||
}, [registeredToken]);
|
||||
@@ -1248,11 +1204,12 @@ export function MainHeader({
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasAccess) {
|
||||
setTestServerStatus(null);
|
||||
setWorkServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshWorkServerStatus(true);
|
||||
void refreshUpdateTargets(true);
|
||||
}, [hasAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1260,7 +1217,7 @@ export function MainHeader({
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshWorkServerStatus(true);
|
||||
void refreshUpdateTargets(true);
|
||||
}, [activeSettingsModal, hasAccess, settingsModalOpen]);
|
||||
|
||||
const ensureClientNotificationPermission = async () => {
|
||||
@@ -1318,27 +1275,27 @@ export function MainHeader({
|
||||
};
|
||||
|
||||
const syncNotificationEnabled = async (nextEnabled: boolean) => {
|
||||
if (notificationLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousEnabled = notificationEnabled;
|
||||
setNotificationCopyFeedback(null);
|
||||
setNotificationLoading(true);
|
||||
setNotificationEnabled(nextEnabled);
|
||||
|
||||
if (nextEnabled) {
|
||||
setNotificationFeedback({ tone: 'info', message: '알림 권한과 Web Push 등록 상태를 확인하는 중입니다.' });
|
||||
const permissionGranted = await ensureClientNotificationPermission();
|
||||
|
||||
if (!permissionGranted) {
|
||||
setNotificationEnabled(false);
|
||||
setNotificationLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setNotificationLoading(true);
|
||||
|
||||
try {
|
||||
const config = await fetchWebPushConfig();
|
||||
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
|
||||
|
||||
if (!config.enabled || !config.publicKey) {
|
||||
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
|
||||
}
|
||||
|
||||
let registration = await getPushServiceWorkerRegistration();
|
||||
|
||||
if (!registration) {
|
||||
@@ -1407,6 +1364,13 @@ export function MainHeader({
|
||||
}
|
||||
|
||||
if (nextEnabled) {
|
||||
const config = await fetchWebPushConfig();
|
||||
setWebPushConfigured(Boolean(config.enabled && config.publicKey));
|
||||
|
||||
if (!config.enabled || !config.publicKey) {
|
||||
throw new Error('서버 Web Push 설정이 비어 있습니다. VAPID 설정을 먼저 확인해 주세요.');
|
||||
}
|
||||
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey);
|
||||
|
||||
@@ -1442,7 +1406,7 @@ export function MainHeader({
|
||||
setNotificationEnabled(false);
|
||||
setNotificationFeedback({ tone: 'success', message: '이 기기의 웹 푸시 알림을 해제했습니다.' });
|
||||
} catch (error) {
|
||||
setNotificationEnabled(!nextEnabled);
|
||||
setNotificationEnabled(previousEnabled);
|
||||
setNotificationFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '알림 설정 저장에 실패했습니다.',
|
||||
@@ -1515,40 +1479,24 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyAppUpdate = async () => {
|
||||
setAppUpdateCopyFeedback(null);
|
||||
|
||||
if (!appUpdateSupported) {
|
||||
setAppUpdateFeedback({ tone: 'warning', message: '현재 환경에서는 앱 업데이트를 지원하지 않습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (appUpdateStatus === 'updating') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const applied = await applyAppUpdate();
|
||||
|
||||
if (!applied) {
|
||||
setAppUpdateFeedback({ tone: 'info', message: '현재 적용할 앱 업데이트가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAppUpdateFeedback({ tone: 'success', message: '앱 업데이트를 적용합니다.' });
|
||||
} catch (error) {
|
||||
setAppUpdateFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '앱 업데이트 적용에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
const refreshServerStatuses = async () => {
|
||||
const items = await fetchServerCommands();
|
||||
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
|
||||
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
||||
setTestServerStatus(nextTestServerStatus);
|
||||
setWorkServerStatus(nextWorkServerStatus);
|
||||
return {
|
||||
test: nextTestServerStatus,
|
||||
'work-server': nextWorkServerStatus,
|
||||
} satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
|
||||
};
|
||||
|
||||
const refreshWorkServerStatus = async (silent = false) => {
|
||||
const refreshUpdateTargets = async (silent = false) => {
|
||||
if (!hasAccess) {
|
||||
setTestServerStatus(null);
|
||||
setWorkServerStatus(null);
|
||||
if (!silent) {
|
||||
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
||||
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1556,20 +1504,18 @@ export function MainHeader({
|
||||
setWorkServerStatusLoading(true);
|
||||
|
||||
try {
|
||||
const items = await fetchServerCommands();
|
||||
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
||||
setWorkServerStatus(nextWorkServerStatus);
|
||||
const nextStatuses = await refreshServerStatuses();
|
||||
|
||||
if (!silent) {
|
||||
setWorkServerUpdateFeedback(null);
|
||||
setUpdateCheckFeedback(null);
|
||||
}
|
||||
|
||||
return nextWorkServerStatus;
|
||||
return nextStatuses;
|
||||
} catch (error) {
|
||||
if (!silent) {
|
||||
setWorkServerUpdateFeedback({
|
||||
setUpdateCheckFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '워크서버 업데이트 상태를 불러오지 못했습니다.',
|
||||
message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -1578,94 +1524,165 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const waitForWorkServerRestart = async () => {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await waitForDuration(2500);
|
||||
|
||||
try {
|
||||
const nextStatus = await refreshWorkServerStatus(true);
|
||||
const nextStatuses = await refreshServerStatuses();
|
||||
const nextStatus = nextStatuses[key];
|
||||
|
||||
if (!nextStatus) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextStatus.availability === 'online' && !nextStatus.updateAvailable && !nextStatus.buildRequired) {
|
||||
setWorkServerUpdateFeedback({
|
||||
tone: 'success',
|
||||
message: '워크서버가 재시작되었고 최신 빌드가 적용되었습니다.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const restarted =
|
||||
baseline == null ||
|
||||
nextStatus.startedAt !== baseline.startedAt ||
|
||||
nextStatus.checkedAt !== baseline.checkedAt;
|
||||
|
||||
if (nextStatus.availability === 'online' && nextStatus.buildRequired) {
|
||||
setWorkServerUpdateFeedback({
|
||||
tone: 'info',
|
||||
message:
|
||||
nextStatus.updateSummary ?? '워크서버는 재시작되었지만 최신 소스 기준으로 다시 빌드가 필요합니다.',
|
||||
});
|
||||
return;
|
||||
if (nextStatus.availability === 'online' && restarted) {
|
||||
return { ok: true, item: nextStatus };
|
||||
}
|
||||
} catch {
|
||||
// 서버가 재시작 중이면 일시적으로 실패할 수 있어 다음 주기까지 기다립니다.
|
||||
// 서버 재기동 중에는 일시적으로 조회가 실패할 수 있습니다.
|
||||
}
|
||||
}
|
||||
|
||||
setWorkServerUpdateFeedback({
|
||||
tone: 'info',
|
||||
message: '워크서버 재시작 요청은 접수했습니다. 잠시 후 업데이트 상태를 다시 확인해 주세요.',
|
||||
});
|
||||
return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
|
||||
};
|
||||
|
||||
const handleApplyWorkServerUpdate = async () => {
|
||||
setWorkServerUpdateCopyFeedback(null);
|
||||
|
||||
if (!hasAccess) {
|
||||
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 업데이트 적용은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
||||
const handleResetClientState = async () => {
|
||||
if (clientResetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workServerRestarting || workServerStatusLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextStatus = workServerStatus;
|
||||
|
||||
if (!nextStatus || (!nextStatus.updateAvailable && !nextStatus.buildRequired)) {
|
||||
nextStatus = await refreshWorkServerStatus(true);
|
||||
setWorkServerStatus(nextStatus);
|
||||
}
|
||||
|
||||
if (!nextStatus) {
|
||||
setWorkServerUpdateFeedback({ tone: 'warning', message: '워크서버 상태를 먼저 확인해 주세요.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nextStatus.updateAvailable && !nextStatus.buildRequired) {
|
||||
setWorkServerUpdateFeedback({ tone: 'info', message: '현재 적용할 Work 서버 업데이트가 없습니다.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkServerUpdateFeedback(null);
|
||||
setWorkServerRestarting(true);
|
||||
setClientResetCopyFeedback(null);
|
||||
setClientResetFeedback(null);
|
||||
setClientResetting(true);
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand('work-server');
|
||||
setWorkServerStatus(result.item);
|
||||
setWorkServerUpdateFeedback({
|
||||
const result = await resetNonAuthClientState();
|
||||
const changedCount =
|
||||
result.removedLocalStorageKeys.length +
|
||||
result.removedSessionStorageKeys.length +
|
||||
result.removedCacheKeys.length +
|
||||
result.unregisteredServiceWorkerCount;
|
||||
|
||||
setClientResetFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
result.restartState === 'accepted'
|
||||
? '워크서버 재시작 요청을 접수했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.'
|
||||
: '워크서버를 다시 시작했습니다. 최신 빌드 적용 여부를 확인하는 중입니다.',
|
||||
changedCount > 0
|
||||
? `토큰/권한 정보는 유지하고 캐시·스토리지를 초기화했습니다. 변경 ${changedCount}건을 정리한 뒤 화면을 새로고침합니다.`
|
||||
: '토큰/권한 정보는 유지하고 초기화 대상 캐시·스토리지를 확인했으며, 화면을 새로고침합니다.',
|
||||
});
|
||||
await waitForWorkServerRestart();
|
||||
window.setTimeout(() => {
|
||||
window.location.replace(window.location.href);
|
||||
}, 700);
|
||||
} catch (error) {
|
||||
setWorkServerUpdateFeedback({
|
||||
setClientResetFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '워크서버 재시작에 실패했습니다.',
|
||||
message: error instanceof Error ? error.message : '캐시·스토리지 초기화에 실패했습니다.',
|
||||
});
|
||||
} finally {
|
||||
setWorkServerRestarting(false);
|
||||
setClientResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const restartServerWithVerification = async (key: 'test' | 'work-server', busyKey: 'test' | 'work-server' | 'all') => {
|
||||
const baseline = key === 'test' ? testServerStatus : workServerStatus;
|
||||
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
|
||||
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
if (key === 'test') {
|
||||
setTestServerStatus(result.item);
|
||||
} else {
|
||||
setWorkServerStatus(result.item);
|
||||
}
|
||||
|
||||
setServerRestartFeedback({
|
||||
tone: 'info',
|
||||
message:
|
||||
result.restartState === 'accepted'
|
||||
? `${targetLabel} 재기동 요청을 접수했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`
|
||||
: `${targetLabel} 재기동을 실행했습니다. 실제 정상 응답 여부를 확인하는 중입니다.`,
|
||||
});
|
||||
setServerRestartingKey(busyKey);
|
||||
|
||||
const verified = await waitForServerRestart(key, baseline);
|
||||
|
||||
if (!verified.ok || !verified.item || verified.item.availability !== 'online') {
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: `${targetLabel} 재기동 후 정상 응답을 확인하지 못했습니다. 상태를 다시 확인해 주세요.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setServerRestartFeedback({
|
||||
tone: 'success',
|
||||
message: `${targetLabel} 재기동 성공을 확인했습니다. 확인 시각 ${formatDateTimeLabel(verified.item.checkedAt)}`,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleRestartSingleServer = async (key: 'test' | 'work-server') => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setServerRestartCopyFeedback(null);
|
||||
setServerRestartFeedback(null);
|
||||
setServerRestartingKey(key);
|
||||
|
||||
try {
|
||||
return await restartServerWithVerification(key, key);
|
||||
} catch (error) {
|
||||
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setServerRestartingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartBothServers = async () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerRestartCopyFeedback(null);
|
||||
setServerRestartFeedback(null);
|
||||
setServerRestartingKey('all');
|
||||
|
||||
try {
|
||||
const testOk = await restartServerWithVerification('test', 'all');
|
||||
|
||||
if (!testOk) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workServerOk = await restartServerWithVerification('work-server', 'all');
|
||||
|
||||
if (!workServerOk) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerRestartFeedback({
|
||||
tone: 'success',
|
||||
message: 'TEST 서버와 WORK 서버 모두 재기동 성공을 확인했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : '서버 재기동에 실패했습니다.',
|
||||
});
|
||||
} finally {
|
||||
setServerRestartingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2691,11 +2708,8 @@ export function MainHeader({
|
||||
}}
|
||||
>
|
||||
<span className="app-header__settings-icon">
|
||||
{appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />}
|
||||
<span
|
||||
className={`app-header__status-dot ${appUpdateStatusClassName}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ReloadOutlined />
|
||||
<span className={`app-header__status-dot ${settingsStatusClassName}`} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="app-header__settings-label">업데이트</span>
|
||||
</button>
|
||||
@@ -2724,9 +2738,6 @@ export function MainHeader({
|
||||
}
|
||||
onChange={(value) => {
|
||||
onChangeTopMenu(value as 'docs' | 'plans');
|
||||
if (isMobileViewport && sidebarCollapsed) {
|
||||
onToggleSidebar();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
@@ -3116,110 +3127,111 @@ export function MainHeader({
|
||||
) : null}
|
||||
{activeSettingsModal === 'update' ? (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Text strong>앱 업데이트</Text>
|
||||
<Text type="secondary">앱 업데이트 상태: {appUpdateSupported ? getAppUpdateStatusLabel(appUpdateStatus) : '미지원'}</Text>
|
||||
{import.meta.env.DEV ? (
|
||||
<Text type="secondary">
|
||||
개발 모드에서는 실시간 반영 대신 변경 감지 후 수동 업데이트 버튼으로 화면에 반영합니다.
|
||||
</Text>
|
||||
) : null}
|
||||
{renderFeedback(appUpdateFeedback, appUpdateCopyFeedback, setAppUpdateCopyFeedback)}
|
||||
{appUpdateStatus === 'updating' ? (
|
||||
<div className="app-header__update-progress" role="status" aria-live="polite">
|
||||
<div className="app-header__update-progress-copy">
|
||||
<Text strong>업데이트 적용 중</Text>
|
||||
<Text type="secondary">
|
||||
{appUpdateCurrentTaskLabel ?? '새 버전을 내려받고 적용하는 중입니다. 잠시만 기다려 주세요.'}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="app-header__update-progress-task">
|
||||
<Text type="secondary">진행 작업</Text>
|
||||
<Text>{appUpdateCurrentTaskLabel ?? '새 버전 반영 준비'}</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={appUpdateProgressPercent ?? 0}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
status="active"
|
||||
strokeColor="#2563eb"
|
||||
trailColor="rgba(37, 99, 235, 0.12)"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<Text strong>업데이트 확인</Text>
|
||||
<Text type="secondary">
|
||||
테스트
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(testServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(testServerStatus, '테스트')}
|
||||
title={getServerVersionStatusTitle(testServerStatus, '테스트')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
소스 수정일: {getServerLastSourceChangedDateLabel(testServerStatus)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
워크
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(workServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(workServerStatus, '워크')}
|
||||
title={getServerVersionStatusTitle(workServerStatus, '워크')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)}
|
||||
</Text>
|
||||
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
icon={appUpdateStatus === 'updating' ? <ReloadOutlined spin /> : <DownloadOutlined />}
|
||||
disabled={!canApplyAppUpdate && appUpdateStatus !== 'updating'}
|
||||
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={workServerStatusLoading}
|
||||
disabled={!canRefreshWorkServerStatus}
|
||||
onClick={() => {
|
||||
void handleApplyAppUpdate();
|
||||
void refreshUpdateTargets();
|
||||
}}
|
||||
>
|
||||
{appUpdateStatus === 'updating'
|
||||
? '업데이트 진행 중'
|
||||
: appUpdateStatus === 'ready'
|
||||
? '적용할 앱 업데이트 없음'
|
||||
: import.meta.env.DEV
|
||||
? '개발 변경 반영'
|
||||
: '앱 업데이트 적용'}
|
||||
업데이트 확인
|
||||
</Button>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
Work 서버 업데이트
|
||||
캐시 / 스토리지 초기화
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
상태: {getWorkServerUpdateStatusLabel(workServerStatus)}
|
||||
권한, 앱 설정, 푸시/서비스워커 등록은 유지하고 화면 상태와 앱 캐시만 초기화합니다.
|
||||
</Text>
|
||||
{renderFeedback(clientResetFeedback, clientResetCopyFeedback, setClientResetCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
danger
|
||||
icon={clientResetting ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={clientResetting}
|
||||
disabled={!canResetClientState}
|
||||
onClick={() => {
|
||||
void handleResetClientState();
|
||||
}}
|
||||
>
|
||||
{clientResetting ? '초기화 진행 중' : '초기화'}
|
||||
</Button>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
서버 재기동
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
실행 중 빌드: {formatDateTimeLabel(workServerStatus?.runningBuiltAt ?? null)}
|
||||
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
최신 빌드: {formatDateTimeLabel(workServerStatus?.latestBuiltAt ?? null)}
|
||||
워크 마지막 확인: {formatDateTimeLabel(workServerStatus?.checkedAt ?? null)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
최근 소스 변경: {formatDateTimeLabel(workServerStatus?.latestSourceChangeAt ?? null)}
|
||||
</Text>
|
||||
{workServerStatus?.latestSourceChangePath ? (
|
||||
<Text type="secondary">변경 기준 파일: {workServerStatus.latestSourceChangePath}</Text>
|
||||
) : null}
|
||||
{!hasAccess ? (
|
||||
<Text type="warning">워크서버 업데이트 확인과 적용은 권한 토큰 등록 후 사용할 수 있습니다.</Text>
|
||||
<Text type="warning">서버 재기동은 권한 토큰 등록 후 사용할 수 있습니다.</Text>
|
||||
) : null}
|
||||
{workServerStatus?.updateSummary ? <Text type="secondary">{workServerStatus.updateSummary}</Text> : null}
|
||||
{renderFeedback(
|
||||
workServerUpdateFeedback,
|
||||
workServerUpdateCopyFeedback,
|
||||
setWorkServerUpdateCopyFeedback,
|
||||
)}
|
||||
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
||||
<Space direction={screens.xs ? 'vertical' : 'horizontal'} style={{ width: '100%' }}>
|
||||
<Button
|
||||
block={screens.xs}
|
||||
icon={workServerStatusLoading ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={workServerStatusLoading}
|
||||
icon={serverRestartingKey === 'test' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'test'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void refreshWorkServerStatus();
|
||||
void handleRestartSingleServer('test');
|
||||
}}
|
||||
disabled={!canRefreshWorkServerStatus}
|
||||
>
|
||||
업데이트 확인
|
||||
테스트 재기동
|
||||
</Button>
|
||||
<Button
|
||||
block={screens.xs}
|
||||
icon={serverRestartingKey === 'work-server' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'work-server'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void handleRestartSingleServer('work-server');
|
||||
}}
|
||||
>
|
||||
워크 재기동
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
block={screens.xs}
|
||||
icon={workServerRestarting ? <ReloadOutlined spin /> : <DownloadOutlined />}
|
||||
loading={workServerRestarting}
|
||||
icon={serverRestartingKey === 'all' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'all'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={() => {
|
||||
void handleApplyWorkServerUpdate();
|
||||
void handleRestartBothServers();
|
||||
}}
|
||||
disabled={!canApplyWorkServerUpdate}
|
||||
>
|
||||
{workServerRestarting
|
||||
? '워크서버 재시작 중'
|
||||
: workServerStatusLoading
|
||||
? '상태 확인 중'
|
||||
: workServerStatus == null
|
||||
? 'Work 서버 상태 확인 후 적용'
|
||||
: workServerStatus.updateAvailable || workServerStatus.buildRequired
|
||||
? 'Work 서버 업데이트 적용'
|
||||
: '적용할 Work 서버 업데이트 없음'}
|
||||
전체 재기동
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
@@ -214,6 +214,24 @@
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator {
|
||||
display: inline-flex;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator--latest {
|
||||
color: #2563eb;
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.app-header__server-version-indicator--stale {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.app-header__settings-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -344,6 +362,15 @@
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
.app-sider--mobile-inline.ant-layout-sider {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto !important;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.app-sider__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -680,6 +707,11 @@
|
||||
height: calc(100vh - 52px);
|
||||
}
|
||||
|
||||
.app-sider--mobile-inline.ant-layout-sider {
|
||||
position: static;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.app-main-content.ant-layout-content {
|
||||
padding: 0;
|
||||
min-height: calc(100dvh - 52px);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Layout, Menu, Space, Tag, Typography } from 'antd';
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
import type { MainSidebarProps } from './types';
|
||||
|
||||
const { Sider } = Layout;
|
||||
@@ -9,6 +10,7 @@ export function MainSidebar({
|
||||
hasAccess,
|
||||
sidebarCollapsed,
|
||||
isMobileViewport,
|
||||
mobileInline = false,
|
||||
openKeys: controlledOpenKeys,
|
||||
apiMenuItems,
|
||||
docsMenuItems,
|
||||
@@ -30,19 +32,46 @@ export function MainSidebar({
|
||||
onSelectChatMenu,
|
||||
onSelectPlayMenu,
|
||||
}: MainSidebarProps) {
|
||||
const handleMenuRouteSelection = (key: string, keyPath: string[]) => {
|
||||
if (keyPath.includes('docs-group')) {
|
||||
onSelectDocsMenu(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('api-group')) {
|
||||
onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('plan-group') || keyPath.includes('server-group')) {
|
||||
onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('codex-live-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) {
|
||||
onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) {
|
||||
onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']);
|
||||
}
|
||||
};
|
||||
|
||||
const collectItemKeys = (items: ItemType[] | undefined): string[] =>
|
||||
(items ?? []).flatMap((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemKey = typeof item.key === 'string' ? item.key : '';
|
||||
const childKeys = 'children' in item ? collectItemKeys(item.children as ItemType[] | undefined) : [];
|
||||
return itemKey ? [itemKey, ...childKeys] : childKeys;
|
||||
});
|
||||
|
||||
const effectiveTopMenu = !hasAccess ? 'docs' : activeTopMenu;
|
||||
const isDocsGroup = effectiveTopMenu === 'docs' || effectiveTopMenu === 'apis';
|
||||
const visibleOpenKeys = sidebarCollapsed
|
||||
? []
|
||||
: controlledOpenKeys.length
|
||||
? controlledOpenKeys
|
||||
: isDocsGroup
|
||||
? !hasAccess
|
||||
? ['docs-group']
|
||||
: ['docs-group', 'api-group']
|
||||
: effectiveTopMenu === 'play'
|
||||
? ['play-group', 'play-layout-group']
|
||||
: ['plan-group', 'codex-live-group', 'app-log-group', 'chat-manage-group'];
|
||||
const visibleOpenKeys = sidebarCollapsed ? [] : controlledOpenKeys;
|
||||
const selectedKeys =
|
||||
effectiveTopMenu === 'docs'
|
||||
? [selectedDocsMenu]
|
||||
@@ -61,13 +90,28 @@ export function MainSidebar({
|
||||
: effectiveTopMenu === 'play'
|
||||
? [...(playMenuItems ?? [])]
|
||||
: [...(planMenuItems ?? []), ...(chatMenuItems ?? [])];
|
||||
const rootKeys = sidebarItems.flatMap((item) =>
|
||||
item && typeof item === 'object' && 'key' in item && typeof item.key === 'string' ? [item.key] : [],
|
||||
);
|
||||
const childKeyMap = new Map(
|
||||
sidebarItems.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object' || !('key' in item) || typeof item.key !== 'string') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[item.key, new Set(collectItemKeys('children' in item ? (item.children as ItemType[] | undefined) : undefined))]] as const;
|
||||
}),
|
||||
);
|
||||
const isMobileOverlay = isMobileViewport && !mobileInline;
|
||||
|
||||
return (
|
||||
<Sider
|
||||
width={isMobileViewport ? '100%' : 260}
|
||||
collapsed={sidebarCollapsed}
|
||||
collapsedWidth={isMobileViewport ? 0 : 72}
|
||||
className={isMobileViewport ? 'app-sider app-sider--mobile' : 'app-sider'}
|
||||
collapsedWidth={isMobileOverlay ? 0 : 72}
|
||||
className={
|
||||
isMobileOverlay ? 'app-sider app-sider--mobile' : mobileInline ? 'app-sider app-sider--mobile-inline' : 'app-sider'
|
||||
}
|
||||
theme="light"
|
||||
>
|
||||
<div className="app-sider__inner">
|
||||
@@ -80,37 +124,30 @@ export function MainSidebar({
|
||||
|
||||
<Menu
|
||||
mode="inline"
|
||||
triggerSubMenuAction={isMobileViewport ? 'click' : 'hover'}
|
||||
inlineCollapsed={sidebarCollapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
openKeys={visibleOpenKeys}
|
||||
items={sidebarItems}
|
||||
onOpenChange={(keys) => {
|
||||
onOpenKeysChange(keys as string[]);
|
||||
const nextKeys = keys as string[];
|
||||
const rootOpenKeys = nextKeys.filter((key) => rootKeys.includes(key));
|
||||
|
||||
if (rootOpenKeys.length <= 1) {
|
||||
onOpenKeysChange(nextKeys);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeRootKey =
|
||||
rootOpenKeys.find((key) => !visibleOpenKeys.includes(key)) ?? rootOpenKeys[rootOpenKeys.length - 1];
|
||||
const allowedChildKeys = childKeyMap.get(activeRootKey) ?? new Set<string>();
|
||||
onOpenKeysChange(nextKeys.filter((key) => key === activeRootKey || allowedChildKeys.has(key)));
|
||||
}}
|
||||
onClick={({ key, keyPath }) => {
|
||||
if (keyPath.includes('docs-group')) {
|
||||
onSelectDocsMenu(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('api-group')) {
|
||||
onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('plan-group') || keyPath.includes('server-group')) {
|
||||
onSelectPlanMenu(key as MainSidebarProps['selectedPlanMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('codex-live-group') || keyPath.includes('app-log-group') || keyPath.includes('chat-manage-group')) {
|
||||
onSelectChatMenu(key as MainSidebarProps['selectedChatMenu']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPath.includes('play-group') || keyPath.includes('play-layout-group')) {
|
||||
onSelectPlayMenu(key as MainSidebarProps['selectedPlayMenu']);
|
||||
}
|
||||
handleMenuRouteSelection(String(key), keyPath as string[]);
|
||||
}}
|
||||
onSelect={({ key, keyPath }) => {
|
||||
handleMenuRouteSelection(String(key), keyPath as string[]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useSyncExternalStore } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
import { getAutomationNotificationPreferenceTarget } from './notificationIdentity';
|
||||
|
||||
const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
|
||||
export const APP_CONFIG_STORAGE_KEY = 'work-server.app-config';
|
||||
const APP_CONFIG_EVENT = 'work-server:app-config';
|
||||
const APP_CONFIG_API_PATH = '/app-config';
|
||||
const AUTOMATION_NOTIFICATION_PREFERENCE_API_PATH = '/notifications/preferences/automation';
|
||||
|
||||
88
src/app/main/appMaintenance.ts
Normal file
88
src/app/main/appMaintenance.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { CLIENT_ID_STORAGE_KEY } from './clientIdentity';
|
||||
import { NOTIFICATION_DEVICE_ID_STORAGE_KEY, PWA_NOTIFICATION_TOKEN_STORAGE_KEY } from './notificationIdentity';
|
||||
import { TOKEN_ACCESS_STORAGE_KEY } from './tokenAccess';
|
||||
import { APP_CONFIG_STORAGE_KEY } from './appConfig';
|
||||
|
||||
const PRESERVED_LOCAL_STORAGE_KEYS = new Set([
|
||||
APP_CONFIG_STORAGE_KEY,
|
||||
TOKEN_ACCESS_STORAGE_KEY,
|
||||
CLIENT_ID_STORAGE_KEY,
|
||||
NOTIFICATION_DEVICE_ID_STORAGE_KEY,
|
||||
PWA_NOTIFICATION_TOKEN_STORAGE_KEY,
|
||||
]);
|
||||
|
||||
const APP_LOCAL_STORAGE_PREFIXES = ['work-', 'main-chat-panel:', 'gps-layer:', 'ai-code-app:'] as const;
|
||||
const APP_SESSION_STORAGE_PREFIXES = ['work-', 'main-chat-panel:', 'gps-layer:', 'ai-code-app.'] as const;
|
||||
|
||||
export type ClientResetSummary = {
|
||||
removedLocalStorageKeys: string[];
|
||||
removedSessionStorageKeys: string[];
|
||||
removedCacheKeys: string[];
|
||||
unregisteredServiceWorkerCount: number;
|
||||
};
|
||||
|
||||
function collectMatchingStorageKeys(storage: Storage, prefixes: readonly string[], preservedKeys: ReadonlySet<string> = new Set()) {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
|
||||
if (!key || preservedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (prefixes.some((prefix) => key.startsWith(prefix))) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
async function clearBrowserCaches() {
|
||||
if (typeof window === 'undefined' || !('caches' in window)) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKeys = await caches.keys();
|
||||
await Promise.all(cacheKeys.map((cacheKey) => caches.delete(cacheKey).catch(() => false)));
|
||||
return cacheKeys;
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetNonAuthClientState() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
removedLocalStorageKeys: [],
|
||||
removedSessionStorageKeys: [],
|
||||
removedCacheKeys: [],
|
||||
unregisteredServiceWorkerCount: 0,
|
||||
} satisfies ClientResetSummary;
|
||||
}
|
||||
|
||||
const removedLocalStorageKeys = collectMatchingStorageKeys(
|
||||
window.localStorage,
|
||||
APP_LOCAL_STORAGE_PREFIXES,
|
||||
PRESERVED_LOCAL_STORAGE_KEYS,
|
||||
);
|
||||
const removedSessionStorageKeys = collectMatchingStorageKeys(window.sessionStorage, APP_SESSION_STORAGE_PREFIXES);
|
||||
|
||||
removedLocalStorageKeys.forEach((key) => {
|
||||
window.localStorage.removeItem(key);
|
||||
});
|
||||
removedSessionStorageKeys.forEach((key) => {
|
||||
window.sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
const removedCacheKeys = await clearBrowserCaches();
|
||||
|
||||
return {
|
||||
removedLocalStorageKeys,
|
||||
removedSessionStorageKeys,
|
||||
removedCacheKeys,
|
||||
unregisteredServiceWorkerCount: 0,
|
||||
} satisfies ClientResetSummary;
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
export type AppUpdateStatus = 'idle' | 'available' | 'updating' | 'ready';
|
||||
|
||||
export type AppUpdateSnapshot = {
|
||||
@@ -342,27 +340,42 @@ export function initializeAppUpdate() {
|
||||
return;
|
||||
}
|
||||
|
||||
serviceWorkerUpdater = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'available',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegistered() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegisterError() {
|
||||
emit();
|
||||
|
||||
void import('virtual:pwa-register')
|
||||
.then(({ registerSW }) => {
|
||||
serviceWorkerUpdater = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'available',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegistered() {
|
||||
snapshot = {
|
||||
supported: true,
|
||||
status: 'ready',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
onRegisterError() {
|
||||
snapshot = {
|
||||
supported: isAppUpdateSupported(),
|
||||
status: isAppUpdateSupported() ? 'ready' : 'idle',
|
||||
progressPercent: null,
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
snapshot = {
|
||||
supported: isAppUpdateSupported(),
|
||||
status: isAppUpdateSupported() ? 'ready' : 'idle',
|
||||
@@ -370,10 +383,7 @@ export function initializeAppUpdate() {
|
||||
currentTaskLabel: null,
|
||||
};
|
||||
emit();
|
||||
},
|
||||
});
|
||||
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeAppUpdate(listener: Listener) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { appendClientIdHeader } from './clientIdentity';
|
||||
|
||||
export type ChatPermissionRole = 'guest' | 'token-user';
|
||||
|
||||
@@ -21,8 +22,12 @@ export type ChatTypeInput = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
const CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
|
||||
const CHAT_TYPES_API_PATH = '/chat-types';
|
||||
const CHAT_TYPE_SYNC_EVENT = 'work-app:chat-types-changed';
|
||||
const CHAT_TYPE_REQUEST_TIMEOUT_MS = 8000;
|
||||
const LEGACY_CHAT_TYPE_STORAGE_KEY = 'work-app.chat-types';
|
||||
const LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-ids';
|
||||
const LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY = 'work-app.chat-types.deleted-default-ids';
|
||||
|
||||
export const CHAT_PERMISSION_ROLE_LABELS: Record<ChatPermissionRole, string> = {
|
||||
guest: '게스트',
|
||||
@@ -33,11 +38,12 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
|
||||
{
|
||||
id: 'general-request',
|
||||
name: '일반 요청',
|
||||
description: '일반 Codex Live 요청입니다. 현재 로컬 main 작업본 기준으로 바로 확인하고 필요 시 소스를 수정합니다.',
|
||||
description:
|
||||
'현재는 프로젝트 루트 main브랜치에서 직접 수정하세요. 작업 이후 실패된 경우 현재 세션에서 수정된 소스에 라인만 롤백하세요. 리소스 제공은 public/.codex_chat/채팅방ID 아래 해주세요. 대화방 내 내용은 context로 참조해주세요. 브라우저 테스트가 필요시 진행해주세요. 브라우저 캡쳐도 리소스 preview 제공해주세요.',
|
||||
isTemplate: false,
|
||||
permissions: ['token-user'],
|
||||
enabled: true,
|
||||
updatedAt: '2026-04-20T00:00:00.000Z',
|
||||
updatedAt: '2026-04-21T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'api-request-template',
|
||||
@@ -54,6 +60,10 @@ function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
function buildChatTypeNameKey(value: string | null | undefined) {
|
||||
return normalizeText(value).replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR');
|
||||
}
|
||||
|
||||
function normalizePermissions(permissions: ChatPermissionRole[] | null | undefined): ChatPermissionRole[] {
|
||||
const nextPermissions = Array.from(
|
||||
new Set(
|
||||
@@ -87,75 +97,216 @@ function normalizeChatType(record: Partial<ChatTypeRecord>): ChatTypeRecord | nu
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultChatTypes(chatTypes: ChatTypeRecord[]) {
|
||||
const defaultsById = new Map(DEFAULT_CHAT_TYPES.map((item) => [item.id, item]));
|
||||
const merged = chatTypes.map((item) => {
|
||||
const defaultItem = defaultsById.get(item.id);
|
||||
|
||||
if (!defaultItem) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const storedUpdatedAt = Date.parse(item.updatedAt);
|
||||
const defaultUpdatedAt = Date.parse(defaultItem.updatedAt);
|
||||
|
||||
if (Number.isFinite(storedUpdatedAt) && Number.isFinite(defaultUpdatedAt) && storedUpdatedAt >= defaultUpdatedAt) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
...defaultItem,
|
||||
};
|
||||
});
|
||||
|
||||
const existingIds = new Set(merged.map((item) => item.id));
|
||||
const missingDefaults = DEFAULT_CHAT_TYPES.filter((item) => !existingIds.has(item.id));
|
||||
|
||||
return [...merged, ...missingDefaults].sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
function getChatTypeSemanticKey(record: Pick<ChatTypeRecord, 'name' | 'isTemplate'>) {
|
||||
return `${record.isTemplate ? 'template' : 'live'}:${buildChatTypeNameKey(record.name)}`;
|
||||
}
|
||||
|
||||
export function loadChatTypes() {
|
||||
function compareUpdatedAt(left: ChatTypeRecord, right: ChatTypeRecord) {
|
||||
const leftTime = Date.parse(left.updatedAt);
|
||||
const rightTime = Date.parse(right.updatedAt);
|
||||
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function dedupeChatTypes(chatTypes: ChatTypeRecord[]) {
|
||||
const bySemanticKey = new Map<string, ChatTypeRecord>();
|
||||
|
||||
for (const item of chatTypes) {
|
||||
const semanticKey = getChatTypeSemanticKey(item);
|
||||
const current = bySemanticKey.get(semanticKey);
|
||||
|
||||
if (!current) {
|
||||
bySemanticKey.set(semanticKey, item);
|
||||
continue;
|
||||
}
|
||||
|
||||
bySemanticKey.set(semanticKey, compareUpdatedAt(current, item) <= 0 ? item : current);
|
||||
}
|
||||
|
||||
return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
}
|
||||
|
||||
function sanitizeChatTypes(chatTypes: Partial<ChatTypeRecord>[]) {
|
||||
return dedupeChatTypes(
|
||||
chatTypes
|
||||
.map((item) => normalizeChatType(item))
|
||||
.filter((item): item is ChatTypeRecord => Boolean(item)),
|
||||
);
|
||||
}
|
||||
|
||||
function emitChatTypesChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(CHAT_TYPE_SYNC_EVENT));
|
||||
}
|
||||
|
||||
function resolveChatTypesApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
}
|
||||
|
||||
return '/api';
|
||||
}
|
||||
|
||||
function resolveChatTypesFallbackBaseUrl() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackUrl = new URL(window.location.origin);
|
||||
fallbackUrl.port = '3100';
|
||||
fallbackUrl.pathname = '/api';
|
||||
fallbackUrl.search = '';
|
||||
fallbackUrl.hash = '';
|
||||
|
||||
return fallbackUrl.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
const CHAT_TYPES_API_BASE_URL = resolveChatTypesApiBaseUrl();
|
||||
const CHAT_TYPES_FALLBACK_BASE_URL = resolveChatTypesFallbackBaseUrl();
|
||||
|
||||
async function requestChatTypesOnce<T>(baseUrl: string, init?: RequestInit) {
|
||||
const headers = appendClientIdHeader(init?.headers);
|
||||
const hasBody = init?.body !== undefined && init.body !== null;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), CHAT_TYPE_REQUEST_TIMEOUT_MS);
|
||||
|
||||
if (hasBody && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(CHAT_TYPE_STORAGE_KEY);
|
||||
const response = await fetch(`${baseUrl}${CHAT_TYPES_API_PATH}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
cache: init?.cache ?? (init?.method?.toUpperCase() === 'GET' ? 'no-store' : undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || '채팅유형 요청에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestChatTypes<T>(init?: RequestInit) {
|
||||
try {
|
||||
return await requestChatTypesOnce<T>(CHAT_TYPES_API_BASE_URL, init);
|
||||
} catch (error) {
|
||||
const shouldRetryWithFallback =
|
||||
CHAT_TYPES_FALLBACK_BASE_URL &&
|
||||
CHAT_TYPES_FALLBACK_BASE_URL !== CHAT_TYPES_API_BASE_URL &&
|
||||
error instanceof Error &&
|
||||
/404|408|502|Failed to fetch|Load failed|NetworkError|응답이 지연/i.test(error.message);
|
||||
|
||||
if (!shouldRetryWithFallback) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return requestChatTypesOnce<T>(CHAT_TYPES_FALLBACK_BASE_URL, init);
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacyDeletedChatTypeIds() {
|
||||
if (typeof window === 'undefined') {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
try {
|
||||
const rawDeletedIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
||||
const rawLegacyDeletedDefaultIds = window.localStorage.getItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
||||
const deletedIds = [rawDeletedIds, rawLegacyDeletedDefaultIds]
|
||||
.flatMap((raw) => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
})
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(deletedIds);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function readLegacyChatTypes() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<ChatTypeRecord>[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = parsed
|
||||
.map((item) => normalizeChatType(item))
|
||||
.filter((item): item is ChatTypeRecord => Boolean(item));
|
||||
|
||||
const resolved = normalized.length > 0 ? ensureDefaultChatTypes(normalized) : DEFAULT_CHAT_TYPES;
|
||||
|
||||
if (JSON.stringify(resolved) !== JSON.stringify(normalized)) {
|
||||
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(resolved));
|
||||
}
|
||||
|
||||
return resolved;
|
||||
const deletedIds = readLegacyDeletedChatTypeIds();
|
||||
const normalized = sanitizeChatTypes(parsed).filter((item) => !deletedIds.has(item.id));
|
||||
return normalized;
|
||||
} catch {
|
||||
return DEFAULT_CHAT_TYPES;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveChatTypes(chatTypes: ChatTypeRecord[]) {
|
||||
function clearLegacyChatTypeStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(CHAT_TYPE_STORAGE_KEY, JSON.stringify(chatTypes));
|
||||
window.dispatchEvent(new CustomEvent(CHAT_TYPE_SYNC_EVENT));
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_IDS_STORAGE_KEY);
|
||||
window.localStorage.removeItem(LEGACY_CHAT_TYPE_DELETED_DEFAULT_IDS_STORAGE_KEY);
|
||||
}
|
||||
|
||||
async function fetchChatTypesFromServer() {
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] | null }>({
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.chatTypes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(response.chatTypes);
|
||||
}
|
||||
|
||||
async function saveChatTypesToServer(chatTypes: ChatTypeRecord[]) {
|
||||
const resolved = sanitizeChatTypes(chatTypes);
|
||||
const response = await requestChatTypes<{ ok: boolean; chatTypes: Partial<ChatTypeRecord>[] }>({
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ chatTypes: resolved }),
|
||||
});
|
||||
|
||||
emitChatTypesChange();
|
||||
clearLegacyChatTypeStorage();
|
||||
return sanitizeChatTypes(response.chatTypes);
|
||||
}
|
||||
|
||||
export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput) {
|
||||
@@ -170,13 +321,25 @@ export function upsertChatType(chatTypes: ChatTypeRecord[], input: ChatTypeInput
|
||||
});
|
||||
|
||||
if (!nextRecord) {
|
||||
return chatTypes;
|
||||
return sanitizeChatTypes(chatTypes);
|
||||
}
|
||||
|
||||
const nextChatTypes = chatTypes.filter((item) => item.id !== nextRecord.id);
|
||||
const nextSemanticKey = getChatTypeSemanticKey(nextRecord);
|
||||
const nextChatTypes = chatTypes.filter(
|
||||
(item) => item.id !== nextRecord.id && getChatTypeSemanticKey(item) !== nextSemanticKey,
|
||||
);
|
||||
nextChatTypes.push(nextRecord);
|
||||
nextChatTypes.sort((left, right) => left.name.localeCompare(right.name, 'ko-KR'));
|
||||
return nextChatTypes;
|
||||
return sanitizeChatTypes(nextChatTypes);
|
||||
}
|
||||
|
||||
export function deleteChatType(chatTypes: ChatTypeRecord[], chatTypeId: string) {
|
||||
const normalizedId = normalizeText(chatTypeId);
|
||||
|
||||
if (!normalizedId) {
|
||||
return sanitizeChatTypes(chatTypes);
|
||||
}
|
||||
|
||||
return sanitizeChatTypes(chatTypes.filter((item) => item.id !== normalizedId));
|
||||
}
|
||||
|
||||
export function resolveCurrentChatPermissionRoles(hasTokenAccess: boolean): ChatPermissionRole[] {
|
||||
@@ -188,31 +351,68 @@ export function canUseChatType(chatType: ChatTypeRecord, roles: ChatPermissionRo
|
||||
}
|
||||
|
||||
export function useChatTypeRegistry() {
|
||||
const [chatTypes, setChatTypes] = useState<ChatTypeRecord[]>(() => loadChatTypes());
|
||||
const [chatTypes, setChatTypesState] = useState<ChatTypeRecord[]>(DEFAULT_CHAT_TYPES);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
isMountedRef.current = true;
|
||||
|
||||
const syncChatTypes = () => {
|
||||
setChatTypes(loadChatTypes());
|
||||
const syncChatTypes = async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const serverChatTypes = await fetchChatTypesFromServer();
|
||||
let resolvedChatTypes = serverChatTypes;
|
||||
|
||||
if (resolvedChatTypes == null) {
|
||||
const legacyChatTypes = readLegacyChatTypes();
|
||||
resolvedChatTypes = legacyChatTypes ?? DEFAULT_CHAT_TYPES;
|
||||
resolvedChatTypes = await saveChatTypesToServer(resolvedChatTypes);
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolvedChatTypes);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(DEFAULT_CHAT_TYPES);
|
||||
setErrorMessage(error instanceof Error ? error.message : '채팅유형을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', syncChatTypes);
|
||||
window.addEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
|
||||
void syncChatTypes();
|
||||
|
||||
const handleSync = () => {
|
||||
void syncChatTypes();
|
||||
};
|
||||
|
||||
window.addEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncChatTypes);
|
||||
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, syncChatTypes);
|
||||
isMountedRef.current = false;
|
||||
window.removeEventListener(CHAT_TYPE_SYNC_EVENT, handleSync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatTypes,
|
||||
setChatTypes: (nextChatTypes: ChatTypeRecord[]) => {
|
||||
saveChatTypes(nextChatTypes);
|
||||
setChatTypes(nextChatTypes);
|
||||
isLoading,
|
||||
errorMessage,
|
||||
setChatTypes: async (nextChatTypes: ChatTypeRecord[]) => {
|
||||
const resolved = await saveChatTypesToServer(nextChatTypes);
|
||||
if (isMountedRef.current) {
|
||||
setChatTypesState(resolved);
|
||||
setErrorMessage('');
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-
|
||||
import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer';
|
||||
import { useAppStore } from '../../../store';
|
||||
import { useTokenAccess } from '../tokenAccess';
|
||||
import { useAppConfig } from '../appConfig';
|
||||
import { ChatNotificationBridgeV2 } from '../ChatNotificationBridgeV2';
|
||||
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
|
||||
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
||||
import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts';
|
||||
import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils';
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
PLAN_MENU_ANCHOR_IDS,
|
||||
renderSidebarIntro,
|
||||
resolveCurrentPageDescriptor,
|
||||
resolvePlanOpenKeys,
|
||||
resolvePlanQuickFilterMenu,
|
||||
resolvePlayOpenKeys,
|
||||
resolveSavedLayoutIdFromMenuKey,
|
||||
@@ -148,16 +146,37 @@ function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
return !hasAccess && topMenu !== 'docs';
|
||||
}
|
||||
|
||||
function resolveSidebarOpenKeys(topMenu: TopMenuKey, hasAccess: boolean) {
|
||||
function resolveSidebarOpenKeys(
|
||||
topMenu: TopMenuKey,
|
||||
hasAccess: boolean,
|
||||
planMenu: PlanSectionKey,
|
||||
chatMenu: ChatSectionKey,
|
||||
) {
|
||||
if (!hasAccess) {
|
||||
return ['docs-group'];
|
||||
}
|
||||
|
||||
if (topMenu === 'docs' || topMenu === 'apis') {
|
||||
return ['docs-group', 'api-group'];
|
||||
if (topMenu === 'docs') {
|
||||
return ['docs-group'];
|
||||
}
|
||||
|
||||
return topMenu === 'play' ? resolvePlayOpenKeys() : resolvePlanOpenKeys();
|
||||
if (topMenu === 'apis') {
|
||||
return ['api-group'];
|
||||
}
|
||||
|
||||
if (topMenu === 'play') {
|
||||
return resolvePlayOpenKeys();
|
||||
}
|
||||
|
||||
if (topMenu === 'plans') {
|
||||
return planMenu === 'server-command' ? ['server-group'] : ['plan-group'];
|
||||
}
|
||||
|
||||
if (chatMenu === 'errors') {
|
||||
return ['app-log-group'];
|
||||
}
|
||||
|
||||
return chatMenu === 'manage' ? ['chat-manage-group'] : ['codex-live-group'];
|
||||
}
|
||||
|
||||
export function MainLayout() {
|
||||
@@ -173,7 +192,9 @@ export function MainLayout() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [contentExpanded, setContentExpanded] = useState(false);
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
|
||||
resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu),
|
||||
);
|
||||
const [activePlanQuickFilter, setActivePlanQuickFilter] = useState<
|
||||
'working' | 'release-pending-main' | 'automation-failed' | null
|
||||
>(routeState.planMenu === 'release' ? 'release-pending-main' : null);
|
||||
@@ -181,6 +202,10 @@ export function MainLayout() {
|
||||
const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, setSavedLayouts, docFolders } = layoutData;
|
||||
const { chatUnreadCount } = useUnreadCounts();
|
||||
|
||||
useEffect(() => {
|
||||
void syncAppConfigFromServer();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const updateViewport = () => {
|
||||
@@ -196,14 +221,17 @@ export function MainLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobileViewport) {
|
||||
setSidebarCollapsed(true);
|
||||
if (!isMobileViewport) {
|
||||
setSidebarCollapsed(false);
|
||||
return;
|
||||
}
|
||||
}, [isMobileViewport]);
|
||||
|
||||
setSidebarCollapsed(routeState.topMenu !== 'docs');
|
||||
}, [isMobileViewport, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess));
|
||||
}, [hasAccess, routeState.topMenu]);
|
||||
setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu));
|
||||
}, [hasAccess, routeState.chatMenu, routeState.planMenu, routeState.topMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) {
|
||||
@@ -342,6 +370,7 @@ export function MainLayout() {
|
||||
const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]);
|
||||
const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]);
|
||||
const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]);
|
||||
const showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
|
||||
const initialSelectedPlanId = Number(searchParams.get('planId'));
|
||||
const initialSelectedWorkId = searchParams.get('workId');
|
||||
|
||||
@@ -371,7 +400,6 @@ export function MainLayout() {
|
||||
>
|
||||
<Layout className="app-shell app-shell--docs-api">
|
||||
<ChatRuntimeBridgeV2 />
|
||||
<ChatNotificationBridgeV2 />
|
||||
{contentExpanded ? null : (
|
||||
<MainHeader
|
||||
activeTopMenu={routeState.topMenu}
|
||||
@@ -400,12 +428,13 @@ export function MainLayout() {
|
||||
)}
|
||||
|
||||
<Layout>
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed) ? null : (
|
||||
{contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
|
||||
<MainSidebar
|
||||
activeTopMenu={routeState.topMenu}
|
||||
hasAccess={hasAccess}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
isMobileViewport={isMobileViewport}
|
||||
mobileInline={showInlineMobileDocsSidebar}
|
||||
openKeys={sidebarOpenKeys}
|
||||
apiMenuItems={apiMenuItems}
|
||||
docsMenuItems={docsMenuItems}
|
||||
@@ -426,7 +455,7 @@ export function MainLayout() {
|
||||
}}
|
||||
onSelectDocsMenu={(key) => {
|
||||
navigate(buildDocsPath(key));
|
||||
if (isMobileViewport) {
|
||||
if (isMobileViewport && !showInlineMobileDocsSidebar) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
}}
|
||||
@@ -457,7 +486,7 @@ export function MainLayout() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{isMobileViewport && !sidebarCollapsed ? null : (
|
||||
{isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
|
||||
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
||||
<Outlet />
|
||||
</MainContent>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
import { DownloadOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CodeOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
FileMarkdownOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
LinkOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Empty, Space, Spin, Typography } from 'antd';
|
||||
import { InlineImage } from '../../../components/common/InlineImage';
|
||||
import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent';
|
||||
import { CodexDiffBlock } from '../../../components/previewer';
|
||||
import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers';
|
||||
import { triggerResourceDownload } from './downloadUtils';
|
||||
import '../../../components/previewer/PreviewerUI.css';
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
|
||||
export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
|
||||
|
||||
export type ChatPreviewTarget = {
|
||||
label: string;
|
||||
@@ -16,6 +27,47 @@ export type ChatPreviewTarget = {
|
||||
kind: ChatPreviewKind;
|
||||
};
|
||||
|
||||
export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return <PictureOutlined />;
|
||||
case 'video':
|
||||
return <VideoCameraOutlined />;
|
||||
case 'markdown':
|
||||
return <FileMarkdownOutlined />;
|
||||
case 'code':
|
||||
case 'diff':
|
||||
return <CodeOutlined />;
|
||||
case 'document':
|
||||
return <FileTextOutlined />;
|
||||
case 'pdf':
|
||||
return <FilePdfOutlined />;
|
||||
default:
|
||||
return <LinkOutlined />;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) {
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return 'image preview';
|
||||
case 'video':
|
||||
return 'video preview';
|
||||
case 'markdown':
|
||||
return 'markdown preview';
|
||||
case 'code':
|
||||
return 'code preview';
|
||||
case 'diff':
|
||||
return 'diff preview';
|
||||
case 'document':
|
||||
return 'document preview';
|
||||
case 'pdf':
|
||||
return 'pdf preview';
|
||||
default:
|
||||
return 'resource preview';
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePreviewErrorMessage(previewError: string) {
|
||||
const normalized = previewError.trim();
|
||||
|
||||
@@ -247,10 +299,10 @@ export function ChatPreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (target.kind === 'code' || target.kind === 'document') {
|
||||
if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') {
|
||||
const resolvedLanguage = resolveCodeLanguage(target, previewText);
|
||||
|
||||
if (resolvedLanguage === 'diff') {
|
||||
if (target.kind === 'diff' || resolvedLanguage === 'diff') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--code">
|
||||
<CodexDiffBlock
|
||||
@@ -291,12 +343,16 @@ export function ChatPreviewBody({
|
||||
브라우저에서 직접 렌더링하지 않는 형식입니다. 아래 버튼으로 새 탭에서 열거나 바로 다운로드할 수 있습니다.
|
||||
</Paragraph>
|
||||
<Space wrap>
|
||||
<Button href={target.url} target="_blank" rel="noreferrer" icon={<EyeOutlined />}>
|
||||
새 탭 열기
|
||||
</Button>
|
||||
<Button href={target.url} download icon={<DownloadOutlined />}>
|
||||
다운로드
|
||||
</Button>
|
||||
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
|
||||
<Button
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
const fileName = target.url.split('/').pop()?.trim() || target.label;
|
||||
triggerResourceDownload(target.url, fileName);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { appendClientIdHeader, getOrCreateClientId } from '../clientIdentity';
|
||||
import { getRegisteredAccessToken } from '../tokenAccess';
|
||||
import { getRegisteredAccessToken, hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import { reportClientError } from '../errorLogApi';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
@@ -17,28 +17,66 @@ import type {
|
||||
ChatViewContext,
|
||||
} from './types';
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 8000;
|
||||
const CONNECT_TIMEOUT_MS = 20000;
|
||||
const CHAT_SESSION_ID_KEY = 'main-chat-panel:session-id';
|
||||
const CHAT_LAST_EVENT_ID_STORAGE_PREFIX = 'main-chat-panel:last-event-id:';
|
||||
const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
|
||||
const CHAT_SESSION_MESSAGES_STORAGE_PREFIX = 'main-chat-panel:messages:';
|
||||
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
|
||||
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
|
||||
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
|
||||
const KST_TIME_ZONE = 'Asia/Seoul';
|
||||
const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
|
||||
const chatSessionLastTypeMemory = new Map<string, string>();
|
||||
const chatLastEventIdMemory = new Map<string, number>();
|
||||
const chatOfflineNotificationMemory = new Map<string, boolean>();
|
||||
let chatClientSessionIdMemory = '';
|
||||
let localMessageSequence = 0;
|
||||
let cachedChatConversationList: ChatConversationSummary[] | null = null;
|
||||
let cachedChatConversationListAt = 0;
|
||||
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
|
||||
|
||||
export function invalidateChatConversationListCache() {
|
||||
cachedChatConversationList = null;
|
||||
cachedChatConversationListAt = 0;
|
||||
chatConversationListRequestPromise = null;
|
||||
}
|
||||
|
||||
function toConversationSortTime(value: string | null | undefined) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function sortChatConversationSummaries(items: ChatConversationSummary[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const leftTime = Math.max(
|
||||
toConversationSortTime(left.lastMessageAt),
|
||||
toConversationSortTime(left.updatedAt),
|
||||
toConversationSortTime(left.createdAt),
|
||||
);
|
||||
const rightTime = Math.max(
|
||||
toConversationSortTime(right.lastMessageAt),
|
||||
toConversationSortTime(right.updatedAt),
|
||||
toConversationSortTime(right.createdAt),
|
||||
);
|
||||
|
||||
if (rightTime !== leftTime) {
|
||||
return rightTime - leftTime;
|
||||
}
|
||||
|
||||
return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_CONNECTION = {
|
||||
reconnectDelayMs: 3000,
|
||||
reconnectDelayMs: 1500,
|
||||
connectTimeoutMs: CONNECT_TIMEOUT_MS,
|
||||
sessionIdKey: CHAT_SESSION_ID_KEY,
|
||||
lastEventIdStoragePrefix: CHAT_LAST_EVENT_ID_STORAGE_PREFIX,
|
||||
notifyOfflineStoragePrefix: CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX,
|
||||
sessionMessagesStoragePrefix: CHAT_SESSION_MESSAGES_STORAGE_PREFIX,
|
||||
sessionLastTypeStoragePrefix: CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX,
|
||||
introMessage: CHAT_INTRO_MESSAGE,
|
||||
} as const;
|
||||
@@ -49,21 +87,6 @@ function buildNotifyOfflineStorageKey(sessionId: string, clientId?: string | nul
|
||||
return `${CHAT_CONNECTION.notifyOfflineStoragePrefix}${normalizedSessionId}:${normalizedClientId}`;
|
||||
}
|
||||
|
||||
function buildLastEventIdStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.lastEventIdStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function buildSessionMessagesStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.sessionMessagesStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function buildSessionLastChatTypeStorageKey(sessionId: string) {
|
||||
const normalizedSessionId = sessionId.trim() || 'default';
|
||||
return `${CHAT_CONNECTION.sessionLastTypeStoragePrefix}${normalizedSessionId}`;
|
||||
}
|
||||
|
||||
function createBrowserSessionId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
@@ -77,29 +100,10 @@ export function clearStoredChatClientConversationState() {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let index = 0; index < window.localStorage.length; index += 1) {
|
||||
const key = window.localStorage.key(index);
|
||||
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
key === CHAT_CONNECTION.sessionIdKey ||
|
||||
key.startsWith(CHAT_CONNECTION.lastEventIdStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.notifyOfflineStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.sessionMessagesStoragePrefix) ||
|
||||
key.startsWith(CHAT_CONNECTION.sessionLastTypeStoragePrefix)
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach((key) => {
|
||||
window.localStorage.removeItem(key);
|
||||
});
|
||||
chatClientSessionIdMemory = '';
|
||||
chatSessionLastTypeMemory.clear();
|
||||
chatLastEventIdMemory.clear();
|
||||
chatOfflineNotificationMemory.clear();
|
||||
}
|
||||
|
||||
function normalizeChatConversationRequest(item: ChatConversationRequest): ChatConversationRequest {
|
||||
@@ -123,15 +127,12 @@ export function getChatClientSessionId() {
|
||||
return '';
|
||||
}
|
||||
|
||||
const existing = window.localStorage.getItem(CHAT_CONNECTION.sessionIdKey)?.trim();
|
||||
|
||||
if (existing) {
|
||||
return existing;
|
||||
if (chatClientSessionIdMemory) {
|
||||
return chatClientSessionIdMemory;
|
||||
}
|
||||
|
||||
const nextSessionId = createBrowserSessionId();
|
||||
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, nextSessionId);
|
||||
return nextSessionId;
|
||||
chatClientSessionIdMemory = createBrowserSessionId();
|
||||
return chatClientSessionIdMemory;
|
||||
}
|
||||
|
||||
export function setChatClientSessionId(sessionId: string) {
|
||||
@@ -145,7 +146,7 @@ export function setChatClientSessionId(sessionId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(CHAT_CONNECTION.sessionIdKey, normalizedSessionId);
|
||||
chatClientSessionIdMemory = normalizedSessionId;
|
||||
}
|
||||
|
||||
export function getLastReceivedChatEventId(sessionId: string) {
|
||||
@@ -159,9 +160,7 @@ export function getLastReceivedChatEventId(sessionId: string) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildLastEventIdStorageKey(normalizedSessionId));
|
||||
const parsed = raw ? Number(raw) : NaN;
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
return chatLastEventIdMemory.get(normalizedSessionId) ?? 0;
|
||||
}
|
||||
|
||||
export function persistLastReceivedChatEventId(sessionId: string, eventId: number) {
|
||||
@@ -181,7 +180,7 @@ export function persistLastReceivedChatEventId(sessionId: string, eventId: numbe
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildLastEventIdStorageKey(normalizedSessionId), String(eventId));
|
||||
chatLastEventIdMemory.set(normalizedSessionId, eventId);
|
||||
}
|
||||
|
||||
export function resetLastReceivedChatEventId(sessionId: string) {
|
||||
@@ -195,7 +194,7 @@ export function resetLastReceivedChatEventId(sessionId: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildLastEventIdStorageKey(normalizedSessionId));
|
||||
chatLastEventIdMemory.delete(normalizedSessionId);
|
||||
}
|
||||
|
||||
export function getStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
|
||||
@@ -203,13 +202,13 @@ export function getStoredChatOfflineNotificationSetting(sessionId: string, clien
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
const key = buildNotifyOfflineStorageKey(sessionId, clientId);
|
||||
|
||||
if (raw === null) {
|
||||
if (!chatOfflineNotificationMemory.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return raw === 'true';
|
||||
return chatOfflineNotificationMemory.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function setStoredChatOfflineNotificationSetting(sessionId: string, enabled: boolean, clientId?: string | null) {
|
||||
@@ -217,7 +216,7 @@ export function setStoredChatOfflineNotificationSetting(sessionId: string, enabl
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildNotifyOfflineStorageKey(sessionId, clientId), enabled ? 'true' : 'false');
|
||||
chatOfflineNotificationMemory.set(buildNotifyOfflineStorageKey(sessionId, clientId), enabled);
|
||||
}
|
||||
|
||||
export function clearStoredChatOfflineNotificationSetting(sessionId: string, clientId?: string | null) {
|
||||
@@ -225,7 +224,7 @@ export function clearStoredChatOfflineNotificationSetting(sessionId: string, cli
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
chatOfflineNotificationMemory.delete(buildNotifyOfflineStorageKey(sessionId, clientId));
|
||||
}
|
||||
|
||||
function resolveSyncedChatOfflineNotificationSetting(
|
||||
@@ -247,106 +246,31 @@ function resolveSyncedChatOfflineNotificationSetting(
|
||||
return serverValue;
|
||||
}
|
||||
|
||||
export function loadStoredChatMessages(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(buildSessionMessagesStorageKey(normalizedSessionId));
|
||||
|
||||
if (!raw) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as ChatMessage[];
|
||||
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
|
||||
return parsed
|
||||
.filter((message) =>
|
||||
Boolean(message) &&
|
||||
(message.author === 'codex' || message.author === 'system' || message.author === 'user') &&
|
||||
typeof message.text === 'string' &&
|
||||
typeof message.timestamp === 'string' &&
|
||||
typeof message.id === 'number',
|
||||
)
|
||||
.filter((message) => message.author !== 'system' || isActivityLogMessage(message));
|
||||
} catch {
|
||||
return [] as ChatMessage[];
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredChatSessionLastTypeId(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildSessionLastChatTypeStorageKey(normalizedSessionId))?.trim() ?? '';
|
||||
const raw = chatSessionLastTypeMemory.get(normalizedSessionId)?.trim() ?? '';
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
export function setStoredChatSessionLastTypeId(sessionId: string, chatTypeId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
const normalizedChatTypeId = chatTypeId.trim();
|
||||
|
||||
if (!normalizedSessionId || !normalizedChatTypeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
buildSessionLastChatTypeStorageKey(normalizedSessionId),
|
||||
normalizedChatTypeId,
|
||||
);
|
||||
}
|
||||
|
||||
export function persistStoredChatMessages(sessionId: string, messages: ChatMessage[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(
|
||||
buildSessionMessagesStorageKey(normalizedSessionId),
|
||||
JSON.stringify(messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message))),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearStoredChatMessages(sessionId: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
if (!normalizedChatTypeId) {
|
||||
chatSessionLastTypeMemory.delete(normalizedSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
if (!normalizedSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildSessionMessagesStorageKey(normalizedSessionId));
|
||||
chatSessionLastTypeMemory.set(normalizedSessionId, normalizedChatTypeId);
|
||||
}
|
||||
|
||||
export function formatTime(date: Date) {
|
||||
@@ -369,6 +293,20 @@ function createLocalMessageId() {
|
||||
return Date.now() * 1_000 + localMessageSequence;
|
||||
}
|
||||
|
||||
function createRecoveredMessageId(requestId: string, variant: 'user' | 'codex' | 'activity') {
|
||||
const baseId = hashRequestId(requestId) * 10;
|
||||
|
||||
if (variant === 'user') {
|
||||
return -(baseId + 3);
|
||||
}
|
||||
|
||||
if (variant === 'activity') {
|
||||
return -(baseId + 2);
|
||||
}
|
||||
|
||||
return -(baseId + 1);
|
||||
}
|
||||
|
||||
function hashRequestId(value: string) {
|
||||
let hash = 0;
|
||||
|
||||
@@ -606,6 +544,7 @@ export function buildOfflineReply(context: ChatViewContext, input: string) {
|
||||
export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number, clientId?: string) {
|
||||
const configuredBaseUrl = import.meta.env.VITE_WORK_SERVER_URL;
|
||||
const resolvedClientId = clientId || getOrCreateClientId();
|
||||
const accessToken = getRegisteredAccessToken();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
@@ -626,6 +565,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
|
||||
if (resolvedClientId) {
|
||||
normalizedUrl.searchParams.set('clientId', resolvedClientId);
|
||||
}
|
||||
if (accessToken) {
|
||||
normalizedUrl.searchParams.set('accessToken', accessToken);
|
||||
}
|
||||
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
|
||||
normalizedUrl.searchParams.set('lastEventId', String(lastEventId));
|
||||
}
|
||||
@@ -641,6 +583,9 @@ export function resolveChatWebSocketUrl(sessionId?: string, lastEventId?: number
|
||||
if (resolvedClientId) {
|
||||
url.searchParams.set('clientId', resolvedClientId);
|
||||
}
|
||||
if (accessToken) {
|
||||
url.searchParams.set('accessToken', accessToken);
|
||||
}
|
||||
if (Number.isFinite(lastEventId) && (lastEventId ?? 0) > 0) {
|
||||
url.searchParams.set('lastEventId', String(lastEventId));
|
||||
}
|
||||
@@ -736,6 +681,106 @@ export async function copyText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export type PreviewCopyResult = 'text' | 'image' | 'url';
|
||||
|
||||
async function copyImagePreview(url: string): Promise<PreviewCopyResult> {
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`preview 이미지를 가져오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const imageBlob = await response.blob();
|
||||
|
||||
if (!imageBlob.type.startsWith('image/')) {
|
||||
throw new Error('이미지 preview만 이미지 자체를 복사할 수 있습니다.');
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.write && typeof ClipboardItem !== 'undefined') {
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[imageBlob.type]: imageBlob,
|
||||
}),
|
||||
]);
|
||||
return 'image';
|
||||
}
|
||||
|
||||
await copyText(url);
|
||||
return 'url';
|
||||
}
|
||||
|
||||
function canCopyPreviewBody(kind: string | null | undefined) {
|
||||
return !['image', 'video', 'pdf', 'file'].includes(String(kind ?? '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
export async function copyPreviewContent({
|
||||
kind,
|
||||
url,
|
||||
fallbackText,
|
||||
}: {
|
||||
kind: string | null | undefined;
|
||||
url: string;
|
||||
fallbackText?: string | null;
|
||||
}): Promise<PreviewCopyResult> {
|
||||
const normalizedKind = String(kind ?? '').trim().toLowerCase();
|
||||
|
||||
if (normalizedKind === 'image') {
|
||||
return copyImagePreview(url);
|
||||
}
|
||||
|
||||
const previewBody = await resolvePreviewBodyForCopy({
|
||||
kind,
|
||||
url,
|
||||
fallbackText,
|
||||
});
|
||||
await copyText(previewBody);
|
||||
return 'text';
|
||||
}
|
||||
|
||||
export async function resolvePreviewBodyForCopy({
|
||||
kind,
|
||||
url,
|
||||
fallbackText,
|
||||
}: {
|
||||
kind: string | null | undefined;
|
||||
url: string;
|
||||
fallbackText?: string | null;
|
||||
}) {
|
||||
const normalizedFallbackText = String(fallbackText ?? '');
|
||||
|
||||
if (!canCopyPreviewBody(kind)) {
|
||||
throw new Error('이 미리보기는 본문 텍스트를 복사할 수 없습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`preview 본문을 가져오지 못했습니다. (${response.status})`);
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
|
||||
if (bodyText.trim()) {
|
||||
return bodyText;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!normalizedFallbackText.trim()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedFallbackText.trim()) {
|
||||
return normalizedFallbackText;
|
||||
}
|
||||
|
||||
throw new Error('복사할 preview 본문이 없습니다.');
|
||||
}
|
||||
|
||||
function resolveChatApiBaseUrl() {
|
||||
if (import.meta.env.VITE_WORK_SERVER_URL) {
|
||||
return import.meta.env.VITE_WORK_SERVER_URL;
|
||||
@@ -751,6 +796,11 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
if (!hasRegisteredAccessTokenAccess()) {
|
||||
window.clearTimeout(timeoutId);
|
||||
throw new Error('등록된 접근 토큰이 없어 채팅 요청을 보낼 수 없습니다.');
|
||||
}
|
||||
|
||||
if (accessToken && !headers.has('X-Access-Token')) {
|
||||
headers.set('X-Access-Token', accessToken);
|
||||
}
|
||||
@@ -775,17 +825,43 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
throw new Error('채팅 서버 응답이 지연됩니다.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw new Error('채팅 서버 연결에 실패했습니다.');
|
||||
}
|
||||
|
||||
window.clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || '채팅 API 요청에 실패했습니다.');
|
||||
|
||||
if (text.trim()) {
|
||||
try {
|
||||
const payload = JSON.parse(text) as { message?: string };
|
||||
const normalizedMessage = String(payload.message ?? '').trim();
|
||||
|
||||
if (normalizedMessage) {
|
||||
throw new Error(normalizedMessage === 'fetch failed' ? '채팅 서버 연결에 실패했습니다.' : normalizedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('채팅 API 요청에 실패했습니다.');
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
const text = await response.text();
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error('채팅 서버 응답이 비어 있습니다.');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error('채팅 서버 응답을 해석하지 못했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileAsBase64(file: File) {
|
||||
@@ -827,10 +903,12 @@ export async function fetchChatConversations() {
|
||||
const clientId = getOrCreateClientId();
|
||||
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
|
||||
.then((response) => {
|
||||
const items = response.items.map((item) => ({
|
||||
...item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||
}));
|
||||
const items = sortChatConversationSummaries(
|
||||
response.items.map((item) => ({
|
||||
...item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
|
||||
})),
|
||||
);
|
||||
|
||||
cachedChatConversationList = items;
|
||||
cachedChatConversationListAt = Date.now();
|
||||
@@ -864,19 +942,23 @@ export async function fetchChatConversationDetail(
|
||||
const response = await requestChatApi<ChatConversationDetailResponse>(
|
||||
`/conversations/${encodeURIComponent(sessionId)}${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
const normalizedRequests = response.requests.map((item) => normalizeChatConversationRequest(item));
|
||||
const visibleRequestIds = new Set(
|
||||
response.messages
|
||||
.map((message) => message.clientRequestId?.trim() ?? '')
|
||||
.filter(Boolean),
|
||||
);
|
||||
const hydratedMessages = hydrateActivityLogMessages(
|
||||
response.messages,
|
||||
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
|
||||
).filter(
|
||||
(message) => message.author !== 'system' || isActivityLogMessage(message),
|
||||
);
|
||||
const recoveredMessages = buildRecoveredMessagesFromConversationDetail(normalizedRequests, response.activityLogs);
|
||||
|
||||
return {
|
||||
...response,
|
||||
messages: hydrateActivityLogMessages(
|
||||
response.messages,
|
||||
response.activityLogs.filter((item) => visibleRequestIds.has(item.requestId?.trim() ?? '')),
|
||||
).filter(
|
||||
(message) => message.author !== 'system' || isActivityLogMessage(message),
|
||||
),
|
||||
messages: mergeRecoveredChatMessages(recoveredMessages, hydratedMessages),
|
||||
item: {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(
|
||||
@@ -885,7 +967,7 @@ export async function fetchChatConversationDetail(
|
||||
clientId,
|
||||
),
|
||||
},
|
||||
requests: response.requests.map((item) => normalizeChatConversationRequest(item)),
|
||||
requests: normalizedRequests,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -945,6 +1027,7 @@ export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||
export async function createChatConversationRoom(args: {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -956,6 +1039,7 @@ export async function createChatConversationRoom(args: {
|
||||
body: JSON.stringify({
|
||||
sessionId: args.sessionId,
|
||||
title: args.title ?? '새 대화',
|
||||
chatTypeId: args.chatTypeId ?? null,
|
||||
contextLabel: args.contextLabel ?? null,
|
||||
contextDescription: args.contextDescription ?? null,
|
||||
notifyOffline,
|
||||
@@ -963,6 +1047,8 @@ export async function createChatConversationRoom(args: {
|
||||
}),
|
||||
});
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
@@ -980,6 +1066,8 @@ export async function renameChatConversationRoom(sessionId: string, title: strin
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
@@ -987,6 +1075,9 @@ export async function updateChatConversationRoom(
|
||||
sessionId: string,
|
||||
payload: {
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean;
|
||||
},
|
||||
) {
|
||||
@@ -999,6 +1090,8 @@ export async function updateChatConversationRoom(
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return {
|
||||
...response.item,
|
||||
notifyOffline: resolveSyncedChatOfflineNotificationSetting(response.item.sessionId, response.item.notifyOffline, clientId),
|
||||
@@ -1025,6 +1118,8 @@ export async function deleteChatConversationRoom(sessionId: string) {
|
||||
},
|
||||
);
|
||||
|
||||
invalidateChatConversationListCache();
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1116,8 +1211,8 @@ function isSameChatMessage(left: ChatMessage, right: ChatMessage) {
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
left.author === 'user' &&
|
||||
right.author === 'user' &&
|
||||
(left.author === 'user' || left.author === 'codex') &&
|
||||
left.author === right.author &&
|
||||
left.clientRequestId &&
|
||||
right.clientRequestId &&
|
||||
left.clientRequestId === right.clientRequestId,
|
||||
@@ -1133,9 +1228,78 @@ function buildComparableChatMessageKey(message: ChatMessage) {
|
||||
return `user-request:${message.clientRequestId}`;
|
||||
}
|
||||
|
||||
if (message.author === 'codex' && message.clientRequestId) {
|
||||
return `codex-request:${message.clientRequestId}`;
|
||||
}
|
||||
|
||||
return `id:${message.id}`;
|
||||
}
|
||||
|
||||
function getComparableChatMessageTime(message: ChatMessage) {
|
||||
const parsed = Date.parse(String(message.timestamp ?? '').trim());
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function buildRecoveredMessagesFromConversationDetail(
|
||||
requests: ChatConversationRequest[],
|
||||
activityLogs: ChatConversationActivityLog[],
|
||||
) {
|
||||
const nextMessages: ChatMessage[] = [];
|
||||
const activityLogMap = new Map(activityLogs.map((item) => [item.requestId.trim(), item]));
|
||||
|
||||
requests.forEach((request) => {
|
||||
const requestId = request.requestId.trim();
|
||||
|
||||
if (!requestId || request.status === 'removed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const userText = String(request.userText ?? '').trim();
|
||||
const responseText = String(request.responseText ?? '').trim();
|
||||
const activityLog = activityLogMap.get(requestId);
|
||||
|
||||
if (userText) {
|
||||
nextMessages.push({
|
||||
id: request.userMessageId ?? createRecoveredMessageId(requestId, 'user'),
|
||||
author: 'user',
|
||||
text: userText,
|
||||
timestamp: request.createdAt || request.updatedAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
nextMessages.push({
|
||||
id: request.responseMessageId ?? createRecoveredMessageId(requestId, 'codex'),
|
||||
author: 'codex',
|
||||
text: responseText,
|
||||
timestamp: request.answeredAt || request.updatedAt || request.createdAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (activityLog && activityLog.lines.length > 0) {
|
||||
nextMessages.push({
|
||||
id: createRecoveredMessageId(requestId, 'activity'),
|
||||
author: 'system',
|
||||
text: `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${activityLog.lines.join('\n\n')}`,
|
||||
timestamp: request.createdAt || request.updatedAt || activityLog.updatedAt || '',
|
||||
clientRequestId: requestId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return nextMessages.sort((left, right) => {
|
||||
const timeDiff = getComparableChatMessageTime(left) - getComparableChatMessageTime(right);
|
||||
|
||||
if (timeDiff !== 0) {
|
||||
return timeDiff;
|
||||
}
|
||||
|
||||
return left.id - right.id;
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeRecoveredChatMessages(previous: ChatMessage[], incoming: ChatMessage[]) {
|
||||
if (previous.length === 0) {
|
||||
return incoming;
|
||||
|
||||
47
src/app/main/mainChatPanel/downloadUtils.ts
Normal file
47
src/app/main/mainChatPanel/downloadUtils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
function isStandaloneDisplayMode() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(display-mode: standalone)').matches === true ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function isMobileLikeViewport() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.matchMedia?.('(max-width: 1180px)').matches === true ||
|
||||
window.matchMedia?.('(pointer: coarse)').matches === true
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldOpenDownloadInNewWindow() {
|
||||
return isStandaloneDisplayMode() && isMobileLikeViewport();
|
||||
}
|
||||
|
||||
export function triggerResourceDownload(url: string, fileName?: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
if (shouldOpenDownloadInNewWindow()) {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
} else if (typeof fileName === 'string' && fileName.trim()) {
|
||||
link.download = fileName.trim();
|
||||
} else {
|
||||
link.download = '';
|
||||
}
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
@@ -4,7 +4,9 @@ export { ErrorLogViewer } from './ErrorLogViewer';
|
||||
export {
|
||||
buildOfflineReply,
|
||||
clearStoredChatClientConversationState,
|
||||
copyPreviewContent,
|
||||
copyText,
|
||||
resolvePreviewBodyForCopy,
|
||||
createActivityLogPlaceholder,
|
||||
createChatConversationRoom,
|
||||
createChatMessage,
|
||||
@@ -20,15 +22,14 @@ export {
|
||||
getStoredChatSessionLastTypeId,
|
||||
isPreparingChatReplyText,
|
||||
getChatClientSessionId,
|
||||
loadStoredChatMessages,
|
||||
markChatConversationResponsesRead,
|
||||
mergeRecoveredChatMessages,
|
||||
persistStoredChatMessages,
|
||||
renameChatConversationRoom,
|
||||
removeChatRuntimeJob,
|
||||
resetLastReceivedChatEventId,
|
||||
setStoredChatSessionLastTypeId,
|
||||
setChatClientSessionId,
|
||||
sortChatConversationSummaries,
|
||||
uploadChatComposerFile,
|
||||
upsertChatMessage,
|
||||
updateChatConversationRoom,
|
||||
|
||||
14
src/app/main/mainChatPanel/previewMarkers.ts
Normal file
14
src/app/main/mainChatPanel/previewMarkers.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const HIDDEN_PREVIEW_TAG_PATTERN = /\[\[preview:([^\]\n]+)\]\]/g;
|
||||
|
||||
export function extractHiddenPreviewUrls(text: string) {
|
||||
return Array.from(String(text ?? '').matchAll(HIDDEN_PREVIEW_TAG_PATTERN))
|
||||
.map((match) => match[1]?.trim())
|
||||
.filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
export function stripHiddenPreviewTags(text: string) {
|
||||
return String(text ?? '')
|
||||
.replace(HIDDEN_PREVIEW_TAG_PATTERN, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export type ChatConversationSummary = {
|
||||
sessionId: string;
|
||||
clientId: string | null;
|
||||
title: string;
|
||||
chatTypeId: string | null;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
persistLastReceivedChatEventId,
|
||||
resolveChatWebSocketUrl,
|
||||
} from './chatUtils';
|
||||
import { hasRegisteredAccessTokenAccess } from '../tokenAccess';
|
||||
import type {
|
||||
ChatActivityEvent,
|
||||
ChatJobEvent,
|
||||
@@ -19,7 +20,6 @@ import type {
|
||||
|
||||
const DISCONNECT_UI_DELAY_MS = 1500;
|
||||
const PRESENCE_PING_INTERVAL_MS = 20_000;
|
||||
const BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS = 15_000;
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
@@ -225,14 +225,13 @@ function sendPresencePing() {
|
||||
);
|
||||
}
|
||||
|
||||
function refreshSharedSocket() {
|
||||
function ensureSharedSocket() {
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (socket && socket.readyState === WebSocket.CONNECTING) {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnectSharedSocket();
|
||||
connectSharedSocket();
|
||||
}
|
||||
|
||||
@@ -280,24 +279,6 @@ function stopPresenceMonitoring() {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshSocketAfterResume() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const socket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sharedChatConnection.lastBackgroundAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - sharedChatConnection.lastBackgroundAt >= BACKGROUND_SOCKET_REFRESH_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||
sharedChatConnection.lastBackgroundAt = Date.now();
|
||||
@@ -306,36 +287,24 @@ function handleVisibilityChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSharedSocket();
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handlePageShow() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSharedSocket();
|
||||
sendPresencePing();
|
||||
sendContextUpdate(sharedChatConnection.currentContext);
|
||||
}
|
||||
|
||||
function handleWindowFocus() {
|
||||
if (shouldRefreshSocketAfterResume()) {
|
||||
refreshSharedSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSharedSocket();
|
||||
sendPresencePing();
|
||||
}
|
||||
|
||||
function handleWindowOnline() {
|
||||
refreshSharedSocket();
|
||||
ensureSharedSocket();
|
||||
}
|
||||
|
||||
function startPresenceMonitoring() {
|
||||
@@ -444,6 +413,16 @@ function connectSharedSocket() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasRegisteredAccessTokenAccess()) {
|
||||
clearReconnectTimer();
|
||||
clearConnectTimeout();
|
||||
clearDisconnectUiTimer();
|
||||
stopPresenceMonitoring();
|
||||
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결을 시작하지 않았습니다.');
|
||||
setSharedConnectionState('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSocket = sharedChatConnection.socketRef.current;
|
||||
|
||||
if (currentSocket && (currentSocket.readyState === WebSocket.OPEN || currentSocket.readyState === WebSocket.CONNECTING)) {
|
||||
@@ -496,6 +475,13 @@ function connectSharedSocket() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeEvent?.code === 1008) {
|
||||
clearReconnectTimer();
|
||||
setSharedConnectionError('등록된 접근 토큰이 없어 채팅 연결이 차단되었습니다.');
|
||||
setSharedConnectionState('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeEvent?.code === 1000 && !message) {
|
||||
setSharedConnectionError('');
|
||||
return;
|
||||
|
||||
5
src/app/main/pwaRegisterStub.ts
Normal file
5
src/app/main/pwaRegisterStub.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function registerSW() {
|
||||
return async function updateServiceWorker() {
|
||||
return;
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { ReactNode } from 'react';
|
||||
import type { PlanFilterStatus } from '../../features/planBoard';
|
||||
|
||||
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
|
||||
export type HeaderTopMenuKey = 'docs' | 'plans' | 'play';
|
||||
export type HeaderTopMenuKey = 'docs' | 'plans';
|
||||
export type ApiSectionKey = 'components' | 'widgets';
|
||||
export type PlanSectionKey = PlanFilterStatus | 'release' | 'release-review' | 'board' | 'charts' | 'schedule' | 'history' | 'server-command';
|
||||
export type ChatSectionKey = 'live' | 'changes' | 'errors' | 'manage';
|
||||
@@ -370,7 +370,7 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
|
||||
return buildDocsPath(currentDocsFolder);
|
||||
}
|
||||
|
||||
return menu === 'plans' ? buildPlansPath('all') : buildPlayPath('layout');
|
||||
return buildPlansPath('all');
|
||||
}
|
||||
|
||||
export function createPageWindowId(topMenu: TopMenuKey, section: string) {
|
||||
|
||||
@@ -34,6 +34,7 @@ export type MainSidebarProps = {
|
||||
hasAccess: boolean;
|
||||
sidebarCollapsed: boolean;
|
||||
isMobileViewport: boolean;
|
||||
mobileInline?: boolean;
|
||||
openKeys: string[];
|
||||
apiMenuItems: MenuProps['items'];
|
||||
docsMenuItems: MenuProps['items'];
|
||||
|
||||
@@ -2,11 +2,12 @@ import {
|
||||
AudioOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
EyeOutlined,
|
||||
DownloadOutlined,
|
||||
FileImageOutlined,
|
||||
FileMarkdownOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
FullscreenOutlined,
|
||||
LinkOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -128,6 +129,81 @@ async function copyAttachmentValue(attachment: EvidenceAttachmentItem) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function resolveAttachmentDownloadFileName(attachment: EvidenceAttachmentItem) {
|
||||
if (typeof attachment.title === 'string' && attachment.title.trim()) {
|
||||
return attachment.title.trim();
|
||||
}
|
||||
|
||||
if (attachment.linkUrl) {
|
||||
try {
|
||||
const resolvedUrl = new URL(
|
||||
attachment.linkUrl,
|
||||
typeof window !== 'undefined' ? window.location.origin : 'https://test.sm-home.cloud/',
|
||||
);
|
||||
const fileName = resolvedUrl.pathname.split('/').pop()?.trim();
|
||||
|
||||
if (fileName) {
|
||||
return fileName;
|
||||
}
|
||||
} catch {
|
||||
return `${attachment.key}.txt`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${attachment.key}.txt`;
|
||||
}
|
||||
|
||||
function resolveAttachmentMimeType(attachment: EvidenceAttachmentItem) {
|
||||
switch (attachment.kind) {
|
||||
case 'markdown':
|
||||
return 'text/markdown;charset=utf-8';
|
||||
case 'json':
|
||||
return 'application/json;charset=utf-8';
|
||||
case 'code':
|
||||
case 'text':
|
||||
case 'empty':
|
||||
return 'text/plain;charset=utf-8';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAttachmentValue(attachment: EvidenceAttachmentItem) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('download-unavailable');
|
||||
}
|
||||
|
||||
const fileName = resolveAttachmentDownloadFileName(attachment);
|
||||
const link = document.createElement('a');
|
||||
|
||||
if (attachment.linkUrl) {
|
||||
link.href = attachment.linkUrl;
|
||||
|
||||
if (attachment.kind === 'preview') {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
} else {
|
||||
link.download = fileName;
|
||||
}
|
||||
} else {
|
||||
const blob = new Blob([attachment.value], {
|
||||
type: resolveAttachmentMimeType(attachment),
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function resolvePreviewerType(kind: EvidenceAttachmentKind) {
|
||||
switch (kind) {
|
||||
case 'markdown':
|
||||
@@ -260,6 +336,14 @@ export function EvidenceAttachmentStrip({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(attachment: EvidenceAttachmentItem) {
|
||||
try {
|
||||
downloadAttachmentValue(attachment);
|
||||
} catch {
|
||||
message.error('다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return (
|
||||
<div className={['evidence-attachment-strip', className].filter(Boolean).join(' ')}>
|
||||
@@ -308,29 +392,27 @@ export function EvidenceAttachmentStrip({
|
||||
void handleCopy(attachment);
|
||||
}}
|
||||
/>
|
||||
{attachment.linkUrl ? (
|
||||
{attachment.linkUrl || attachment.value ? (
|
||||
<Button
|
||||
type="link"
|
||||
type="text"
|
||||
size="small"
|
||||
href={attachment.linkUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ paddingInline: 0 }}
|
||||
icon={<LinkOutlined />}
|
||||
>
|
||||
링크
|
||||
</Button>
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
void handleDownload(attachment);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{onPreview ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
type="text"
|
||||
aria-label="최대화"
|
||||
icon={<FullscreenOutlined />}
|
||||
onClick={() => {
|
||||
void onPreview(attachment);
|
||||
}}
|
||||
>
|
||||
미리보기
|
||||
</Button>
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { App, Card, Flex, Modal, Space, Switch, Typography } from 'antd';
|
||||
import {
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { App, Button, Card, Flex, Modal, Space, Switch, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import type { SampleMeta } from '../../../widgets/core';
|
||||
import {
|
||||
@@ -96,6 +103,7 @@ export function Sample() {
|
||||
const { message } = App.useApp();
|
||||
const [compact, setCompact] = useState(false);
|
||||
const [selectedAttachment, setSelectedAttachment] = useState<EvidenceAttachmentItem | null>(null);
|
||||
const [isPreviewExpanded, setIsPreviewExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
@@ -130,13 +138,51 @@ export function Sample() {
|
||||
open={Boolean(selectedAttachment)}
|
||||
title={selectedAttachment?.title ?? 'Attachment Preview'}
|
||||
footer={null}
|
||||
width={1080}
|
||||
width={isPreviewExpanded ? 'calc(100vw - 32px)' : 1080}
|
||||
onCancel={() => {
|
||||
setSelectedAttachment(null);
|
||||
setIsPreviewExpanded(false);
|
||||
}}
|
||||
>
|
||||
{selectedAttachment ? (
|
||||
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
|
||||
<Flex vertical gap={12}>
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
<Button
|
||||
aria-label="복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(selectedAttachment.copyValue ?? selectedAttachment.value);
|
||||
message.success(`${String(selectedAttachment.title)} 복사`);
|
||||
}}
|
||||
/>
|
||||
{selectedAttachment.linkUrl ? (
|
||||
<Button
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
href={selectedAttachment.linkUrl}
|
||||
target={selectedAttachment.kind === 'preview' ? '_blank' : undefined}
|
||||
rel={selectedAttachment.kind === 'preview' ? 'noreferrer' : undefined}
|
||||
download={selectedAttachment.kind === 'preview' ? undefined : true}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label={isPreviewExpanded ? '최대화 해제' : '최대화'}
|
||||
icon={isPreviewExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => {
|
||||
setIsPreviewExpanded((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label="닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedAttachment(null);
|
||||
setIsPreviewExpanded(false);
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<EvidenceAttachmentPreviewBody attachment={selectedAttachment} />
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
</Flex>
|
||||
|
||||
@@ -30,6 +30,7 @@ type CodexDiffBlockProps = {
|
||||
summary?: string;
|
||||
emptyDescription?: string;
|
||||
showToolbar?: boolean;
|
||||
expandAll?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -170,30 +171,42 @@ export function CodexDiffBlock({
|
||||
summary,
|
||||
emptyDescription = '표시할 diff가 없습니다.',
|
||||
showToolbar = true,
|
||||
expandAll = false,
|
||||
className,
|
||||
}: CodexDiffBlockProps) {
|
||||
const diffSections = useMemo(() => parseCodexDiffSections(diffText), [diffText]);
|
||||
const [expandedDiffPaths, setExpandedDiffPaths] = useState<string[]>(() => diffSections.slice(0, 1).map((section) => section.path));
|
||||
const [expandedDiffPath, setExpandedDiffPath] = useState<string | null>(() =>
|
||||
expandAll ? diffSections[0]?.path ?? null : diffSections[0]?.path ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDiffPaths((currentPaths) => {
|
||||
const availablePaths = new Set(diffSections.map((section) => section.path));
|
||||
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
|
||||
if (expandAll) {
|
||||
setExpandedDiffPath(diffSections[0]?.path ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextPaths.length > 0) {
|
||||
return nextPaths;
|
||||
setExpandedDiffPath((currentPath) => {
|
||||
if (currentPath && diffSections.some((section) => section.path === currentPath)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
return diffSections[0] ? [diffSections[0].path] : [];
|
||||
return diffSections[0]?.path ?? null;
|
||||
});
|
||||
}, [diffSections]);
|
||||
}, [diffSections, expandAll]);
|
||||
|
||||
if (!diffSections.length) {
|
||||
return <Empty description={emptyDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className ?? 'codex-diff-previewer__diff-list'}>
|
||||
<div
|
||||
className={[
|
||||
className ?? 'codex-diff-previewer__diff-list',
|
||||
expandAll ? 'codex-diff-previewer__diff-list--expand-all' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="codex-diff-previewer__diff-toolbar">
|
||||
<Text type="secondary">
|
||||
{summary ?? `파일 ${diffSections.length}개 기준으로 diff를 분리해 표시합니다.`}
|
||||
@@ -203,15 +216,15 @@ export function CodexDiffBlock({
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths(diffSections.map((section) => section.path));
|
||||
setExpandedDiffPath(diffSections[0]?.path ?? null);
|
||||
}}
|
||||
>
|
||||
전체 펼치기
|
||||
첫 diff 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths([]);
|
||||
setExpandedDiffPath(null);
|
||||
}}
|
||||
>
|
||||
전체 접기
|
||||
@@ -221,7 +234,7 @@ export function CodexDiffBlock({
|
||||
</div>
|
||||
|
||||
{diffSections.map((section) => {
|
||||
const isExpanded = expandedDiffPaths.includes(section.path);
|
||||
const isExpanded = expandedDiffPath === section.path;
|
||||
const displayPath = section.previousPath ? `${section.previousPath} -> ${section.path}` : section.path;
|
||||
|
||||
return (
|
||||
@@ -230,11 +243,7 @@ export function CodexDiffBlock({
|
||||
type="button"
|
||||
className="codex-diff-previewer__diff-toggle"
|
||||
onClick={() => {
|
||||
setExpandedDiffPaths((currentPaths) =>
|
||||
currentPaths.includes(section.path)
|
||||
? currentPaths.filter((path) => path !== section.path)
|
||||
: [...currentPaths, section.path],
|
||||
);
|
||||
setExpandedDiffPath((currentPath) => (currentPath === section.path ? null : section.path));
|
||||
}}
|
||||
>
|
||||
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
|
||||
.codex-diff-previewer__diff-section--expanded {
|
||||
position: fixed;
|
||||
inset: 16px;
|
||||
inset: 0;
|
||||
z-index: 1250;
|
||||
border-radius: 20px;
|
||||
border-radius: 0;
|
||||
border-inline: 0;
|
||||
background: #0f172a;
|
||||
box-shadow:
|
||||
0 28px 72px rgba(15, 23, 42, 0.38),
|
||||
@@ -95,7 +96,7 @@
|
||||
}
|
||||
|
||||
.codex-diff-previewer__diff-section--expanded .codex-diff-previewer__diff-body {
|
||||
height: calc(100vh - 68px);
|
||||
height: calc(100vh - 60px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
FileImageOutlined,
|
||||
FileTextOutlined,
|
||||
@@ -92,7 +93,7 @@ export function CodexDiffPreviewer({
|
||||
}: CodexDiffPreviewerProps) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [activeMode, setActiveMode] = useState<'source' | 'diff'>(files.length > 0 ? 'source' : 'diff');
|
||||
const [expandedSourcePaths, setExpandedSourcePaths] = useState<string[]>(() => files.slice(0, 1).map((file) => file.path));
|
||||
const [expandedSourcePath, setExpandedSourcePath] = useState<string | null>(() => files[0]?.path ?? null);
|
||||
const [expandedPreviewPath, setExpandedPreviewPath] = useState<string | null>(null);
|
||||
const statusCount = useMemo(() => buildStatusCount(files), [files]);
|
||||
const canShowSource = files.length > 0;
|
||||
@@ -129,6 +130,24 @@ export function CodexDiffPreviewer({
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(path: string, content: string) {
|
||||
if (typeof document === 'undefined') {
|
||||
messageApi.error('다운로드를 사용할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = path.split('/').pop() || 'preview.txt';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
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 handleFullscreen(path: string) {
|
||||
setExpandedPreviewPath((currentPath) => (currentPath === path ? null : path));
|
||||
}
|
||||
@@ -141,15 +160,12 @@ export function CodexDiffPreviewer({
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedSourcePaths((currentPaths) => {
|
||||
const availablePaths = new Set(files.map((file) => file.path));
|
||||
const nextPaths = currentPaths.filter((path) => availablePaths.has(path));
|
||||
|
||||
if (nextPaths.length > 0) {
|
||||
return nextPaths;
|
||||
setExpandedSourcePath((currentPath) => {
|
||||
if (currentPath && files.some((file) => file.path === currentPath)) {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
return files[0] ? [files[0].path] : [];
|
||||
return files[0]?.path ?? null;
|
||||
});
|
||||
}, [canShowSource, diffText, files]);
|
||||
|
||||
@@ -231,15 +247,15 @@ export function CodexDiffPreviewer({
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedSourcePaths(files.map((file) => file.path));
|
||||
setExpandedSourcePath(files[0]?.path ?? null);
|
||||
}}
|
||||
>
|
||||
전체 펼치기
|
||||
첫 문서 펼치기
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setExpandedSourcePaths([]);
|
||||
setExpandedSourcePath(null);
|
||||
}}
|
||||
>
|
||||
전체 접기
|
||||
@@ -248,7 +264,7 @@ export function CodexDiffPreviewer({
|
||||
</div>
|
||||
|
||||
{files.map((file) => {
|
||||
const isExpanded = expandedSourcePaths.includes(file.path);
|
||||
const isExpanded = expandedSourcePath === file.path;
|
||||
const isPreviewExpanded = expandedPreviewPath === file.path;
|
||||
const displayPath = file.previousPath ? `${file.previousPath} -> ${file.path}` : file.path;
|
||||
|
||||
@@ -266,11 +282,7 @@ export function CodexDiffPreviewer({
|
||||
type="button"
|
||||
className="codex-diff-previewer__diff-toggle"
|
||||
onClick={() => {
|
||||
setExpandedSourcePaths((currentPaths) =>
|
||||
currentPaths.includes(file.path)
|
||||
? currentPaths.filter((path) => path !== file.path)
|
||||
: [...currentPaths, file.path],
|
||||
);
|
||||
setExpandedSourcePath((currentPath) => (currentPath === file.path ? null : file.path));
|
||||
}}
|
||||
>
|
||||
<Space align="center" size={10} className="codex-diff-previewer__diff-toggle-main">
|
||||
@@ -296,6 +308,16 @@ export function CodexDiffPreviewer({
|
||||
void handleCopy(file.content);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleDownload(file.path, file.content);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
@@ -316,6 +338,7 @@ export function CodexDiffPreviewer({
|
||||
value={file.content}
|
||||
language={file.language}
|
||||
imageAlt={file.path.split('/').pop() ?? file.path}
|
||||
downloadFileName={file.path.split('/').pop() ?? file.path}
|
||||
height={isPreviewExpanded ? 'calc(100vh - 120px)' : height}
|
||||
copyable={false}
|
||||
maximizable={false}
|
||||
|
||||
@@ -93,6 +93,15 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.previewer-ui__action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
.previewer-ui--headerless {
|
||||
border-color: transparent;
|
||||
border-radius: 0;
|
||||
@@ -105,17 +114,18 @@
|
||||
|
||||
.previewer-ui--expanded {
|
||||
position: fixed;
|
||||
inset: 16px;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
height: auto;
|
||||
border-radius: 20px;
|
||||
border-radius: 0;
|
||||
border-inline: 0;
|
||||
box-shadow:
|
||||
0 24px 64px rgba(15, 23, 42, 0.28),
|
||||
0 12px 28px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.previewer-ui--expanded .previewer-ui__body {
|
||||
height: calc(100vh - 32px - 53px) !important;
|
||||
height: calc(100vh - 53px) !important;
|
||||
}
|
||||
|
||||
.previewer-ui__language-select {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CopyOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Select, message } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -136,6 +136,22 @@ async function copyText(text: string) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function downloadBlob(content: BlobPart, fileName: string, mimeType = 'text/plain;charset=utf-8') {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('다운로드를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
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 resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'value'>) {
|
||||
switch (type) {
|
||||
case 'json':
|
||||
@@ -147,6 +163,45 @@ function resolveCopyValue({ type, value }: Pick<PreviewerUIProps, 'type' | 'valu
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDownloadValue({
|
||||
type,
|
||||
value,
|
||||
downloadValue,
|
||||
}: Pick<PreviewerUIProps, 'type' | 'value' | 'downloadValue'>) {
|
||||
if (typeof downloadValue === 'string') {
|
||||
return downloadValue;
|
||||
}
|
||||
|
||||
if (type === 'image') {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
return resolveCopyValue({ type, value });
|
||||
}
|
||||
|
||||
function resolveDownloadFileName({
|
||||
type,
|
||||
language,
|
||||
downloadFileName,
|
||||
}: Pick<PreviewerUIProps, 'type' | 'language' | 'downloadFileName'>) {
|
||||
if (downloadFileName?.trim()) {
|
||||
return downloadFileName.trim();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'json':
|
||||
return 'preview.json';
|
||||
case 'markdown':
|
||||
return 'preview.md';
|
||||
case 'code':
|
||||
return `preview.${language || 'txt'}`;
|
||||
case 'image':
|
||||
return 'preview';
|
||||
default:
|
||||
return 'preview.txt';
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent({
|
||||
type,
|
||||
value,
|
||||
@@ -212,6 +267,10 @@ export function PreviewerUI({
|
||||
value,
|
||||
copyValue,
|
||||
copyable = true,
|
||||
downloadable = true,
|
||||
downloadValue,
|
||||
downloadUrl,
|
||||
downloadFileName,
|
||||
maximizable = true,
|
||||
language = 'text',
|
||||
format = 'auto',
|
||||
@@ -226,8 +285,11 @@ export function PreviewerUI({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0;
|
||||
const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value });
|
||||
const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue });
|
||||
const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName });
|
||||
const canCopy = copyable && resolvedCopyValue.trim().length > 0;
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || maximizable || Boolean(toolbar);
|
||||
const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded || typeof document === 'undefined') {
|
||||
@@ -271,6 +333,37 @@ export function PreviewerUI({
|
||||
setIsExpanded((previous) => !previous);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!canDownload) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (downloadUrl) {
|
||||
if (typeof document === 'undefined') {
|
||||
throw new Error('다운로드를 사용할 수 없습니다.');
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = resolvedDownloadFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
const mimeType =
|
||||
type === 'json'
|
||||
? 'application/json;charset=utf-8'
|
||||
: type === 'markdown'
|
||||
? 'text/markdown;charset=utf-8'
|
||||
: 'text/plain;charset=utf-8';
|
||||
downloadBlob(resolvedDownloadValue, resolvedDownloadFileName, mimeType);
|
||||
}
|
||||
} catch {
|
||||
messageApi.error('다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
const actionContent = (
|
||||
<>
|
||||
{hasLanguageSelector ? (
|
||||
@@ -286,15 +379,27 @@ export function PreviewerUI({
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="previewer-ui__action-button"
|
||||
aria-label="복사"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => void handleCopy()}
|
||||
/>
|
||||
) : null}
|
||||
{canDownload ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="previewer-ui__action-button"
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
/>
|
||||
) : null}
|
||||
{maximizable ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="previewer-ui__action-button"
|
||||
aria-label={isExpanded ? '최대화 해제' : '최대화'}
|
||||
icon={isExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
|
||||
@@ -16,6 +16,10 @@ export type PreviewerUIProps = {
|
||||
value?: unknown;
|
||||
copyValue?: string;
|
||||
copyable?: boolean;
|
||||
downloadable?: boolean;
|
||||
downloadValue?: string;
|
||||
downloadUrl?: string;
|
||||
downloadFileName?: string;
|
||||
maximizable?: boolean;
|
||||
language?: string;
|
||||
format?: PreviewerFormat;
|
||||
|
||||
@@ -2,7 +2,10 @@ import {
|
||||
ArrowLeftOutlined,
|
||||
CloseOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
DownOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
UpOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
@@ -2510,6 +2513,7 @@ export function WorklogEvidenceTab({
|
||||
const [worklogContents, setWorklogContents] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewModalItem, setPreviewModalItem] = useState<EvidenceAttachmentItem | null>(null);
|
||||
const [isPreviewModalExpanded, setIsPreviewModalExpanded] = useState(false);
|
||||
|
||||
async function handleCopyText(text: string) {
|
||||
try {
|
||||
@@ -2642,10 +2646,13 @@ export function WorklogEvidenceTab({
|
||||
open={Boolean(previewModalItem)}
|
||||
title={previewModalItem?.title ?? 'Preview'}
|
||||
footer={null}
|
||||
width={1120}
|
||||
rootClassName="plan-board-page__evidence-modal"
|
||||
width={isPreviewModalExpanded ? 'calc(100vw - 32px)' : 1120}
|
||||
rootClassName={`plan-board-page__evidence-modal${
|
||||
isPreviewModalExpanded ? ' plan-board-page__evidence-modal--expanded' : ''
|
||||
}`}
|
||||
onCancel={() => {
|
||||
setPreviewModalItem(null);
|
||||
setIsPreviewModalExpanded(false);
|
||||
}}
|
||||
closeIcon={<CloseOutlined />}
|
||||
>
|
||||
@@ -2660,18 +2667,30 @@ export function WorklogEvidenceTab({
|
||||
onClick={() => void handleCopyText(previewModalItem.value)}
|
||||
/>
|
||||
{previewModalItem.linkUrl ? (
|
||||
<Button type="link" href={previewModalItem.linkUrl} target="_blank" rel="noreferrer">
|
||||
링크 열기
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="다운로드"
|
||||
icon={<DownloadOutlined />}
|
||||
href={previewModalItem.linkUrl}
|
||||
target={previewModalItem.kind === 'preview' ? '_blank' : undefined}
|
||||
rel={previewModalItem.kind === 'preview' ? 'noreferrer' : undefined}
|
||||
download={previewModalItem.kind === 'preview' ? undefined : true}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label={isPreviewModalExpanded ? '최대화 해제' : '최대화'}
|
||||
icon={isPreviewModalExpanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => {
|
||||
setIsPreviewModalExpanded((previous) => !previous);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
aria-label="닫기"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setPreviewModalItem(null);
|
||||
setIsPreviewModalExpanded(false);
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div className="plan-board-page__evidence-modal-body">
|
||||
|
||||
@@ -17,6 +17,13 @@ body,
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(22, 93, 255, 0.14), transparent 34%),
|
||||
linear-gradient(180deg, #f8fbff 0%, #eef4ff 45%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.markdown-preview__image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -35,6 +42,7 @@ body {
|
||||
min-width: 320px;
|
||||
font-family:
|
||||
'SUIT Variable', 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: #182230;
|
||||
}
|
||||
|
||||
img,
|
||||
|
||||
20
src/sw.js
20
src/sw.js
@@ -76,6 +76,10 @@ self.addEventListener('notificationclick', (event) => {
|
||||
const notificationCategory = typeof notificationData.category === 'string' ? notificationData.category.trim() : '';
|
||||
const notificationType = typeof notificationData.type === 'string' ? notificationData.type.trim() : '';
|
||||
const notificationThreadId = typeof notificationData.threadId === 'string' ? notificationData.threadId.trim() : '';
|
||||
const isChatNotification =
|
||||
notificationCategory === 'chat' ||
|
||||
notificationType.startsWith('chat') ||
|
||||
notificationThreadId.startsWith('chat:');
|
||||
|
||||
event.notification.close();
|
||||
let targetUrl;
|
||||
@@ -98,6 +102,17 @@ self.addEventListener('notificationclick', (event) => {
|
||||
targetUrl.searchParams.set('topMenu', notificationData.category === 'chat' ? 'chat' : 'plans');
|
||||
}
|
||||
|
||||
if (isChatNotification) {
|
||||
targetUrl.pathname = '/chat/live';
|
||||
targetUrl.searchParams.set('topMenu', 'chat');
|
||||
targetUrl.searchParams.delete('chatView');
|
||||
targetUrl.searchParams.delete('runtimeRequestId');
|
||||
|
||||
if (notificationSessionId) {
|
||||
targetUrl.searchParams.set('sessionId', notificationSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationData.planId && !targetUrl.searchParams.has('planId')) {
|
||||
targetUrl.searchParams.set('planId', String(notificationData.planId));
|
||||
}
|
||||
@@ -109,11 +124,6 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
self.registration.getNotifications().then((notifications) => {
|
||||
const isChatNotification =
|
||||
notificationCategory === 'chat' ||
|
||||
notificationType.startsWith('chat') ||
|
||||
notificationThreadId.startsWith('chat:');
|
||||
|
||||
if (!notificationSessionId || !isChatNotification) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user