feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -10,6 +10,10 @@
gap: 2px;
}
.header-message-center__tabs {
width: 100%;
}
.header-message-center__loading {
display: flex;
align-items: center;

View File

@@ -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 ? (

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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';

View 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;
}

View File

@@ -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) {

View File

@@ -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;
},
};
}

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
);

View File

@@ -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;

View 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);
}

View File

@@ -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,

View 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();
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
export function registerSW() {
return async function updateServiceWorker() {
return;
};
}

View File

@@ -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) {

View File

@@ -34,6 +34,7 @@ export type MainSidebarProps = {
hasAccess: boolean;
sidebarCollapsed: boolean;
isMobileViewport: boolean;
mobileInline?: boolean;
openKeys: string[];
apiMenuItems: MenuProps['items'];
docsMenuItems: MenuProps['items'];

View File

@@ -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>
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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 {

View File

@@ -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()}

View File

@@ -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;

View File

@@ -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">

View File

@@ -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,

View File

@@ -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;
}