feat: update codex live runtime and restart flow

This commit is contained in:
2026-04-23 18:10:43 +09:00
parent b0b9980a6c
commit 6e863feafd
36 changed files with 1636 additions and 358 deletions

View File

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

View File

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

View File

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

View File

@@ -392,6 +392,7 @@ function InlineMessagePreview({
isPreviewLoading={isPreviewLoading}
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={12}
/>
</div>
) : null}

View File

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

View File

@@ -553,6 +553,7 @@ function InlineMessagePreview({
isPreviewLoading={isLoading}
previewError={previewError}
previewContentType={previewContentType}
maxMarkdownBlocks={12}
/>
</div>
) : null}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('지원하지 않는 서버 키입니다.');
}

View File

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

View File

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