feat: update codex live runtime and restart flow
This commit is contained in:
@@ -1784,9 +1784,20 @@
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich--markdown {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
max-height: min(420px, 70vh);
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-rich--markdown .markdown-preview {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-message__preview-image,
|
||||
.app-chat-message__preview-video,
|
||||
.app-chat-message__preview-frame {
|
||||
@@ -2175,6 +2186,7 @@
|
||||
.app-chat-panel__preview-stage > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-loading {
|
||||
@@ -2366,12 +2378,32 @@
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-footer {
|
||||
.app-chat-panel__preview-modal-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-findbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 20px 12px;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-findbar .ant-input-affix-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-rich,
|
||||
@@ -2387,6 +2419,12 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown,
|
||||
.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown {
|
||||
height: 100%;
|
||||
max-height: 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,
|
||||
@@ -2403,8 +2441,17 @@
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-chat-panel__preview-modal-footer {
|
||||
justify-content: flex-end;
|
||||
.app-chat-panel__preview-modal-title {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-findbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-chat-panel__preview-modal-findbar .ant-btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../../store';
|
||||
import { useAppConfig } from './appConfig';
|
||||
@@ -192,6 +193,267 @@ function createConversationPreviewText(text: string) {
|
||||
return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized;
|
||||
}
|
||||
|
||||
type PreviewTextMatch = {
|
||||
node: Text;
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
type PreviewMatchAnchor = {
|
||||
match: PreviewTextMatch;
|
||||
target: HTMLElement;
|
||||
offsetTop: number;
|
||||
};
|
||||
|
||||
function collectPreviewTextMatches(root: HTMLElement, keyword: string) {
|
||||
const normalizedKeyword = keyword.trim().toLocaleLowerCase();
|
||||
|
||||
if (!normalizedKeyword || typeof document === 'undefined') {
|
||||
return [] as PreviewTextMatch[];
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const matches: PreviewTextMatch[] = [];
|
||||
let currentNode = walker.nextNode();
|
||||
|
||||
while (currentNode) {
|
||||
if (currentNode.nodeType === Node.TEXT_NODE) {
|
||||
const textNode = currentNode as Text;
|
||||
const textContent = textNode.textContent ?? '';
|
||||
const normalizedText = textContent.toLocaleLowerCase();
|
||||
|
||||
if (normalizedText) {
|
||||
let fromIndex = 0;
|
||||
|
||||
while (fromIndex < normalizedText.length) {
|
||||
const matchIndex = normalizedText.indexOf(normalizedKeyword, fromIndex);
|
||||
|
||||
if (matchIndex < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
node: textNode,
|
||||
start: matchIndex,
|
||||
end: matchIndex + normalizedKeyword.length,
|
||||
});
|
||||
fromIndex = matchIndex + Math.max(1, normalizedKeyword.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentNode = walker.nextNode();
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function focusPreviewTextMatch(match: PreviewTextMatch) {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(match.node, match.start);
|
||||
range.setEnd(match.node, match.end);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolvePreviewMatchTargetElement(match: PreviewTextMatch) {
|
||||
return (
|
||||
match.node.parentElement?.closest<HTMLElement>(
|
||||
'.previewer-ui__line, .markdown-preview__block, .codex-diff-previewer__diff-line, p, li, h1, h2, h3, pre, code, div',
|
||||
) ?? match.node.parentElement
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePreviewScrollContainer(root: HTMLElement) {
|
||||
const preferredSelectors = [
|
||||
'.app-chat-panel__preview-rich--markdown',
|
||||
'.app-chat-panel__preview-rich',
|
||||
'.codex-diff-previewer__diff-body',
|
||||
'.previewer-ui__editor-body',
|
||||
];
|
||||
|
||||
if (root.scrollHeight > root.clientHeight + 4) {
|
||||
return root;
|
||||
}
|
||||
|
||||
for (const selector of preferredSelectors) {
|
||||
const candidate = root.querySelector<HTMLElement>(selector);
|
||||
|
||||
if (candidate && candidate.scrollHeight > candidate.clientHeight + 4) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) {
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
return targetRect.top - containerRect.top + container.scrollTop;
|
||||
}
|
||||
|
||||
function getPreviewMatchOffsetWithinContainer(match: PreviewTextMatch, container: HTMLElement) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(match.node, match.start);
|
||||
range.setEnd(match.node, match.end);
|
||||
|
||||
const rects = range.getClientRects();
|
||||
const firstRect = rects.item(0);
|
||||
|
||||
if (!firstRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
return firstRect.top - containerRect.top + container.scrollTop;
|
||||
}
|
||||
|
||||
function getPreviewMatchHeight(match: PreviewTextMatch) {
|
||||
if (typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(match.node, match.start);
|
||||
range.setEnd(match.node, match.end);
|
||||
|
||||
const rects = range.getClientRects();
|
||||
const firstRect = rects.item(0);
|
||||
|
||||
if (!firstRect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return firstRect.height;
|
||||
}
|
||||
|
||||
function buildPreviewMatchAnchors(matches: PreviewTextMatch[], container: HTMLElement) {
|
||||
const anchors: PreviewMatchAnchor[] = [];
|
||||
|
||||
matches.forEach((match) => {
|
||||
const target = resolvePreviewMatchTargetElement(match);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
anchors.push({
|
||||
match,
|
||||
target,
|
||||
offsetTop: getPreviewMatchOffsetWithinContainer(match, container) ?? getElementOffsetWithinContainer(target, container),
|
||||
});
|
||||
});
|
||||
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function resolvePreviewSearchAnchorIndex(
|
||||
anchors: PreviewMatchAnchor[],
|
||||
container: HTMLElement,
|
||||
direction: 'forward' | 'backward',
|
||||
) {
|
||||
if (anchors.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const viewportAnchor = container.scrollTop + container.clientHeight / 2;
|
||||
const threshold = 4;
|
||||
|
||||
if (direction === 'backward') {
|
||||
for (let index = anchors.length - 1; index >= 0; index -= 1) {
|
||||
if (anchors[index].offsetTop < viewportAnchor - threshold) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return anchors.length - 1;
|
||||
}
|
||||
|
||||
for (let index = 0; index < anchors.length; index += 1) {
|
||||
if (anchors[index].offsetTop > viewportAnchor + threshold) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolvePreviewSearchResultIndex(args: {
|
||||
anchors: PreviewMatchAnchor[];
|
||||
container: HTMLElement;
|
||||
direction: 'forward' | 'backward';
|
||||
currentIndex: number;
|
||||
}) {
|
||||
const { anchors, container, direction, currentIndex } = args;
|
||||
|
||||
if (anchors.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < anchors.length) {
|
||||
if (direction === 'backward') {
|
||||
return currentIndex === 0 ? anchors.length - 1 : currentIndex - 1;
|
||||
}
|
||||
|
||||
return currentIndex === anchors.length - 1 ? 0 : currentIndex + 1;
|
||||
}
|
||||
|
||||
return resolvePreviewSearchAnchorIndex(anchors, container, direction);
|
||||
}
|
||||
|
||||
function focusPreviewTextMatchInContainer(match: PreviewTextMatch, container: HTMLElement) {
|
||||
if (!focusPreviewTextMatch(match)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchTop = getPreviewMatchOffsetWithinContainer(match, container);
|
||||
|
||||
if (matchTop != null) {
|
||||
const matchHeight = getPreviewMatchHeight(match) ?? 0;
|
||||
const nextScrollTop = Math.max(0, matchTop - container.clientHeight / 2 + matchHeight / 2);
|
||||
|
||||
container.scrollTo({
|
||||
top: nextScrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const target = resolvePreviewMatchTargetElement(match);
|
||||
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetTop = getElementOffsetWithinContainer(target, container);
|
||||
const nextScrollTop = Math.max(0, targetTop - container.clientHeight / 2 + target.clientHeight / 2);
|
||||
|
||||
container.scrollTo({
|
||||
top: nextScrollTop,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveConversationListPreviewText(preview: string) {
|
||||
const normalized = createConversationPreviewText(preview);
|
||||
|
||||
@@ -646,12 +908,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isResourceStripOpen, setIsResourceStripOpen] = useState(false);
|
||||
const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false);
|
||||
const [isPreviewFindOpen, setIsPreviewFindOpen] = useState(false);
|
||||
const [previewFindQuery, setPreviewFindQuery] = useState('');
|
||||
const [notificationToggleSessionId, setNotificationToggleSessionId] = useState<string | null>(null);
|
||||
const [renamingConversationSessionId, setRenamingConversationSessionId] = useState<string | null>(null);
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [pendingContextConfirm, setPendingContextConfirm] = useState<PendingContextConfirm | null>(null);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const composerRef = useRef<TextAreaRef | null>(null);
|
||||
const previewFindInputRef = useRef<InputRef | null>(null);
|
||||
const previewSearchRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewSearchMatchesRef = useRef<PreviewTextMatch[]>([]);
|
||||
const previewSearchMatchIndexRef = useRef(-1);
|
||||
const previewSearchKeyRef = useRef('');
|
||||
const titleClusterRef = useRef<HTMLDivElement | null>(null);
|
||||
const copyFeedbackTimerRef = useRef<number | null>(null);
|
||||
const pendingRequestsRef = useRef<PendingChatRequest[]>([]);
|
||||
@@ -661,6 +930,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||
const shouldRestoreConversationAfterReconnectRef = useRef(false);
|
||||
const handledRequestedSessionIdRef = useRef('');
|
||||
const isClosingConversationRef = useRef(false);
|
||||
const lastChatTypeSessionIdRef = useRef('');
|
||||
const notifiedTerminalJobKeysRef = useRef<string[]>([]);
|
||||
const lastMarkedReadResponseIdBySessionRef = useRef<Record<string, number>>({});
|
||||
@@ -746,6 +1016,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
clientId: null,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
lastChatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name ?? null,
|
||||
contextDescription: selectedChatType?.description ?? null,
|
||||
notifyOffline: true,
|
||||
@@ -771,6 +1042,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
sessionId,
|
||||
title: '새 대화',
|
||||
chatTypeId: selectedChatType?.id ?? null,
|
||||
lastChatTypeId: selectedChatType?.id ?? null,
|
||||
contextLabel: selectedChatType?.name,
|
||||
contextDescription: selectedChatType?.description,
|
||||
notifyOffline: true,
|
||||
@@ -1344,6 +1616,130 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
}, [activePreview, messageApi]);
|
||||
|
||||
const canSearchActivePreview =
|
||||
Boolean(activePreview) &&
|
||||
!isPreviewLoading &&
|
||||
!previewError.trim() &&
|
||||
(activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document');
|
||||
|
||||
const resetActivePreviewSearchState = useCallback(() => {
|
||||
previewSearchMatchesRef.current = [];
|
||||
previewSearchMatchIndexRef.current = -1;
|
||||
previewSearchKeyRef.current = '';
|
||||
}, []);
|
||||
|
||||
const clearActivePreviewSearchSelection = useCallback(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPreviewModalOpen) {
|
||||
setIsPreviewFindOpen(false);
|
||||
setPreviewFindQuery('');
|
||||
resetActivePreviewSearchState();
|
||||
clearActivePreviewSearchSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreviewFindOpen) {
|
||||
window.setTimeout(() => {
|
||||
previewFindInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]);
|
||||
|
||||
useEffect(() => {
|
||||
resetActivePreviewSearchState();
|
||||
clearActivePreviewSearchSelection();
|
||||
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
|
||||
|
||||
useEffect(() => {
|
||||
resetActivePreviewSearchState();
|
||||
}, [previewFindQuery, resetActivePreviewSearchState]);
|
||||
|
||||
const handlePreviewSearchRootPointerDown = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!isMobileViewport || !isPreviewFindOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inputElement = previewFindInputRef.current?.input;
|
||||
|
||||
if (!inputElement || document.activeElement !== inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target instanceof Node && inputElement.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
inputElement.blur();
|
||||
},
|
||||
[isMobileViewport, isPreviewFindOpen],
|
||||
);
|
||||
|
||||
const handleFindActivePreview = useCallback(
|
||||
(direction: 'forward' | 'backward' = 'forward') => {
|
||||
const keyword = previewFindQuery.trim();
|
||||
|
||||
if (!keyword) {
|
||||
void messageApi.info('찾을 단어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = previewSearchRootRef.current;
|
||||
|
||||
if (!root) {
|
||||
void messageApi.error('미리보기 본문을 아직 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const searchKey = `${activePreview?.id ?? 'preview'}:${keyword.toLocaleLowerCase()}`;
|
||||
const matches =
|
||||
previewSearchKeyRef.current === searchKey
|
||||
? previewSearchMatchesRef.current
|
||||
: collectPreviewTextMatches(root, keyword);
|
||||
|
||||
if (previewSearchKeyRef.current !== searchKey) {
|
||||
previewSearchKeyRef.current = searchKey;
|
||||
previewSearchMatchesRef.current = matches;
|
||||
previewSearchMatchIndexRef.current = -1;
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
void messageApi.info('일치하는 단어를 찾지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollContainer = resolvePreviewScrollContainer(root);
|
||||
const anchors = buildPreviewMatchAnchors(matches, scrollContainer);
|
||||
const nextIndex = resolvePreviewSearchResultIndex({
|
||||
anchors,
|
||||
container: scrollContainer,
|
||||
direction,
|
||||
currentIndex: previewSearchMatchIndexRef.current,
|
||||
});
|
||||
const nextAnchor = nextIndex >= 0 ? anchors[nextIndex] : null;
|
||||
|
||||
if (!nextAnchor || !focusPreviewTextMatchInContainer(nextAnchor.match, scrollContainer)) {
|
||||
void messageApi.error('검색 결과 위치로 이동하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
previewSearchMatchIndexRef.current = nextIndex;
|
||||
},
|
||||
[activePreview?.id, messageApi, previewFindQuery],
|
||||
);
|
||||
|
||||
const markConversationReadLocally = (sessionId: string) => {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
@@ -1532,7 +1928,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
const openConversationSession = (sessionId: string) => {
|
||||
isClosingConversationRef.current = false;
|
||||
replaceChatSessionInUrl(sessionId);
|
||||
clearRequestedRuntimeLogInUrl();
|
||||
setActiveView('chat');
|
||||
const now = new Date().toISOString();
|
||||
const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? [];
|
||||
const hasCachedMessages = cachedMessages.length > 0;
|
||||
@@ -1566,6 +1965,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
clientId: null,
|
||||
title: '대화 내용을 불러오는 중입니다.',
|
||||
chatTypeId: null,
|
||||
lastChatTypeId: null,
|
||||
contextLabel: null,
|
||||
contextDescription: null,
|
||||
notifyOffline: true,
|
||||
@@ -1842,7 +2242,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
|
||||
if (activeSessionId) {
|
||||
if (hasSessionChanged) {
|
||||
const lastUsedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId);
|
||||
const lastUsedChatTypeId =
|
||||
activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId);
|
||||
|
||||
if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) {
|
||||
if (selectedChatTypeId !== lastUsedChatTypeId) {
|
||||
@@ -1865,7 +2266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
|
||||
}, [activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||
}, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !selectedChatTypeId) {
|
||||
@@ -1873,7 +2274,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
}
|
||||
|
||||
setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId);
|
||||
}, [activeSessionId, selectedChatTypeId]);
|
||||
setConversationItems((previous) =>
|
||||
previous.map((item) =>
|
||||
item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId
|
||||
? { ...item, lastChatTypeId: selectedChatTypeId }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
|
||||
const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null;
|
||||
|
||||
if (currentLastChatTypeId === selectedChatTypeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void chatGateway.updateConversation(activeSessionId, {
|
||||
lastChatTypeId: selectedChatTypeId,
|
||||
}).catch(() => {
|
||||
// Ignore background sync failures and keep local in-memory fallback.
|
||||
});
|
||||
}, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat');
|
||||
@@ -1925,7 +2345,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedSessionId && !requestedChatView) {
|
||||
setActiveView('chat');
|
||||
}
|
||||
|
||||
if (!requestedSessionId) {
|
||||
isClosingConversationRef.current = false;
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
return;
|
||||
}
|
||||
@@ -1934,21 +2359,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) {
|
||||
if (isClosingConversationRef.current && requestedSessionId === activeSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
requestedSessionId === activeSessionId &&
|
||||
handledRequestedSessionIdRef.current === requestedSessionId &&
|
||||
activeView === 'chat' &&
|
||||
!isConversationPaneClosed &&
|
||||
(!isMobileViewport || isMobileConversationView)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
handledRequestedSessionIdRef.current = requestedSessionId;
|
||||
|
||||
if (requestedSessionId === activeSessionId) {
|
||||
if (isMobileViewport && !isConversationPaneClosed) {
|
||||
setIsMobileConversationView(true);
|
||||
}
|
||||
openConversationSession(requestedSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
openConversationSession(requestedSessionId);
|
||||
}, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]);
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeView,
|
||||
conversationItems,
|
||||
isConversationListLoading,
|
||||
isConversationPaneClosed,
|
||||
isMobileConversationView,
|
||||
isMobileViewport,
|
||||
location.pathname,
|
||||
requestedChatView,
|
||||
requestedSessionId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestedSessionId) {
|
||||
@@ -2329,6 +2773,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
icon={<CloseOutlined />}
|
||||
aria-label="대화창 닫기"
|
||||
onClick={() => {
|
||||
isClosingConversationRef.current = true;
|
||||
handledRequestedSessionIdRef.current = '';
|
||||
replaceChatSessionInUrl('');
|
||||
setIsConversationPaneClosed(true);
|
||||
@@ -2594,17 +3039,30 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isPreviewModalOpen && Boolean(activePreview)}
|
||||
title={activePreview ? `${activePreview.label} preview` : 'preview'}
|
||||
footer={
|
||||
title={
|
||||
activePreview ? (
|
||||
<div className="app-chat-panel__preview-modal-footer">
|
||||
<Space wrap>
|
||||
<div className="app-chat-panel__preview-modal-title">
|
||||
<span className="app-chat-panel__preview-modal-title-text">{`${activePreview.label} preview`}</span>
|
||||
<Space size={4} wrap>
|
||||
{canSearchActivePreview ? (
|
||||
<Button
|
||||
type={isPreviewFindOpen ? 'default' : 'text'}
|
||||
aria-label="단어 찾기"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => {
|
||||
setIsPreviewFindOpen((current) => !current);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button type="text" aria-label="복사" icon={<CopyOutlined />} onClick={() => void handleCopyActivePreview()} />
|
||||
<Button type="text" aria-label="다운로드" icon={<DownloadOutlined />} onClick={handleDownloadActivePreview} />
|
||||
</Space>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
'preview'
|
||||
)
|
||||
}
|
||||
footer={null}
|
||||
onCancel={() => {
|
||||
setIsPreviewModalOpen(false);
|
||||
}}
|
||||
@@ -2620,13 +3078,52 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
|
||||
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
<div className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal">
|
||||
{canSearchActivePreview && isPreviewFindOpen ? (
|
||||
<div className="app-chat-panel__preview-modal-findbar">
|
||||
<Input
|
||||
ref={previewFindInputRef}
|
||||
size="small"
|
||||
value={previewFindQuery}
|
||||
placeholder="단어 찾기"
|
||||
allowClear
|
||||
onChange={(event) => {
|
||||
setPreviewFindQuery(event.target.value);
|
||||
}}
|
||||
onPressEnter={(event) => {
|
||||
handleFindActivePreview(event.shiftKey ? 'backward' : 'forward');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
handleFindActivePreview('backward');
|
||||
}}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
handleFindActivePreview('forward');
|
||||
}}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
ref={previewSearchRootRef}
|
||||
className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root"
|
||||
onPointerDownCapture={handlePreviewSearchRootPointerDown}
|
||||
>
|
||||
<ChatPreviewBody
|
||||
target={activePreview}
|
||||
previewText={previewText}
|
||||
isPreviewLoading={isPreviewLoading}
|
||||
previewError={previewError}
|
||||
previewContentType={previewContentType}
|
||||
maxMarkdownBlocks={undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -869,6 +870,7 @@ export function MainHeader({
|
||||
void contentExpanded;
|
||||
void onToggleContentExpanded;
|
||||
const screens = useBreakpoint();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -911,9 +913,12 @@ export function MainHeader({
|
||||
const [clientResetFeedback, setClientResetFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [testServerStatus, setTestServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [prodServerStatus, setProdServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatus, setWorkServerStatus] = useState<ServerCommandItem | null>(null);
|
||||
const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false);
|
||||
const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null);
|
||||
const [serverRestartingKey, setServerRestartingKey] = useState<
|
||||
'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null
|
||||
>(null);
|
||||
const [serverRestartFeedback, setServerRestartFeedback] = useState<InlineFeedback | null>(null);
|
||||
const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState<InlineFeedback | null>(null);
|
||||
const { registeredToken, hasAccess } = useTokenAccess();
|
||||
@@ -941,9 +946,11 @@ export function MainHeader({
|
||||
: 'app-header__status-dot--inactive';
|
||||
const testServerPendingUpdateCount =
|
||||
testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0;
|
||||
const prodServerPendingUpdateCount =
|
||||
prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0;
|
||||
const workServerPendingUpdateCount =
|
||||
workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0;
|
||||
const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount;
|
||||
const settingsStatusClassName =
|
||||
totalPendingUpdateCount >= 2
|
||||
? 'app-header__status-dot--inactive'
|
||||
@@ -989,6 +996,8 @@ export function MainHeader({
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.set('topMenu', 'chat');
|
||||
searchParams.set('sessionId', sessionId);
|
||||
searchParams.delete('chatView');
|
||||
searchParams.delete('runtimeRequestId');
|
||||
navigate({
|
||||
pathname: buildChatPath('live'),
|
||||
search: `?${searchParams.toString()}`,
|
||||
@@ -1482,18 +1491,22 @@ export function MainHeader({
|
||||
const refreshServerStatuses = async () => {
|
||||
const items = await fetchServerCommands();
|
||||
const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null;
|
||||
const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null;
|
||||
const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null;
|
||||
setTestServerStatus(nextTestServerStatus);
|
||||
setProdServerStatus(nextProdServerStatus);
|
||||
setWorkServerStatus(nextWorkServerStatus);
|
||||
return {
|
||||
test: nextTestServerStatus,
|
||||
prod: nextProdServerStatus,
|
||||
'work-server': nextWorkServerStatus,
|
||||
} satisfies Record<'test' | 'work-server', ServerCommandItem | null>;
|
||||
} satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>;
|
||||
};
|
||||
|
||||
const refreshUpdateTargets = async (silent = false) => {
|
||||
if (!hasAccess) {
|
||||
setTestServerStatus(null);
|
||||
setProdServerStatus(null);
|
||||
setWorkServerStatus(null);
|
||||
if (!silent) {
|
||||
setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' });
|
||||
@@ -1515,7 +1528,7 @@ export function MainHeader({
|
||||
if (!silent) {
|
||||
setUpdateCheckFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
||||
message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
@@ -1524,7 +1537,7 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => {
|
||||
const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
await waitForDuration(2500);
|
||||
|
||||
@@ -1549,7 +1562,10 @@ export function MainHeader({
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus };
|
||||
return {
|
||||
ok: false,
|
||||
item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus,
|
||||
};
|
||||
};
|
||||
|
||||
const handleResetClientState = async () => {
|
||||
@@ -1589,14 +1605,19 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
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 restartServerWithVerification = async (
|
||||
key: 'test' | 'prod' | 'work-server',
|
||||
busyKey: 'test' | 'prod' | 'work-server' | 'all',
|
||||
) => {
|
||||
const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus;
|
||||
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
||||
|
||||
const result = await restartServerCommand(key);
|
||||
|
||||
if (key === 'test') {
|
||||
setTestServerStatus(result.item);
|
||||
} else if (key === 'prod') {
|
||||
setProdServerStatus(result.item);
|
||||
} else {
|
||||
setWorkServerStatus(result.item);
|
||||
}
|
||||
@@ -1627,7 +1648,7 @@ export function MainHeader({
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleRestartSingleServer = async (key: 'test' | 'work-server') => {
|
||||
const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return false;
|
||||
}
|
||||
@@ -1639,7 +1660,7 @@ export function MainHeader({
|
||||
try {
|
||||
return await restartServerWithVerification(key, key);
|
||||
} catch (error) {
|
||||
const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버';
|
||||
const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버';
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`,
|
||||
@@ -1650,6 +1671,67 @@ export function MainHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRestartProdServer = () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.confirm({
|
||||
title: 'PROD 빌드 반영',
|
||||
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
|
||||
okText: '빌드 및 재기동',
|
||||
cancelText: '취소',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await handleRestartSingleServer('prod');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestartCommandRunner = async () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
setServerRestartCopyFeedback(null);
|
||||
setServerRestartFeedback(null);
|
||||
setServerRestartingKey('command-runner');
|
||||
|
||||
try {
|
||||
const result = await restartServerCommand('command-runner');
|
||||
setServerRestartFeedback({
|
||||
tone: 'success',
|
||||
message:
|
||||
result.restartState === 'accepted'
|
||||
? 'Command runner 배포 및 재기동 요청을 접수했습니다.'
|
||||
: 'Command runner 배포 및 재기동을 완료했습니다.',
|
||||
});
|
||||
} catch (error) {
|
||||
setServerRestartFeedback({
|
||||
tone: 'error',
|
||||
message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.',
|
||||
});
|
||||
} finally {
|
||||
setServerRestartingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmRestartCommandRunner = () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
modalApi.confirm({
|
||||
title: 'Command runner 배포 및 재기동',
|
||||
content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?',
|
||||
okText: '배포 및 재기동',
|
||||
cancelText: '취소',
|
||||
onOk: async () => {
|
||||
await handleRestartCommandRunner();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRestartBothServers = async () => {
|
||||
if (!hasAccess || serverRestartingKey) {
|
||||
return;
|
||||
@@ -1737,7 +1819,7 @@ export function MainHeader({
|
||||
};
|
||||
|
||||
const handleResetNotificationIdentity = () => {
|
||||
Modal.confirm({
|
||||
modalApi.confirm({
|
||||
title: '알림 클라이언트 초기화',
|
||||
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
|
||||
okText: '초기화',
|
||||
@@ -2718,6 +2800,7 @@ export function MainHeader({
|
||||
|
||||
return (
|
||||
<>
|
||||
{modalContextHolder}
|
||||
<Header className="app-header">
|
||||
<Space size={12} className="app-header__row">
|
||||
<Space size={12} className="app-header__menu-side">
|
||||
@@ -3027,6 +3110,24 @@ export function MainHeader({
|
||||
{activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null}
|
||||
{activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null}
|
||||
{activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null}
|
||||
<Divider style={{ marginBlock: 4 }} />
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Text strong>Command runner</Text>
|
||||
<Text type="secondary">
|
||||
별도 명시적 요청이 있을 때만 command runner 배포 및 재기동을 실행합니다.
|
||||
</Text>
|
||||
{renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={serverRestartingKey === 'command-runner' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'command-runner'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={handleConfirmRestartCommandRunner}
|
||||
>
|
||||
command runner 배포 및 재기동
|
||||
</Button>
|
||||
</Space>
|
||||
</>
|
||||
) : null}
|
||||
{activeSettingsModal === 'notification' ? (
|
||||
@@ -3154,6 +3255,17 @@ export function MainHeader({
|
||||
<Text type="secondary">
|
||||
소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
운영
|
||||
<span
|
||||
className={`app-header__server-version-indicator ${getServerVersionStatusClassName(prodServerStatus)}`}
|
||||
aria-label={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||
title={getServerVersionStatusTitle(prodServerStatus, '운영')}
|
||||
style={{ marginInlineStart: 8, verticalAlign: 'middle' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Text>
|
||||
<Text type="secondary">{formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)}</Text>
|
||||
{renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)}
|
||||
<Button
|
||||
block
|
||||
@@ -3188,6 +3300,7 @@ export function MainHeader({
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
서버 재기동
|
||||
</Text>
|
||||
<Text type="secondary">전체 재기동은 TEST와 WORK 서버만 순서대로 진행합니다.</Text>
|
||||
<Text type="secondary">
|
||||
테스트 마지막 확인: {formatDateTimeLabel(testServerStatus?.checkedAt ?? null)}
|
||||
</Text>
|
||||
@@ -3234,6 +3347,24 @@ export function MainHeader({
|
||||
전체 재기동
|
||||
</Button>
|
||||
</Space>
|
||||
<Text strong style={{ marginTop: 8 }}>
|
||||
PROD 빌드 반영
|
||||
</Text>
|
||||
<Text type="secondary">운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)}</Text>
|
||||
<Text type="secondary">
|
||||
PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다.
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
block
|
||||
icon={serverRestartingKey === 'prod' ? <ReloadOutlined spin /> : <ReloadOutlined />}
|
||||
loading={serverRestartingKey === 'prod'}
|
||||
disabled={!canRestartServers}
|
||||
onClick={handleConfirmRestartProdServer}
|
||||
>
|
||||
PROD 빌드 반영
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -392,6 +392,7 @@ function InlineMessagePreview({
|
||||
isPreviewLoading={isPreviewLoading}
|
||||
previewError={previewError}
|
||||
previewContentType={previewContentType}
|
||||
maxMarkdownBlocks={12}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ChatGateway = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -44,7 +45,10 @@ export type ChatGateway = {
|
||||
updateConversation: (
|
||||
sessionId: string,
|
||||
payload: Partial<
|
||||
Pick<ChatConversationSummary, 'title' | 'chatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'>
|
||||
Pick<
|
||||
ChatConversationSummary,
|
||||
'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline'
|
||||
>
|
||||
>,
|
||||
) => Promise<ChatConversationSummary>;
|
||||
deleteConversation: (sessionId: string) => Promise<void>;
|
||||
|
||||
@@ -553,6 +553,7 @@ function InlineMessagePreview({
|
||||
isPreviewLoading={isLoading}
|
||||
previewError={previewError}
|
||||
previewContentType={previewContentType}
|
||||
maxMarkdownBlocks={12}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -209,6 +209,7 @@ type ChatPreviewBodyProps = {
|
||||
isPreviewLoading: boolean;
|
||||
previewError: string;
|
||||
previewContentType?: string;
|
||||
maxMarkdownBlocks?: number;
|
||||
};
|
||||
|
||||
function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) {
|
||||
@@ -236,6 +237,7 @@ export function ChatPreviewBody({
|
||||
isPreviewLoading,
|
||||
previewError,
|
||||
previewContentType,
|
||||
maxMarkdownBlocks,
|
||||
}: ChatPreviewBodyProps) {
|
||||
if (!target) {
|
||||
return <Empty description="preview 가능한 링크가 아직 없습니다." />;
|
||||
@@ -294,7 +296,10 @@ export function ChatPreviewBody({
|
||||
if (target.kind === 'markdown') {
|
||||
return (
|
||||
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">
|
||||
<MarkdownPreviewContent content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'} maxBlocks={12} />
|
||||
<MarkdownPreviewContent
|
||||
content={previewText || '# Preview\n\n표시할 preview 본문이 없습니다.'}
|
||||
maxBlocks={maxMarkdownBlocks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd';
|
||||
import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
cancelChatRuntimeJob,
|
||||
fetchChatRuntimeJobDetail,
|
||||
removeChatRuntimeJob,
|
||||
rollbackChatRuntimeJob,
|
||||
} from './chatUtils';
|
||||
import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types';
|
||||
|
||||
@@ -157,10 +158,14 @@ function RecentRuntimeList({
|
||||
items,
|
||||
onSelectSession,
|
||||
onOpenLog,
|
||||
onRollbackJob,
|
||||
pendingActionRequestId,
|
||||
}: {
|
||||
items: ChatRuntimeSnapshot['recent'];
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onOpenLog: (requestId: string) => void;
|
||||
onRollbackJob: (requestId: string, sessionId: string) => void;
|
||||
pendingActionRequestId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="app-chat-runtime__section app-chat-runtime__section--recent">
|
||||
@@ -181,22 +186,35 @@ function RecentRuntimeList({
|
||||
<Text strong>{buildTerminalLabel(item.terminalStatus)}</Text>
|
||||
<Text type="secondary">{item.mode === 'direct' ? '즉시' : '큐'}</Text>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
<Space size={8} wrap className="app-chat-runtime__job-actions">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onOpenLog(item.requestId);
|
||||
}}
|
||||
>
|
||||
로그
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
disabled={item.terminalStatus !== 'completed'}
|
||||
loading={pendingActionRequestId === item.requestId}
|
||||
onClick={() => {
|
||||
onRollbackJob(item.requestId, item.sessionId);
|
||||
}}
|
||||
>
|
||||
롤백
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onSelectSession(item.sessionId);
|
||||
}}
|
||||
>
|
||||
채팅방 이동
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Text className="app-chat-runtime__job-summary">{item.summary || '요약 없음'}</Text>
|
||||
<div className="app-chat-runtime__job-meta">
|
||||
@@ -233,6 +251,8 @@ export function ChatRuntimeDashboard({
|
||||
onRequestedLogHandled?: () => void;
|
||||
}) {
|
||||
const sessions = snapshot?.sessions ?? [];
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [modalApi, modalContextHolder] = Modal.useModal();
|
||||
const [selectedDetail, setSelectedDetail] = useState<ChatRuntimeJobDetail | null>(null);
|
||||
const [isLogModalOpen, setIsLogModalOpen] = useState(false);
|
||||
const [logLoadError, setLogLoadError] = useState('');
|
||||
@@ -240,6 +260,23 @@ export function ChatRuntimeDashboard({
|
||||
const [pendingActionRequestId, setPendingActionRequestId] = useState<string | null>(null);
|
||||
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const confirmAction = (options: {
|
||||
title: string;
|
||||
content: string;
|
||||
okText: string;
|
||||
cancelText?: string;
|
||||
}) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
modalApi.confirm({
|
||||
title: options.title,
|
||||
content: options.content,
|
||||
okText: options.okText,
|
||||
cancelText: options.cancelText ?? '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
|
||||
const loadLogDetail = async (requestId: string) => {
|
||||
setIsLogLoading(true);
|
||||
setLogLoadError('');
|
||||
@@ -261,15 +298,10 @@ export function ChatRuntimeDashboard({
|
||||
};
|
||||
|
||||
const handleCancel = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '실행 중 요청을 취소할까요?',
|
||||
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
||||
okText: '취소 실행',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
const confirmed = await confirmAction({
|
||||
title: '실행 중 요청을 취소할까요?',
|
||||
content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.',
|
||||
okText: '취소 실행',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
@@ -280,21 +312,22 @@ export function ChatRuntimeDashboard({
|
||||
|
||||
try {
|
||||
await cancelChatRuntimeJob(requestId);
|
||||
messageApi.success('취소 요청을 보냈습니다.');
|
||||
if (selectedDetail?.item?.requestId === requestId) {
|
||||
await loadLogDetail(requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '실행 취소 요청에 실패했습니다.');
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (requestId: string) => {
|
||||
const confirmed = await new Promise<boolean>((resolve) => {
|
||||
Modal.confirm({
|
||||
title: '대기열 요청을 제거할까요?',
|
||||
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
||||
okText: '제거',
|
||||
cancelText: '닫기',
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
const confirmed = await confirmAction({
|
||||
title: '대기열 요청을 제거할까요?',
|
||||
content: '아직 실행되지 않은 대기 요청만 제거됩니다.',
|
||||
okText: '제거',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
@@ -305,6 +338,38 @@ export function ChatRuntimeDashboard({
|
||||
|
||||
try {
|
||||
await removeChatRuntimeJob(requestId);
|
||||
messageApi.success('대기 요청을 제거했습니다.');
|
||||
if (selectedDetail?.item?.requestId === requestId) {
|
||||
await loadLogDetail(requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '대기 요청 제거에 실패했습니다.');
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (requestId: string, sessionId: string) => {
|
||||
const confirmed = await confirmAction({
|
||||
title: '최근 실행 변경을 롤백할까요?',
|
||||
content: '이 실행이 남긴 diff만 역적용합니다. 이후 다른 세션이 같은 라인을 수정했다면 실패할 수 있습니다.',
|
||||
okText: '롤백',
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingActionRequestId(requestId);
|
||||
|
||||
try {
|
||||
await rollbackChatRuntimeJob(requestId, sessionId);
|
||||
messageApi.success('최근 실행 변경을 롤백했습니다.');
|
||||
if (selectedDetail?.item?.requestId === requestId) {
|
||||
await loadLogDetail(requestId);
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.');
|
||||
} finally {
|
||||
setPendingActionRequestId(null);
|
||||
}
|
||||
@@ -362,6 +427,8 @@ export function ChatRuntimeDashboard({
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<div className="app-chat-runtime">
|
||||
<div className="app-chat-runtime__summary-strip">
|
||||
<div className="app-chat-runtime__summary-card">
|
||||
@@ -431,7 +498,13 @@ export function ChatRuntimeDashboard({
|
||||
onRemoveJob={handleRemove}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
<RecentRuntimeList items={snapshot?.recent ?? []} onSelectSession={onSelectSession} onOpenLog={openLog} />
|
||||
<RecentRuntimeList
|
||||
items={snapshot?.recent ?? []}
|
||||
onSelectSession={onSelectSession}
|
||||
onOpenLog={openLog}
|
||||
onRollbackJob={handleRollback}
|
||||
pendingActionRequestId={pendingActionRequestId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1003,6 +1003,19 @@ export async function removeChatRuntimeJob(requestId: string) {
|
||||
return response.removed;
|
||||
}
|
||||
|
||||
export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) {
|
||||
const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>(
|
||||
`/runtime/jobs/${encodeURIComponent(requestId)}/rollback`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: sessionId?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return response.rolledBack;
|
||||
}
|
||||
|
||||
export async function uploadChatComposerFile(sessionId: string, file: File) {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
|
||||
@@ -1028,6 +1041,7 @@ export async function createChatConversationRoom(args: {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
contextLabel?: string;
|
||||
contextDescription?: string;
|
||||
notifyOffline?: boolean;
|
||||
@@ -1040,6 +1054,7 @@ export async function createChatConversationRoom(args: {
|
||||
sessionId: args.sessionId,
|
||||
title: args.title ?? '새 대화',
|
||||
chatTypeId: args.chatTypeId ?? null,
|
||||
lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null,
|
||||
contextLabel: args.contextLabel ?? null,
|
||||
contextDescription: args.contextDescription ?? null,
|
||||
notifyOffline,
|
||||
@@ -1076,6 +1091,7 @@ export async function updateChatConversationRoom(
|
||||
payload: {
|
||||
title?: string;
|
||||
chatTypeId?: string | null;
|
||||
lastChatTypeId?: string | null;
|
||||
contextLabel?: string | null;
|
||||
contextDescription?: string | null;
|
||||
notifyOffline?: boolean;
|
||||
|
||||
@@ -38,6 +38,7 @@ export type ChatConversationSummary = {
|
||||
clientId: string | null;
|
||||
title: string;
|
||||
chatTypeId: string | null;
|
||||
lastChatTypeId: string | null;
|
||||
contextLabel: string | null;
|
||||
contextDescription: string | null;
|
||||
notifyOffline: boolean;
|
||||
|
||||
@@ -93,6 +93,25 @@
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.previewer-ui__findbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: -4px -4px 12px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.previewer-ui__findbar .ant-input-affix-wrapper {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.previewer-ui__action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -124,6 +143,12 @@
|
||||
0 12px 28px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.previewer-ui--expanded .previewer-ui__header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.previewer-ui--expanded .previewer-ui__body {
|
||||
height: calc(100vh - 53px) !important;
|
||||
}
|
||||
@@ -305,3 +330,24 @@
|
||||
.previewer-ui__token--option {
|
||||
color: #c586c0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.previewer-ui__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.previewer-ui__toolbar {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.previewer-ui__findbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.previewer-ui__findbar .ant-btn {
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { Button, Empty, Select, message } from 'antd';
|
||||
import {
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
FullscreenExitOutlined,
|
||||
FullscreenOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Empty, Input, Select, message } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { InlineImage } from '../common/InlineImage';
|
||||
import { CodexDiffBlock } from './CodexDiffBlock';
|
||||
import type { PreviewerUIProps } from './types';
|
||||
@@ -283,13 +289,17 @@ export function PreviewerUI({
|
||||
}: PreviewerUIProps) {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFindOpen, setIsFindOpen] = useState(false);
|
||||
const [findQuery, setFindQuery] = useState('');
|
||||
const findInputRef = useRef<HTMLInputElement | null>(null);
|
||||
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 canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0);
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar);
|
||||
const canFind = type !== 'image' && type !== 'empty';
|
||||
const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || canFind || Boolean(toolbar);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded || typeof document === 'undefined') {
|
||||
@@ -312,6 +322,18 @@ export function PreviewerUI({
|
||||
};
|
||||
}, [isExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
setIsFindOpen(false);
|
||||
setFindQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFindOpen) {
|
||||
window.setTimeout(() => findInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isExpanded, isFindOpen]);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!canCopy) {
|
||||
return;
|
||||
@@ -333,6 +355,44 @@ export function PreviewerUI({
|
||||
setIsExpanded((previous) => !previous);
|
||||
}
|
||||
|
||||
function handleFind(direction: 'forward' | 'backward' = 'forward') {
|
||||
const keyword = findQuery.trim();
|
||||
if (!keyword) {
|
||||
messageApi.info('찾을 단어를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const browserWindow = typeof window === 'undefined' ? null : (window as Window & {
|
||||
find?: (
|
||||
text: string,
|
||||
caseSensitive?: boolean,
|
||||
backwards?: boolean,
|
||||
wrapAround?: boolean,
|
||||
wholeWord?: boolean,
|
||||
searchInFrames?: boolean,
|
||||
showDialog?: boolean,
|
||||
) => boolean;
|
||||
});
|
||||
|
||||
if (!browserWindow || typeof browserWindow.find !== 'function') {
|
||||
messageApi.error('이 브라우저에서는 단어 찾기를 지원하지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = browserWindow.find(keyword, false, direction === 'backward', true, false, false, false);
|
||||
if (!matched) {
|
||||
messageApi.info('일치하는 단어를 찾지 못했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFind() {
|
||||
if (!canFind) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFindOpen((previous) => !previous);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
if (!canDownload) {
|
||||
return;
|
||||
@@ -405,6 +465,16 @@ export function PreviewerUI({
|
||||
onClick={() => void toggleFullscreen()}
|
||||
/>
|
||||
) : null}
|
||||
{canFind ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
className="previewer-ui__action-button"
|
||||
aria-label="단어 찾기"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={toggleFind}
|
||||
/>
|
||||
) : null}
|
||||
{toolbar}
|
||||
</>
|
||||
);
|
||||
@@ -433,6 +503,27 @@ export function PreviewerUI({
|
||||
) : null}
|
||||
<div className="previewer-ui__body previewer-ui__scroll" style={{ height }}>
|
||||
{!showHeader && shouldShowActions ? <div className="previewer-ui__floating-toolbar">{actionContent}</div> : null}
|
||||
{isExpanded && isFindOpen ? (
|
||||
<div className="previewer-ui__findbar">
|
||||
<Input
|
||||
ref={(node) => {
|
||||
findInputRef.current = node?.input ?? null;
|
||||
}}
|
||||
size="small"
|
||||
value={findQuery}
|
||||
placeholder="단어 찾기"
|
||||
allowClear
|
||||
onChange={(event) => setFindQuery(event.target.value)}
|
||||
onPressEnter={(event) => handleFind(event.shiftKey ? 'backward' : 'forward')}
|
||||
/>
|
||||
<Button size="small" onClick={() => handleFind('backward')}>
|
||||
이전
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => handleFind('forward')}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{renderContent({
|
||||
type,
|
||||
value,
|
||||
|
||||
@@ -30,7 +30,6 @@ export function SearchCommandModal({
|
||||
submitHint,
|
||||
}: SearchCommandModalProps) {
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
const selectionLockRef = useRef(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -47,11 +46,10 @@ export function SearchCommandModal({
|
||||
}, [open]);
|
||||
|
||||
const submitOption = (option: SearchKeywordOption | undefined) => {
|
||||
if (!option || selectionLockRef.current) {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionLockRef.current = true;
|
||||
onSelectOption(option);
|
||||
onClose();
|
||||
};
|
||||
@@ -127,7 +125,6 @@ export function SearchCommandModal({
|
||||
return;
|
||||
}
|
||||
|
||||
selectionLockRef.current = false;
|
||||
window.requestAnimationFrame(() => {
|
||||
inputRef.current?.focus({
|
||||
cursor: 'all',
|
||||
|
||||
@@ -108,6 +108,7 @@ export function ServerCommandPage() {
|
||||
const [lastActionByKey, setLastActionByKey] = useState<Record<ServerCommandKey, LastActionInfo>>({
|
||||
test: { output: null, executedAt: '', restartState: 'completed' },
|
||||
rel: { output: null, executedAt: '', restartState: 'completed' },
|
||||
prod: { output: null, executedAt: '', restartState: 'completed' },
|
||||
'work-server': { output: null, executedAt: '', restartState: 'completed' },
|
||||
'command-runner': { output: null, executedAt: '', restartState: 'completed' },
|
||||
});
|
||||
@@ -211,7 +212,7 @@ export function ServerCommandPage() {
|
||||
Server Command
|
||||
</Title>
|
||||
<Paragraph className="server-command-page__copy">
|
||||
TEST, REL, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||
TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다.
|
||||
</Paragraph>
|
||||
<Row gutter={[12, 12]} className="server-command-page__summary-grid">
|
||||
<Col xs={12} md={6}>
|
||||
|
||||
@@ -129,7 +129,7 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem {
|
||||
const item = value as Partial<Record<keyof ServerCommandItem, unknown>>;
|
||||
const key = typeof item.key === 'string' ? item.key : '';
|
||||
|
||||
if (key !== 'test' && key !== 'rel' && key !== 'work-server' && key !== 'command-runner') {
|
||||
if (key !== 'test' && key !== 'rel' && key !== 'prod' && key !== 'work-server' && key !== 'command-runner') {
|
||||
throw new Error('지원하지 않는 서버 키입니다.');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ServerCommandKey = 'test' | 'rel' | 'work-server' | 'command-runner';
|
||||
export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner';
|
||||
|
||||
export type ServerCommandItem = {
|
||||
key: ServerCommandKey;
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
@@ -18,14 +17,12 @@ type SearchLayerContextValue = SearchLayerSnapshot & {
|
||||
};
|
||||
|
||||
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
|
||||
const WINDOW_SELECTION_DEDUP_MS = 500;
|
||||
|
||||
export function SearchLayerProvider({ children }: PropsWithChildren) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
|
||||
const [mode, setMode] = useState<SearchOpenMode>('navigate');
|
||||
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
|
||||
const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null);
|
||||
|
||||
const value = useMemo<SearchLayerContextValue>(
|
||||
() => ({
|
||||
@@ -35,7 +32,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
|
||||
windowSelections,
|
||||
setOptions,
|
||||
openSearch: (nextMode = 'navigate') => {
|
||||
lastWindowSelectionRef.current = null;
|
||||
setMode(nextMode);
|
||||
setOpen(true);
|
||||
},
|
||||
@@ -72,22 +68,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) {
|
||||
onClose={value.closeSearch}
|
||||
onSelectOption={(option) => {
|
||||
if (mode === 'window') {
|
||||
const now = Date.now();
|
||||
const previousSelection = lastWindowSelectionRef.current;
|
||||
|
||||
if (
|
||||
previousSelection &&
|
||||
previousSelection.id === option.id &&
|
||||
now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowSelectionRef.current = {
|
||||
id: option.id,
|
||||
at: now,
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
setWindowSelections((previous) => [
|
||||
...previous,
|
||||
|
||||
Reference in New Issue
Block a user