chore: update live chat and work server changes

This commit is contained in:
2026-04-26 16:37:06 +09:00
parent 63e5d263a7
commit 20a6333ed2
38 changed files with 2078 additions and 2281 deletions

View File

@@ -4,6 +4,7 @@ import { ApisPage } from './pages/ApisPage';
import { ChatPage } from './pages/ChatPage';
import { DocsPage } from './pages/DocsPage';
import { PlansPage } from './pages/PlansPage';
import { PlayPage } from './pages/PlayPage';
import { buildDocsPath, buildPlansPath } from './routes';
export function AppShell() {
@@ -15,8 +16,8 @@ export function AppShell() {
<Route path="apis/:section" element={<ApisPage />} />
<Route path="plans/:section" element={<PlansPage />} />
<Route path="chat/:section" element={<ChatPage />} />
<Route path="play/layout" element={<Navigate to={buildPlansPath('all')} replace />} />
<Route path="play/layout-record/:layoutId" element={<Navigate to={buildPlansPath('all')} replace />} />
<Route path="play/layout" element={<PlayPage />} />
<Route path="play/layout-record/:layoutId" element={<PlayPage />} />
<Route path="*" element={<Navigate to={buildDocsPath()} replace />} />
</Route>
</Routes>

View File

@@ -1,5 +1,6 @@
// @ts-nocheck
import { useEffect, useRef } from 'react';
import { useAppConfig } from './appConfig';
import {
createNotificationMessage,
sendClientNotification,
@@ -175,6 +176,7 @@ function selectNotificationPollingCandidates<
}
export function ChatNotificationBridgeV2() {
const appConfig = useAppConfig();
const notifiedFailedJobKeysRef = useRef<string[]>([]);
const lastPolledCodexMessageIdBySessionRef = useRef<Record<string, number>>({});
const lastFailedRequestKeyBySessionRef = useRef<Record<string, string>>({});
@@ -257,5 +259,9 @@ export function ChatNotificationBridgeV2() {
})
.catch(() => undefined);
};
if (!appConfig.chat.receiveRoomNotifications) {
return null;
}
return null;
}

View File

@@ -164,6 +164,47 @@
background: rgba(255, 255, 255, 0.9);
}
.app-chat-panel__create-conversation-modal {
display: flex;
flex-direction: column;
gap: 14px;
}
.app-chat-panel__create-conversation-options {
width: 100%;
}
.app-chat-panel__create-conversation-space {
display: flex;
width: 100%;
}
.app-chat-panel__create-conversation-option {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 14px;
background: rgba(248, 250, 252, 0.7);
}
.app-chat-panel__create-conversation-option .ant-radio-wrapper {
margin-inline-end: 0;
width: 100%;
}
.app-chat-panel__create-conversation-option-label {
font-weight: 600;
color: #0f172a;
}
.app-chat-panel__create-conversation-option-description {
padding-left: 24px;
white-space: normal;
}
.app-chat-panel__conversation-list-body {
display: flex;
flex: 1;
@@ -843,12 +884,15 @@
.app-chat-panel__resource-strip-list {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
max-width: 100%;
padding: 8px 12px 0;
overflow-x: auto;
overflow-y: hidden;
max-height: min(32vh, 240px);
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
}
@@ -856,6 +900,24 @@
display: none;
}
.app-chat-panel__resource-chip {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
width: 100%;
padding: 6px 8px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
cursor: pointer;
font-size: 11px;
text-align: left;
}
.app-chat-panel__title-input {
width: min(240px, 48vw);
}
@@ -1079,6 +1141,72 @@
}
}
@media (min-width: 768px) and (max-width: 1180px) {
.app-chat-panel .app-chat-panel__title-copy .ant-typography,
.app-chat-panel .app-chat-panel__conversation-header .ant-typography {
font-size: 15px;
}
.app-chat-panel .app-chat-panel__conversation-section-title,
.app-chat-panel .app-chat-panel__conversation-section-count,
.app-chat-panel .app-chat-panel__conversation-item-time,
.app-chat-panel .app-chat-panel__conversation-item-id,
.app-chat-panel .app-chat-panel__conversation-item-status,
.app-chat-panel .app-chat-panel__conversation-item-flag,
.app-chat-panel .app-chat-panel__conversation-item-unread-badge,
.app-chat-panel .app-chat-panel__history-loader,
.app-chat-panel .app-chat-panel__system-status .ant-typography,
.app-chat-panel .app-chat-message__header-meta .ant-typography,
.app-chat-panel .app-chat-message__status,
.app-chat-panel .app-chat-message__request-detail,
.app-chat-panel .app-chat-preview-card__kind,
.app-chat-panel .app-chat-preview-card__kind.ant-typography,
.app-chat-panel .app-chat-panel__composer-queue-order,
.app-chat-panel .app-chat-panel__composer-attachment-pending-label,
.app-chat-panel .app-chat-panel__resource-chip,
.app-chat-panel .app-chat-panel__resource-strip-filter,
.app-chat-panel .app-chat-panel__resource-strip-empty.ant-typography,
.app-chat-panel .app-chat-panel__busy-overlay span {
font-size: 12px;
}
.app-chat-panel .app-chat-panel__conversation-item-title,
.app-chat-panel .app-chat-panel__composer-type .ant-select-selector,
.app-chat-panel .app-chat-panel__composer .ant-btn,
.app-chat-panel .app-chat-panel__composer-actions .ant-typography,
.app-chat-panel .app-chat-panel__composer-attachment-name,
.app-chat-panel .app-chat-preview-card__label,
.app-chat-panel .app-chat-preview-card__label.ant-typography {
font-size: 14px;
}
.app-chat-panel .app-chat-panel__conversation-item-preview,
.app-chat-panel .app-chat-message__header-meta,
.app-chat-panel .app-chat-message__header-meta strong,
.app-chat-panel .app-chat-message__header-meta > span,
.app-chat-panel .app-chat-panel__composer-queue-text,
.app-chat-panel .app-chat-panel__composer-queue-more,
.app-chat-panel .app-chat-panel__preview-modal-close-label {
font-size: 13px;
}
.app-chat-panel .app-chat-message__body {
font-size: 18px;
line-height: 1.6;
}
.app-chat-panel .app-chat-panel__composer .ant-input-textarea textarea,
.app-chat-panel .app-chat-panel__composer textarea.ant-input {
font-size: 19px;
line-height: 1.6;
}
.app-chat-panel .app-chat-panel__composer-input-shell,
.app-chat-panel .app-chat-panel__composer textarea.ant-input {
min-height: 0;
}
}
.app-chat-panel__conversation-header {
display: flex;
align-items: center;
@@ -1213,12 +1341,6 @@
font-size: 11px;
}
.app-chat-panel__system-status--hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.app-chat-panel__system-status-dots {
display: inline-flex;
align-items: center;
@@ -1898,8 +2020,14 @@
}
.app-chat-panel__composer {
gap: 8px;
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding-top: 4px;
padding-right: 10px;
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
padding-left: 10px;
border-top: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 0;
background: rgba(248, 250, 252, 0.94);
@@ -1908,8 +2036,12 @@
.app-chat-panel__composer-input-shell {
position: relative;
display: flex;
align-items: stretch;
flex: none;
width: 100%;
min-width: 0;
min-height: 0;
}
.app-chat-panel__composer-queue {
@@ -2003,20 +2135,28 @@
white-space: nowrap;
}
.app-chat-panel__composer .ant-input-textarea {
.app-chat-panel__composer .ant-input-textarea,
.app-chat-panel__composer textarea.ant-input {
width: 100%;
min-width: 0;
display: block;
align-self: stretch;
flex: none;
min-height: 0;
}
.app-chat-panel__composer .ant-input-textarea textarea {
.app-chat-panel__composer textarea.ant-input {
width: 100%;
font-size: 13px;
line-height: 1.4;
min-height: 88px;
padding: 8px 76px 16px 14px;
height: clamp(64px, 10dvh, 92px);
min-height: clamp(64px, 10dvh, 92px);
padding: 10px 52px 8px 14px;
box-sizing: border-box;
resize: none;
}
.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea {
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 76px;
}
@@ -2060,7 +2200,7 @@
.app-chat-panel__composer-clear.ant-btn {
position: absolute;
right: 10px;
bottom: 10px;
top: 10px;
z-index: 2;
height: 28px;
padding: 0 10px;
@@ -2074,7 +2214,7 @@
transition:
opacity 0.16s ease,
transform 0.16s ease;
transform: translateY(4px);
transform: translateY(-4px);
}
.app-chat-panel__composer-clear.app-chat-panel__composer-clear--visible.ant-btn {
@@ -2093,6 +2233,7 @@
flex-wrap: wrap;
gap: 6px;
width: 100%;
min-height: 0;
}
.app-chat-panel__composer-attachment-chip {
@@ -2107,6 +2248,19 @@
color: #334155;
}
.app-chat-panel__composer-attachment-chip--pending {
border-style: dashed;
background: rgba(239, 246, 255, 0.96);
color: #1d4ed8;
}
.app-chat-panel__composer-attachment-chip--failed {
border-style: solid;
border-color: rgba(239, 68, 68, 0.28);
background: rgba(254, 242, 242, 0.98);
color: #b91c1c;
}
.app-chat-panel__composer-attachment-name {
max-width: min(240px, 52vw);
overflow: hidden;
@@ -2116,6 +2270,14 @@
line-height: 1.3;
}
.app-chat-panel__composer-attachment-pending-label {
flex: none;
font-size: 10px;
line-height: 1.2;
color: inherit;
opacity: 0.78;
}
.app-chat-panel__composer-attachment-remove.ant-btn {
width: 22px;
min-width: 22px;
@@ -2147,13 +2309,11 @@
padding-block: 2px;
}
.app-chat-panel__composer-type-note,
.app-chat-panel__composer-actions .ant-typography {
font-size: 12px;
}
.app-chat-panel__composer-hint,
.app-chat-panel__composer-type-note {
.app-chat-panel__composer-hint {
display: block;
}
@@ -2186,7 +2346,11 @@
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
width: 100%;
padding-top: 2px;
max-height: min(32vh, 240px);
overflow-x: hidden;
overflow-y: auto;
}
.app-chat-panel__resource-strip-filter {
@@ -2211,20 +2375,6 @@
line-height: 1.5;
}
.app-chat-panel__resource-strip .app-chat-preview-card {
margin: 0;
}
.app-chat-panel__resource-strip .app-chat-preview-card__body {
padding-top: 8px;
}
.app-chat-panel__resource-strip .app-chat-panel__preview-rich,
.app-chat-panel__resource-strip .previewer-ui__editor,
.app-chat-panel__resource-strip .previewer-ui__editor-body {
min-height: 0;
}
.app-chat-panel__preview-stage {
display: flex;
min-height: 0;
@@ -2326,15 +2476,30 @@
.app-chat-panel__preview-video,
.app-chat-panel__preview-frame {
width: 100%;
height: 100%;
min-height: 320px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
background: #0f172a;
object-fit: contain;
}
.app-chat-panel__preview-image {
display: block;
height: auto;
max-height: min(72vh, 640px);
margin: 0 auto;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
linear-gradient(135deg, rgba(226, 232, 240, 0.16), rgba(255, 255, 255, 0));
object-position: top center;
}
.app-chat-panel__preview-video {
height: 100%;
background: #0f172a;
}
.app-chat-panel__preview-frame {
height: 100%;
background: #fff;
}
@@ -2373,6 +2538,41 @@
z-index: 1600;
}
.app-chat-panel__preview-modal .ant-modal-close {
position: fixed;
top: 18px;
right: 18px;
inset-inline-end: 18px;
width: auto;
height: auto;
padding: 0;
border-radius: 999px;
background: rgba(15, 23, 42, 0.18);
box-shadow: none;
backdrop-filter: blur(3px);
opacity: 0.46;
transition:
opacity 160ms ease,
background-color 160ms ease;
}
.app-chat-panel__preview-modal .ant-modal-close:hover {
background: rgba(15, 23, 42, 0.28);
opacity: 0.7;
}
.app-chat-panel__preview-modal-close-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
padding: 10px 16px;
color: #fff;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.app-chat-panel__preview-modal .ant-modal-content {
display: flex;
flex-direction: column;
@@ -2487,11 +2687,79 @@
border-radius: 0;
}
.app-chat-panel__preview-modal .app-chat-panel__preview-image {
height: 100%;
max-height: none;
background: #fff;
object-position: center;
}
.app-chat-panel__preview-modal .previewer-ui__editor-body {
max-height: none;
padding-inline: 0;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-content {
background: #fff;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-header,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-meta,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-findbar {
display: none;
}
.app-chat-panel__preview-modal--html-mobile .ant-modal-body,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-modal-body,
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-stage--modal {
padding: 0;
}
.app-chat-panel__preview-stage--html-mobile {
align-items: stretch;
justify-content: stretch;
padding: 0;
overflow: hidden;
}
.app-chat-panel__preview-stage--html-mobile > * {
display: flex;
justify-content: stretch;
width: 100%;
min-height: 100%;
padding: 0;
}
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame {
width: 100%;
height: 100dvh;
min-height: 100dvh;
border: 0;
border-radius: 0;
background: #fff;
box-shadow: none;
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal .ant-modal-close {
top: 12px;
right: 12px;
inset-inline-end: 12px;
}
.app-chat-panel__preview-stage--html-mobile > * {
padding: 0;
}
.app-chat-panel__preview-modal--html-mobile .app-chat-panel__preview-frame {
width: 100%;
height: 100dvh;
min-height: 100dvh;
border-radius: 0;
box-shadow: none;
}
}
@media (max-width: 720px) {
.app-chat-panel__preview-modal-title {
align-items: flex-start;
@@ -2619,16 +2887,34 @@
}
.app-chat-panel__messages,
.app-chat-panel__composer,
.app-chat-panel__preview-stage,
.app-chat-panel__resource-strip {
padding-left: 12px;
padding-right: 12px;
}
.app-chat-panel__composer {
padding-left: 10px;
padding-right: 10px;
padding-top: 4px;
padding-bottom: max(2px, min(env(safe-area-inset-bottom, 0px), 8px));
}
.app-chat-panel__composer textarea.ant-input {
height: clamp(56px, 8.5dvh, 72px);
min-height: clamp(56px, 8.5dvh, 72px);
padding-top: 8px;
padding-bottom: 8px;
}
.app-chat-panel__composer-input-shell--with-queue textarea.ant-input {
padding-top: 64px;
}
.app-chat-panel__resource-strip-list {
overflow: auto;
flex-wrap: nowrap;
max-height: min(30vh, 220px);
overflow-x: hidden;
overflow-y: auto;
padding-bottom: 2px;
}
@@ -3104,11 +3390,19 @@
.chat-v2__conversation-title {
color: #111827;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-v2__conversation-preview {
color: #6b7280;
font-size: 13px;
display: -webkit-box;
overflow: hidden;
line-height: 1.4;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
@media (max-width: 1180px) {

View File

@@ -17,7 +17,7 @@ import {
SearchOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd';
import { Alert, Button, Card, Empty, Input, Modal, Radio, 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 PointerEvent as ReactPointerEvent, type SetStateAction } from 'react';
@@ -33,12 +33,11 @@ import { useRuntimeController } from './chatV2/hooks/useRuntimeController';
import { useConversationViewController } from './chatV2/hooks/useConversationViewController';
import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController';
import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody';
import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl';
import { triggerResourceDownload } from './mainChatPanel/downloadUtils';
import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls';
import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers';
import { extractPreviewItems, isHtmlPreviewItem } from './mainChatPanel/previewItems';
import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess';
import { renderModalWithEnterConfirm } from './modalKeyboard';
import { createNotificationMessage } from './notificationApi';
import { useTokenAccess } from './tokenAccess';
import {
@@ -80,14 +79,10 @@ type ChatTypeOption = {
disabled?: boolean;
};
type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
type PreviewItem = {
type CreateConversationTarget = {
id: string;
label: string;
url: string;
kind: PreviewKind;
source: 'message' | 'context';
name: string;
description: string;
};
type PendingChatRequest = {
@@ -131,6 +126,7 @@ const CHAT_RESTART_EXCLUSION_PATTERNS = [
/\bno restart\b/i,
/\bwithout restart\b/i,
] as const;
const CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS = [0, 350, 900, 1800] as const;
function isStandaloneDisplayMode() {
if (typeof window === 'undefined') {
@@ -159,6 +155,70 @@ function isRestartRequiredResponseText(text: string) {
return CHAT_RESTART_REQUIRED_PATTERNS.some((pattern) => pattern.test(normalized));
}
function hasVisibleCompletedResponseForRequest(
detail: ChatConversationDetailResponse,
requestId: string,
) {
const normalizedRequestId = requestId.trim();
if (!normalizedRequestId) {
return false;
}
const request = detail.requests.find((item) => item.requestId === normalizedRequestId) ?? null;
if (!request || request.status !== 'completed') {
return false;
}
const responseByMessageId =
request.responseMessageId != null
? detail.messages.find((message) => message.id === request.responseMessageId && message.author === 'codex') ?? null
: null;
if (responseByMessageId && !isPreparingChatReplyText(responseByMessageId.text)) {
return true;
}
return detail.messages.some(
(message) =>
message.author === 'codex' &&
message.clientRequestId === normalizedRequestId &&
!isPreparingChatReplyText(message.text),
);
}
function doesConversationDetailSatisfyTerminalRequest(
detail: ChatConversationDetailResponse,
expectation?: { requestId: string; status: 'completed' | 'failed' },
) {
if (!expectation) {
return true;
}
const normalizedRequestId = expectation.requestId.trim();
if (!normalizedRequestId) {
return true;
}
const request = detail.requests.find((item) => item.requestId === normalizedRequestId) ?? null;
if (!request || request.status !== expectation.status) {
return false;
}
if (expectation.status === 'failed') {
return true;
}
return hasVisibleCompletedResponseForRequest(detail, normalizedRequestId);
}
function resolveConversationDefaultTitle(chatType: CreateConversationTarget | null) {
return chatType?.name?.trim() || '새 대화';
}
function buildChatSessionLink(sessionId: string) {
const normalizedSessionId = sessionId.trim();
@@ -519,10 +579,18 @@ function compareConversationItemsByLatestChat(left: ChatConversationSummary, rig
}
function getConversationLatestActivityTime(item: ChatConversationSummary) {
const latestTimestamp = item.lastMessageAt || item.createdAt;
const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0;
const timestamps = [item.lastMessageAt, item.updatedAt, item.createdAt];
let latestTime = 0;
return Number.isFinite(parsedTime) ? parsedTime : 0;
timestamps.forEach((timestamp) => {
const parsedTime = timestamp ? new Date(timestamp).getTime() : 0;
if (Number.isFinite(parsedTime) && parsedTime > latestTime) {
latestTime = parsedTime;
}
});
return latestTime;
}
function getLatestConversationPreviewMessage(messages: ChatMessage[]) {
@@ -700,127 +768,6 @@ function clearLegacyChatMessageStorage() {
window.localStorage.removeItem('main-chat-panel:messages');
}
function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}
function buildPreviewLabel(url: string, source: PreviewItem['source']) {
try {
const parsed = new URL(url);
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1);
if (lastSegment) {
return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment;
}
return source === 'context' ? '현재 화면 미리보기' : parsed.hostname;
} catch {
return source === 'context' ? '현재 화면 미리보기' : url;
}
}
function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
if (!item || item.kind !== 'code') {
return false;
}
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const pathname = parsed.pathname.toLowerCase();
return pathname.endsWith('.html') || pathname.endsWith('.htm');
} catch {
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
return pathname.endsWith('.html') || pathname.endsWith('.htm');
}
}
function extractPreviewItems(messages: ChatMessage[]) {
const seen = new Set<string>();
const items: PreviewItem[] = [];
const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => {
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
const kind = classifyPreviewKind(normalizedUrl);
if (kind === 'file') {
return;
}
if (seen.has(normalizedUrl)) {
return;
}
seen.add(normalizedUrl);
items.push({
id: `${message.id}-${normalizedUrl}`,
label: buildPreviewLabel(normalizedUrl, 'message'),
url: normalizedUrl,
kind,
source: 'message',
});
});
});
return items.slice(0, 12);
}
function mapSystemStatusMessage(text: string) {
const normalized = text.trim();
@@ -962,6 +909,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const [selectedChatTypeId, setSelectedChatTypeId] = useState<string | null>(availableChatTypes[0]?.id ?? null);
const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null;
const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false;
const [isCreateConversationModalOpen, setIsCreateConversationModalOpen] = useState(false);
const [createConversationChatTypeId, setCreateConversationChatTypeId] = useState<string | null>(
availableChatTypes[0]?.id ?? null,
);
const selectedCreateConversationChatType =
availableChatTypes.find((item) => item.id === createConversationChatTypeId) ?? availableChatTypes[0] ?? null;
const requestedSessionId = getSessionIdFromSearch(location.search);
const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search);
const requestedChatView = getRequestedChatViewFromSearch(location.search);
@@ -1015,6 +968,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const loadOlderMessagesRef = useRef<() => void | Promise<void>>(() => {});
const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting');
const shouldRestoreConversationAfterReconnectRef = useRef(false);
const shouldForceStickToBottomOnNextLoadRef = useRef(false);
const lastConversationForegroundResyncAtRef = useRef(0);
const handledRequestedSessionIdRef = useRef('');
const syncedSelectedChatTypeSessionIdRef = useRef<string | null>(null);
const isClosingConversationRef = useRef(false);
@@ -1081,15 +1036,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setNotificationToggleSessionId((current) => (current === sessionId ? null : current));
}
};
const handleCreateConversation = async () => {
const handleCreateConversation = async (chatTypeOverride?: CreateConversationTarget | null) => {
const sessionId = createConversationSessionId();
const now = new Date().toISOString();
const nextConversationChatType =
selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null);
chatTypeOverride ?? (selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null));
const nextConversationTitle = resolveConversationDefaultTitle(nextConversationChatType);
const optimisticItem: ChatConversationSummary = {
sessionId,
clientId: null,
title: '새 대화',
title: nextConversationTitle,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name ?? null,
@@ -1115,7 +1071,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
try {
const item = await chatGateway.createConversation({
sessionId,
title: '새 대화',
title: nextConversationTitle,
chatTypeId: nextConversationChatType?.id ?? null,
lastChatTypeId: nextConversationChatType?.id ?? null,
contextLabel: nextConversationChatType?.name,
@@ -1146,6 +1102,25 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
messageApi.error(error instanceof Error ? error.message : '새 대화를 만들지 못했습니다.');
}
};
const openCreateConversationModal = () => {
if (availableChatTypes.length === 0) {
messageApi.warning('사용 가능한 채팅유형이 없습니다.');
return;
}
setCreateConversationChatTypeId((current) => current ?? selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
setIsCreateConversationModalOpen(true);
};
const handleConfirmCreateConversation = async () => {
if (!selectedCreateConversationChatType) {
messageApi.warning('채팅유형을 먼저 선택하세요.');
return;
}
setIsCreateConversationModalOpen(false);
setSelectedChatTypeId(selectedCreateConversationChatType.id);
await handleCreateConversation(selectedCreateConversationChatType);
};
const upsertRequestItem = (request: ChatConversationRequest) => {
setRequestItems((previous) => {
const existingIndex = previous.findIndex(
@@ -1218,24 +1193,66 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
);
const syncConversationFromServer = useCallback(
async (sessionId: string) => {
async (
sessionId: string,
options?: {
ensureTerminalRequest?: {
requestId: string;
status: 'completed' | 'failed';
};
},
) => {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return;
}
try {
const detail = await chatGateway.getConversationDetail(normalizedSessionId, {
limit: normalizedSessionId === activeSessionId ? Math.max(20, messagesRef.current.length || 0) : 20,
});
syncConversationDetailIntoState(normalizedSessionId, detail);
} catch {
// Ignore background resync failures.
const activeSessionRequestCount = requestItemsRef.current.filter(
(item) => item.sessionId === normalizedSessionId,
).length;
const detailLimit =
normalizedSessionId === activeSessionId
? Math.max(20, messagesRef.current.length || 0, activeSessionRequestCount || 0)
: Math.max(20, activeSessionRequestCount || 0);
for (const delayMs of CHAT_TERMINAL_REQUEST_RESYNC_DELAYS_MS) {
if (delayMs > 0) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
}
try {
const detail = await chatGateway.getConversationDetail(normalizedSessionId, {
limit: detailLimit,
});
syncConversationDetailIntoState(normalizedSessionId, detail);
if (doesConversationDetailSatisfyTerminalRequest(detail, options?.ensureTerminalRequest)) {
return;
}
} catch {
// Ignore background resync failures and keep retrying briefly.
}
}
},
[activeSessionId, syncConversationDetailIntoState],
);
const resyncConversationEntryState = useCallback(() => {
const now = Date.now();
if (now - lastConversationForegroundResyncAtRef.current < 600) {
return;
}
lastConversationForegroundResyncAtRef.current = now;
void reloadConversationItems();
if (activeSessionId.trim()) {
void syncConversationFromServer(activeSessionId);
}
}, [activeSessionId, reloadConversationItems, syncConversationFromServer]);
const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => {
const sessionId = eventSessionId.trim() || activeSessionId;
@@ -1312,9 +1329,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
});
}
window.setTimeout(() => {
void syncConversationFromServer(sessionId);
}, event.status === 'completed' ? 700 : 250);
void syncConversationFromServer(sessionId, {
ensureTerminalRequest: {
requestId: event.requestId,
status: event.status,
},
});
};
const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => {
const sessionId = eventSessionId.trim() || activeSessionId;
@@ -1426,7 +1446,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null;
if (incomingMessage.author === 'codex' && hasMeaningfulCodexResponse && isRestartRequiredResponseText(incomingMessage.text)) {
if (
appConfig.chat.receiveRoomNotifications &&
incomingMessage.author === 'codex' &&
hasMeaningfulCodexResponse &&
isRestartRequiredResponseText(incomingMessage.text)
) {
const restartNotificationKey = `${sessionId}:${incomingMessage.clientRequestId ?? incomingMessage.id}:restart-required`;
if (!notifiedRestartRequirementKeysRef.current.includes(restartNotificationKey)) {
@@ -1460,7 +1485,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}
}
if (incomingMessage.author !== 'codex' || eventConversation?.notifyOffline !== true) {
if (
!appConfig.chat.receiveRoomNotifications ||
incomingMessage.author !== 'codex' ||
eventConversation?.notifyOffline !== true
) {
return;
}
@@ -1637,6 +1666,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
activeSessionId,
oldestLoadedMessageId,
reloadKey: conversationRoomReloadKey,
shouldForceStickToBottomOnNextLoadRef,
connectionState,
captureViewportRestoreSnapshot,
sessionMessageCacheRef,
@@ -1709,6 +1739,41 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
);
const pendingDeleteConversation =
conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null;
useEffect(() => {
if (!pendingContextConfirm && !pendingDeleteConversation) {
return undefined;
}
const handleEnterConfirm = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
const visibleModal = Array.from(document.querySelectorAll<HTMLElement>('.ant-modal-root')).find((element) => {
return element.offsetParent !== null;
});
const shouldIgnoreInteractiveTarget =
activeElement instanceof HTMLElement &&
visibleModal?.contains(activeElement) &&
Boolean(activeElement.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]'));
if (event.key !== 'Enter' || event.isComposing || shouldIgnoreInteractiveTarget) {
return;
}
const okButton = visibleModal?.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn-primary');
if (!okButton || okButton.disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
okButton.click();
};
window.addEventListener('keydown', handleEnterConfirm, true);
return () => {
window.removeEventListener('keydown', handleEnterConfirm, true);
};
}, [pendingContextConfirm, pendingDeleteConversation]);
const {
activePreview,
isPreviewLoading,
@@ -1721,6 +1786,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
} = useConversationViewController({
activeSessionId,
activeView,
isMobileViewport,
previewItems,
selectedChatTypeId,
composerRef,
@@ -1781,6 +1847,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [activePreview, messageApi]);
const isActivePreviewHtml = isHtmlPreviewItem(activePreview);
const isHtmlPreviewFullscreen = isActivePreviewHtml && isPreviewModalOpen;
const canSearchActivePreview =
Boolean(activePreview) &&
@@ -1823,8 +1890,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]);
useEffect(() => {
setIsHtmlPreviewMode(false);
}, [activePreview?.id, isPreviewModalOpen]);
setIsHtmlPreviewMode(isHtmlPreviewItem(activePreview));
}, [activePreview]);
useEffect(() => {
resetActivePreviewSearchState();
@@ -1981,7 +2048,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<span className="app-chat-panel__conversation-item-title">{item.title || '새 대화'}</span>
</span>
<span className="app-chat-panel__conversation-item-time">
{formatConversationListTimestamp(item.lastMessageAt || item.createdAt)}
{formatConversationListTimestamp(item.lastMessageAt || item.updatedAt || item.createdAt)}
</span>
</span>
<span className="app-chat-panel__conversation-item-id">{item.sessionId}</span>
@@ -2116,10 +2183,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsMobileConversationView(true);
}
setActiveView('chat');
setConversationRoomReloadKey((previous) => previous + 1);
resyncConversationEntryState();
return;
}
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
shouldForceStickToBottomOnNextLoadRef.current = sessionId === activeSessionId;
setIsConversationContentLoading(true);
setIsDeferringAuxiliaryChatRequests(true);
setHasOlderMessages(false);
@@ -2177,6 +2247,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setIsResourceStripOpen(false);
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
void reloadConversationItems();
};
useEffect(() => {
@@ -2439,6 +2510,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
setSelectedChatTypeId(availableChatTypes[0]?.id ?? null);
}, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]);
useEffect(() => {
if (createConversationChatTypeId && availableChatTypes.some((item) => item.id === createConversationChatTypeId)) {
return;
}
setCreateConversationChatTypeId(selectedChatType?.id ?? availableChatTypes[0]?.id ?? null);
}, [availableChatTypes, createConversationChatTypeId, selectedChatType?.id]);
useEffect(() => {
if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) {
return;
@@ -2657,6 +2736,49 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
};
}, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]);
useEffect(() => {
if (connectionState !== 'connected' || isDeferringAuxiliaryChatRequests) {
return;
}
if (!shouldRestoreConversationAfterReconnectRef.current) {
return;
}
shouldRestoreConversationAfterReconnectRef.current = false;
resyncConversationEntryState();
if (activeSessionId.trim()) {
setConversationRoomReloadKey((previous) => previous + 1);
}
}, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests, resyncConversationEntryState]);
useEffect(() => {
const handleFocus = () => {
resyncConversationEntryState();
};
const handlePageShow = () => {
resyncConversationEntryState();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
return;
}
resyncConversationEntryState();
};
window.addEventListener('focus', handleFocus);
window.addEventListener('pageshow', handlePageShow);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', handleFocus);
window.removeEventListener('pageshow', handlePageShow);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [resyncConversationEntryState]);
useEffect(() => {
if (connectionState !== 'disconnected') {
return;
@@ -3035,8 +3157,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
icon={<PlusOutlined />}
aria-label="새 대화 생성"
title="새 대화 생성"
disabled={availableChatTypes.length === 0}
onClick={() => {
void handleCreateConversation();
openCreateConversationModal();
}}
/>
<Text type="secondary">{isConversationListLoading ? '불러오는 중' : `${conversationItems.length}`}</Text>
@@ -3143,6 +3266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))}
isResourceStripOpen={isResourceStripOpen}
isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed}
isMobileViewport={isMobileViewport}
isChatTypeSelectionLocked={isChatTypeSelectionLocked}
isComposerAttachmentUploading={isComposerAttachmentUploading}
onViewportScroll={handleViewportScroll}
@@ -3240,11 +3364,64 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
)}
</div>
<Modal
open={isCreateConversationModalOpen}
title="채팅유형 선택"
okText="대화 시작"
cancelText="취소"
zIndex={1700}
okButtonProps={{ disabled: !selectedCreateConversationChatType }}
onCancel={() => {
setIsCreateConversationModalOpen(false);
}}
onOk={() => {
void handleConfirmCreateConversation();
}}
>
{availableChatTypes.length > 0 ? (
<div className="app-chat-panel__create-conversation-modal">
<Text type="secondary">
, .
</Text>
<Radio.Group
className="app-chat-panel__create-conversation-options"
value={selectedCreateConversationChatType?.id}
onChange={(event) => {
setCreateConversationChatTypeId(event.target.value);
}}
>
<Space direction="vertical" size={12} className="app-chat-panel__create-conversation-space">
{availableChatTypes.map((item) => (
<label key={item.id} className="app-chat-panel__create-conversation-option">
<Radio value={item.id}>
<span className="app-chat-panel__create-conversation-option-label">{item.name}</span>
</Radio>
{item.description.trim() ? (
<Text type="secondary" className="app-chat-panel__create-conversation-option-description">
{item.description.split('\n')[0].replace(/^#+\s*/, '')}
</Text>
) : null}
</label>
))}
</Space>
</Radio.Group>
</div>
) : (
<Alert
showIcon
type="warning"
message="사용 가능한 채팅유형이 없습니다."
description="채팅유형 관리에서 현재 사용자 권한으로 사용할 수 있는 유형을 먼저 등록하세요."
/>
)}
</Modal>
<Modal
open={Boolean(pendingContextConfirm)}
title="최근 대화 문맥 일부만 참조됩니다"
okText="확인 후 전송"
cancelText="취소"
okButtonProps={{ autoFocus: true }}
modalRender={renderModalWithEnterConfirm}
zIndex={1690}
onCancel={() => {
setPendingContextConfirm(null);
@@ -3273,7 +3450,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
<Modal
open={isPreviewModalOpen && Boolean(activePreview)}
title={
activePreview ? (
isHtmlPreviewFullscreen
? null
: activePreview ? (
<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>
@@ -3313,17 +3492,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
}}
width="100vw"
zIndex={1600}
className="app-chat-panel__preview-modal"
className={`app-chat-panel__preview-modal${isHtmlPreviewFullscreen ? ' app-chat-panel__preview-modal--html-mobile' : ''}`}
closeIcon={<span className="app-chat-panel__preview-modal-close-label"></span>}
>
{activePreview ? (
<div className="app-chat-panel__preview-modal-body">
<div className="app-chat-panel__preview-modal-meta">
<Space size={[8, 8]} wrap>
<Tag icon={<PaperClipOutlined />}>{activePreview.kind}</Tag>
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
</Space>
</div>
{canSearchActivePreview && isPreviewFindOpen ? (
{!isHtmlPreviewFullscreen ? (
<div className="app-chat-panel__preview-modal-meta">
<Space size={[8, 8]} wrap>
<Tag icon={<PaperClipOutlined />}>{activePreview.kind}</Tag>
<Tag>{activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}</Tag>
</Space>
</div>
) : null}
{!isHtmlPreviewFullscreen && canSearchActivePreview && isPreviewFindOpen ? (
<div className="app-chat-panel__preview-modal-findbar">
<Input
ref={previewFindInputRef}
@@ -3359,7 +3541,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
) : null}
<div
ref={previewSearchRootRef}
className="app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root"
className={`app-chat-panel__preview-stage app-chat-panel__preview-stage--modal app-chat-panel__preview-search-root${
isHtmlPreviewFullscreen ? ' app-chat-panel__preview-stage--html-mobile' : ''
}`}
onPointerDownCapture={handlePreviewSearchRootPointerDown}
>
<ChatPreviewBody
@@ -3382,7 +3566,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
title="대화방을 삭제할까요?"
okText="삭제"
cancelText="취소"
okButtonProps={{ danger: true }}
okButtonProps={{ danger: true, autoFocus: true }}
modalRender={renderModalWithEnterConfirm}
zIndex={1700}
className="app-chat-panel__delete-confirm-modal"
onCancel={() => {

View File

@@ -45,6 +45,7 @@ import {
type AppConfig,
type PlanCostTimeUnit,
} from './appConfig';
import { renderModalWithEnterConfirm } from './modalKeyboard';
import {
fetchWebPushConfig,
registerPwaNotificationToken,
@@ -119,7 +120,9 @@ function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat'])
return (
left.maxContextMessages === right.maxContextMessages &&
left.maxContextChars === right.maxContextChars &&
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds
left.codexLiveMaxExecutionSeconds === right.codexLiveMaxExecutionSeconds &&
left.codexLiveIdleTimeoutSeconds === right.codexLiveIdleTimeoutSeconds &&
left.receiveRoomNotifications === right.receiveRoomNotifications
);
}
@@ -138,6 +141,14 @@ function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['c
changedLabels.push('Codex Live 최대 실행 시간');
}
if (saved.codexLiveIdleTimeoutSeconds !== draft.codexLiveIdleTimeoutSeconds) {
changedLabels.push('Codex Live 무출력 실패 시간');
}
if (saved.receiveRoomNotifications !== draft.receiveRoomNotifications) {
changedLabels.push('채팅방 알림 수신');
}
return changedLabels;
}
@@ -991,6 +1002,31 @@ export function MainHeader({
: totalPendingUpdateCount === 1
? '업데이트 1건 존재'
: '최신 상태';
const headerTopMenuOptions = hasAccess
? [
{
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
},
{
label: isMobileViewport ? <span aria-label="작업"><ProfileOutlined /></span> : '작업',
value: 'plans',
icon: isMobileViewport ? undefined : <ProfileOutlined />,
},
{
label: isMobileViewport ? <span aria-label="Play"><ApiOutlined /></span> : 'Play',
value: 'play',
icon: isMobileViewport ? undefined : <ApiOutlined />,
},
]
: [
{
label: isMobileViewport ? <span aria-label="Docs"><FileMarkdownOutlined /></span> : 'Docs',
value: 'docs',
icon: isMobileViewport ? undefined : <FileMarkdownOutlined />,
},
];
const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0;
const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0;
const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0;
@@ -1713,6 +1749,8 @@ export function MainHeader({
content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?',
okText: '빌드 및 재기동',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
onOk: async () => {
await handleRestartSingleServer('prod');
@@ -1812,6 +1850,8 @@ export function MainHeader({
content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.',
okText: '초기화',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
onOk: () => {
clearNotificationIdentity();
window.location.reload();
@@ -2144,8 +2184,8 @@ export function MainHeader({
message={chatSettingsDirty ? '채팅 문맥 설정에 저장되지 않은 변경이 있습니다.' : '채팅 문맥 설정이 저장되었습니다.'}
description={
chatSettingsDirty
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}초 / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}`
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}까지 허용`
? `변경 항목: ${chatSettingsDiffLabels.join(', ')} / DB 저장값 기준: 최근 ${appConfig.chat.maxContextMessages}개, ${appConfig.chat.maxContextChars}자, 최대 실행 ${appConfig.chat.codexLiveMaxExecutionSeconds}, 무출력 실패 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} / 편집 중: 최근 ${appConfigDraft.chat.maxContextMessages}개, ${appConfigDraft.chat.maxContextChars}자, 최대 실행 ${appConfigDraft.chat.codexLiveMaxExecutionSeconds}, 무출력 실패 ${appConfigDraft.chat.codexLiveIdleTimeoutSeconds}초, 채팅방 알림 ${appConfigDraft.chat.receiveRoomNotifications ? '수신' : '차단'}`
: `현재 저장값: 최근 ${appConfig.chat.maxContextMessages}개 메시지, 최대 ${appConfig.chat.maxContextChars}자까지 채팅 문맥으로 참조하고 Codex Live 실행 시간은 최대 ${appConfig.chat.codexLiveMaxExecutionSeconds}, 무출력 실패 시간은 ${appConfig.chat.codexLiveIdleTimeoutSeconds}초이며 채팅방 알림은 ${appConfig.chat.receiveRoomNotifications ? '수신' : '차단'} 상태`
}
/>
@@ -2194,6 +2234,26 @@ export function MainHeader({
/>
</div>
<div>
<Checkbox
checked={appConfigDraft.chat.receiveRoomNotifications}
onChange={(event) => {
setAppConfigDraft((current) => ({
...current,
chat: {
...current.chat,
receiveRoomNotifications: event.target.checked,
},
}));
}}
>
</Checkbox>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
Codex Live .
</Paragraph>
</div>
<div>
<Text strong>Codex Live ()</Text>
<Paragraph type="secondary">Codex Live 1 .</Paragraph>
@@ -2216,6 +2276,29 @@ export function MainHeader({
}}
/>
</div>
<div>
<Text strong>Codex Live ()</Text>
<Paragraph type="secondary"> .</Paragraph>
<InputNumber
min={30}
max={3600}
step={10}
value={appConfigDraft.chat.codexLiveIdleTimeoutSeconds}
onChange={(value) => {
setAppConfigDraft((current) => ({
...current,
chat: {
...current.chat,
codexLiveIdleTimeoutSeconds:
typeof value === 'number' && Number.isFinite(value)
? Math.min(3600, Math.max(30, Math.round(value)))
: DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds,
},
}));
}}
/>
</div>
</Space>
);
@@ -2855,17 +2938,11 @@ export function MainHeader({
onClick={onToggleSidebar}
/>
<Segmented
className="app-header__top-menu"
value={headerTopMenu}
options={
hasAccess
? [
{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> },
{ label: '작업', value: 'plans', icon: <ProfileOutlined /> },
]
: [{ label: 'Docs', value: 'docs', icon: <FileMarkdownOutlined /> }]
}
options={headerTopMenuOptions}
onChange={(value) => {
onChangeTopMenu(value as 'docs' | 'plans');
onChangeTopMenu(value as 'docs' | 'plans' | 'play');
}}
/>
</Space>

View File

@@ -11,12 +11,24 @@
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
.app-shell:has(.app-chat-panel) > .ant-layout {
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved) > .ant-layout {
min-height: 0;
height: calc(100dvh - 60px);
overflow: hidden;
}
.app-shell--docs-api {
background:
radial-gradient(circle at top left, rgba(22, 93, 255, 0.12), transparent 26%),
@@ -410,6 +422,15 @@
min-width: 0;
}
.app-header__top-menu.ant-segmented {
padding: 4px;
}
.app-header__top-menu .ant-segmented-item {
min-height: 34px;
padding-inline: 14px;
}
.app-sider.ant-layout-sider {
background: rgba(255, 255, 255, 0.72);
border-right: 1px solid rgba(148, 163, 184, 0.14);
@@ -471,6 +492,13 @@
overflow: hidden;
}
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved) {
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
}
.app-main-content--expanded.ant-layout-content {
position: relative;
display: flex;
@@ -488,12 +516,30 @@
min-height: 100%;
}
.app-main-panel--play-saved {
height: 100%;
min-height: calc(100dvh - 60px);
overflow: hidden;
}
.app-main-panel--play > * {
min-width: 0;
min-height: 100%;
width: 100%;
}
.app-main-layout:has(.app-main-panel--play-saved) {
padding: 0;
gap: 0;
overflow: hidden;
}
.app-main-content--expanded.ant-layout-content:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 60px);
padding: 0;
overflow: hidden;
}
.app-main-panel:has(.app-chat-panel) {
height: 100%;
min-height: 100%;
@@ -556,6 +602,16 @@
overflow: hidden;
}
.app-shell:has(.app-main-panel--play-saved),
.app-shell:has(.app-main-panel--play-saved) > .ant-layout,
.app-main-content.ant-layout-content:has(.app-main-panel--play-saved),
.app-main-layout:has(.app-main-panel--play-saved),
.app-main-panel--play-saved {
height: calc(100dvh - 52px);
min-height: calc(100dvh - 52px);
overflow: hidden;
}
.app-header {
padding-inline: 8px;
}
@@ -657,12 +713,13 @@
.app-main-window-layer__body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0;
min-width: 0;
min-height: 100%;
min-height: 0;
padding: 0 !important;
overflow: auto;
overflow: hidden;
}
.app-main-window-layer__fallback {
@@ -765,6 +822,15 @@
gap: 8px;
}
.app-header__top-menu.ant-segmented {
padding: 5px;
}
.app-header__top-menu .ant-segmented-item {
min-height: 34px;
padding-inline: 12px;
}
.app-header__row .ant-btn {
width: 32px;
height: 32px;
@@ -811,6 +877,14 @@
gap: 8px;
}
.app-main-panel--play-saved {
min-height: calc(100dvh - 52px);
}
.app-main-layout:has(.app-main-panel--play-saved) {
min-height: calc(100dvh - 52px);
}
.app-main-layout:has(.chat-type-management-page) {
padding: 0;
gap: 0;

View File

@@ -19,6 +19,8 @@ export type AppConfig = {
maxContextMessages: number;
maxContextChars: number;
codexLiveMaxExecutionSeconds: number;
codexLiveIdleTimeoutSeconds: number;
receiveRoomNotifications: boolean;
};
automation: {
autoRefreshEnabled: boolean;
@@ -72,6 +74,8 @@ export const DEFAULT_APP_CONFIG: AppConfig = {
maxContextMessages: 12,
maxContextChars: 3200,
codexLiveMaxExecutionSeconds: 600,
codexLiveIdleTimeoutSeconds: 180,
receiveRoomNotifications: true,
},
automation: {
autoRefreshEnabled: true,
@@ -253,6 +257,22 @@ function normalizeCodexLiveMaxExecutionSeconds(value: number | undefined, fallba
return Math.min(7200, Math.max(60, Math.round(value)));
}
function normalizeCodexLiveIdleTimeoutSeconds(value: number | undefined, fallback: number) {
if (value === undefined || !Number.isFinite(value)) {
return fallback;
}
return Math.min(3600, Math.max(30, Math.round(value)));
}
function normalizeBooleanValue(value: boolean | undefined, fallback: boolean) {
if (typeof value !== 'boolean') {
return fallback;
}
return value;
}
function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
const chat = raw?.chat;
const automation = raw?.automation;
@@ -272,6 +292,14 @@ function normalizeConfig(raw?: Partial<AppConfig>): AppConfig {
chat?.codexLiveMaxExecutionSeconds,
DEFAULT_APP_CONFIG.chat.codexLiveMaxExecutionSeconds,
),
codexLiveIdleTimeoutSeconds: normalizeCodexLiveIdleTimeoutSeconds(
chat?.codexLiveIdleTimeoutSeconds,
DEFAULT_APP_CONFIG.chat.codexLiveIdleTimeoutSeconds,
),
receiveRoomNotifications: normalizeBooleanValue(
chat?.receiveRoomNotifications,
DEFAULT_APP_CONFIG.chat.receiveRoomNotifications,
),
},
automation: {
autoRefreshEnabled: automation?.autoRefreshEnabled ?? DEFAULT_APP_CONFIG.automation.autoRefreshEnabled,

View File

@@ -33,7 +33,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [
id: 'general-request',
name: '일반 요청',
description:
'## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
'## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat/<chat-session-id>/resource/` 기준으로 제공합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.',
permissions: ['token-user'],
enabled: true,
updatedAt: '2026-04-21T00:00:00.000Z',

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,19 @@ import { useCallback } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
export type ComposerFilePickResult = {
items: {
key: string;
fileName: string;
status: 'uploaded' | 'failed';
reason?: string;
}[];
};
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
type PendingChatRequest = {
sessionId: string;
requestId: string;
@@ -107,9 +120,9 @@ export function useConversationComposerController({
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const handleComposerFilesPicked = useCallback(
async (files: File[]) => {
async (files: File[]): Promise<ComposerFilePickResult> => {
if (files.length === 0 || isComposerAttachmentUploading) {
return;
return { items: [] };
}
setIsComposerAttachmentUploading(true);
@@ -117,7 +130,7 @@ export function useConversationComposerController({
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedFileNames: string[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
@@ -125,7 +138,12 @@ export function useConversationComposerController({
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
const fileName = files[index]?.name || `파일 ${index + 1}`;
const reason =
result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패';
failedItems.push({ fileName, reason });
});
if (uploadedItems.length > 0) {
@@ -134,14 +152,29 @@ export function useConversationComposerController({
setShowScrollToBottom(false);
}
if (failedFileNames.length > 0) {
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
setIsComposerAttachmentUploading(false);
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
};
},
[
activeSessionId,

View File

@@ -28,6 +28,7 @@ type UseConversationRoomDataOptions = {
activeSessionId: string;
oldestLoadedMessageId: number | null;
reloadKey: number;
shouldForceStickToBottomOnNextLoadRef: MutableRefObject<boolean>;
connectionState: 'connecting' | 'connected' | 'disconnected';
captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
@@ -51,6 +52,7 @@ export function useConversationRoomData({
activeSessionId,
oldestLoadedMessageId,
reloadKey,
shouldForceStickToBottomOnNextLoadRef,
connectionState,
captureViewportRestoreSnapshot,
sessionMessageCacheRef,
@@ -93,11 +95,13 @@ export function useConversationRoomData({
const loadConversationDetail = async () => {
const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
const shouldForceStickToBottom = isSessionChanged || shouldForceStickToBottomOnNextLoadRef.current;
previousSessionIdRef.current = requestedSessionId;
captureViewportRestoreSnapshot({
forceStickToBottom: isSessionChanged,
forceStickToBottom: shouldForceStickToBottom,
});
shouldForceStickToBottomOnNextLoadRef.current = false;
pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
@@ -196,6 +200,7 @@ export function useConversationRoomData({
pendingViewportRestoreRef,
reloadKey,
sessionMessageCacheRef,
shouldForceStickToBottomOnNextLoadRef,
setConversationItems,
setConversationLoadingLabel,
setIsConversationContentLoading,

View File

@@ -12,6 +12,7 @@ type PreviewItem = {
type UseConversationViewControllerOptions = {
activeSessionId: string;
activeView: 'chat' | 'runtime' | 'errors';
isMobileViewport: boolean;
previewItems: PreviewItem[];
selectedChatTypeId: string | null;
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
@@ -28,6 +29,7 @@ export function useConversationViewController({
activeSessionId,
activeView,
composerRef,
isMobileViewport,
previewItems,
selectedChatTypeId,
setActiveSystemStatus,
@@ -99,7 +101,12 @@ export function useConversationViewController({
return;
}
if (activePreview.kind === 'image' || activePreview.kind === 'video' || activePreview.kind === 'pdf') {
if (
activePreview.kind === 'image' ||
activePreview.kind === 'video' ||
activePreview.kind === 'pdf' ||
activePreview.kind === 'file'
) {
setPreviewText('');
setPreviewError('');
setPreviewContentType('');
@@ -146,12 +153,12 @@ export function useConversationViewController({
}, [activePreview, isPreviewModalOpen]);
useEffect(() => {
if (activeView !== 'chat') {
if (activeView !== 'chat' || isMobileViewport) {
return;
}
composerRef.current?.focus({ cursor: 'end' });
}, [activeView, composerRef, selectedChatTypeId]);
}, [activeView, composerRef, isMobileViewport, selectedChatTypeId]);
useEffect(() => {
if (activeView !== 'chat') {

View File

@@ -1,5 +1,4 @@
import {
CodeOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
@@ -17,7 +16,7 @@ import {
ThunderboltOutlined,
UpOutlined,
} from '@ant-design/icons';
import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd';
import { Alert, Button, Checkbox, Input, Select, Spin, message } from 'antd';
import type { TextAreaRef } from 'antd/es/input/TextArea';
import {
useEffect,
@@ -32,7 +31,8 @@ import {
} from 'react';
import { InlineImage } from '../../../components/common/InlineImage';
import { CodexDiffBlock } from '../../../components/previewer';
import { ChatPreviewBody, resolveChatPreviewGlyph, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import type { ComposerFilePickResult } from '../chatV2/hooks/useConversationComposerController';
import { ChatPreviewBody, resolveChatPreviewKindLabel, type ChatPreviewKind } from './ChatPreviewBody';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from './previewMarkers';
import { normalizeChatResourceUrl } from './chatResourceUrl';
@@ -40,7 +40,6 @@ import { copyPreviewContent, copyText } from './chatUtils';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from './types';
const KST_TIME_ZONE = 'Asia/Seoul';
const { Text } = Typography;
const KST_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
const KST_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('sv-SE', {
timeZone: KST_TIME_ZONE,
@@ -81,6 +80,13 @@ type InlinePreviewTarget = {
kind: InlinePreviewKind;
};
type PendingComposerUpload = {
key: string;
name: string;
status: 'uploading' | 'uploaded' | 'failed';
reason?: string;
};
type PreviewFetchError = Error & {
status?: number;
};
@@ -171,19 +177,8 @@ function buildPreviewFileName(item: PreviewOption) {
}
}
function normalizePreviewOptionKind(kind: string): ChatPreviewKind {
switch (kind) {
case 'image':
case 'video':
case 'markdown':
case 'code':
case 'diff':
case 'document':
case 'pdf':
return kind;
default:
return 'file';
}
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
async function createPreviewFetchError(response: Response): Promise<PreviewFetchError> {
@@ -459,7 +454,7 @@ function InlineMessagePreview({
return;
}
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf') {
if (target.kind === 'image' || target.kind === 'video' || target.kind === 'pdf' || target.kind === 'file') {
return;
}
@@ -529,9 +524,6 @@ function InlineMessagePreview({
<section className={`app-chat-preview-card${isExpanded ? ' app-chat-preview-card--expanded' : ' app-chat-preview-card--collapsed'}`}>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
{resolveChatPreviewGlyph(target.kind)}
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">{target.label}</span>
<span className="app-chat-preview-card__kind">{resolveChatPreviewKindLabel(target.kind)}</span>
@@ -623,9 +615,6 @@ function DiffMessagePreview({
>
<div className="app-chat-preview-card__header">
<div className="app-chat-preview-card__meta">
<span className="app-chat-preview-card__glyph" aria-hidden="true">
<CodeOutlined />
</span>
<div className="app-chat-preview-card__titles">
<span className="app-chat-preview-card__label">Codex Diff</span>
<span className="app-chat-preview-card__kind">{`diff preview · 파일 ${fileCount}`}</span>
@@ -707,6 +696,7 @@ type ChatConversationViewProps = {
previewItems: PreviewOption[];
isResourceStripOpen: boolean;
isComposerDisabled: boolean;
isMobileViewport: boolean;
isChatTypeSelectionLocked: boolean;
isComposerAttachmentUploading: boolean;
onViewportScroll: () => void;
@@ -714,7 +704,7 @@ type ChatConversationViewProps = {
onViewportTouchMove: (event: TouchEvent<HTMLDivElement>) => void;
onViewportTouchStart: (event: TouchEvent<HTMLDivElement>) => void;
onDraftChange: (value: string) => void;
onPickComposerFiles: (files: File[]) => void | Promise<void>;
onPickComposerFiles: (files: File[]) => ComposerFilePickResult | Promise<ComposerFilePickResult>;
onRemoveComposerAttachment: (attachmentId: string) => void;
onSelectChatType: (value: string) => void;
onSend: () => void;
@@ -753,6 +743,7 @@ export function ChatConversationView({
previewItems,
isResourceStripOpen,
isComposerDisabled,
isMobileViewport,
isChatTypeSelectionLocked,
isComposerAttachmentUploading,
onViewportScroll,
@@ -777,12 +768,12 @@ export function ChatConversationView({
}: ChatConversationViewProps) {
const [expandedMessageIds, setExpandedMessageIds] = useState<number[]>([]);
const [expandedPreviewKey, setExpandedPreviewKey] = useState<string | null>(null);
const [expandedResourcePreviewKey, setExpandedResourcePreviewKey] = useState<string | null>(null);
const [fullscreenPreviewKey, setFullscreenPreviewKey] = useState<string | null>(null);
const [collapsedActivityRequestIds, setCollapsedActivityRequestIds] = useState<string[]>([]);
const [collapsibleMessageIds, setCollapsibleMessageIds] = useState<number[]>([]);
const [showBusyOverlay, setShowBusyOverlay] = useState(false);
const [showLatestResourceOnly, setShowLatestResourceOnly] = useState(true);
const [pendingComposerUploads, setPendingComposerUploads] = useState<PendingComposerUpload[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const activitySectionRefs = useRef(new Map<string, HTMLElement>());
const messageBodyRefs = useRef(new Map<number, HTMLDivElement>());
@@ -841,11 +832,6 @@ export function ChatConversationView({
return [...ordered, ...orphanActivityMessages];
}, [visibleMessages]);
const previewItemsByUrl = useMemo(() => new Map(previewItems.map((item) => [item.url, item])), [previewItems]);
const selectedChatTypeOption = useMemo(
() => chatTypeOptions.find((option) => option.value === selectedChatTypeId) ?? null,
[chatTypeOptions, selectedChatTypeId],
);
const normalizedSelectedChatTypeLabel = selectedChatTypeOption?.label?.trim() ?? '';
const isChatTypeReadonly = useMemo(() => {
if (isChatTypeSelectionLocked) {
return true;
@@ -1064,8 +1050,74 @@ export function ChatConversationView({
};
}, [isComposerAttachmentUploading, isConversationLoading]);
useEffect(() => {
if (pendingComposerUploads.length === 0) {
return;
}
const uploadedAttachmentNames = new Set(
composerAttachments.map((attachment) => attachment.name.trim()).filter(Boolean),
);
const resolvedUploads = pendingComposerUploads.filter(
(item) => item.status === 'uploaded' && uploadedAttachmentNames.has(item.name.trim()),
);
if (resolvedUploads.length > 0) {
const resolvedKeys = new Set(resolvedUploads.map((item) => item.key));
setPendingComposerUploads((current) => current.filter((item) => !resolvedKeys.has(item.key)));
}
}, [composerAttachments, pendingComposerUploads]);
const busyOverlayLabel = '첨부 파일을 처리하는 중입니다.';
const syncPendingComposerUploads = async (files: File[]) => {
const nextPendingUploads = files.map((file) => ({
key: buildComposerFilePickKey(file),
name: file.name,
status: 'uploading' as const,
}));
const pendingKeys = new Set(nextPendingUploads.map((item) => item.key));
setPendingComposerUploads((current) => [
...current.filter((item) => !pendingKeys.has(item.key)),
...nextPendingUploads,
]);
let result: ComposerFilePickResult = { items: [] };
try {
result = (await onPickComposerFiles(files)) ?? { items: [] };
} catch {
result = {
items: nextPendingUploads.map((item) => ({
key: item.key,
fileName: item.name,
status: 'failed',
})),
};
}
const resultByKey = new Map<string, ComposerFilePickResult['items'][number]>(
result.items.map((item) => [item.key, item]),
);
setPendingComposerUploads((current) =>
current.flatMap((item) => {
if (!pendingKeys.has(item.key)) {
return [item];
}
const matched = resultByKey.get(item.key);
if (!matched || matched.status === 'failed') {
return [{ ...item, status: 'failed', reason: matched?.reason }];
}
return [{ ...item, status: 'uploaded', reason: undefined }];
}),
);
};
const handleComposerFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? []);
event.target.value = '';
@@ -1074,7 +1126,7 @@ export function ChatConversationView({
return;
}
void onPickComposerFiles(files);
void syncPendingComposerUploads(files);
};
const handleComposerPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
@@ -1100,9 +1152,69 @@ export function ChatConversationView({
new Map(files.map((file) => [`${file.name}:${file.size}:${file.type}:${file.lastModified}`, file])).values(),
);
void onPickComposerFiles(uniqueFiles);
void syncPendingComposerUploads(uniqueFiles);
};
const dismissPendingComposerUpload = (key: string) => {
setPendingComposerUploads((current) => current.filter((item) => item.key !== key));
};
const composerAttachmentStrip =
pendingComposerUploads.length > 0 || composerAttachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{pendingComposerUploads.map((upload) => (
<div
key={`pending:${upload.key}`}
className={`app-chat-panel__composer-attachment-chip app-chat-panel__composer-attachment-chip--pending${
upload.status === 'failed' ? ' app-chat-panel__composer-attachment-chip--failed' : ''
}`}
title={upload.status === 'failed' ? upload.reason ?? '업로드 실패' : undefined}
>
<span className="app-chat-panel__composer-attachment-name">{upload.name}</span>
<span className="app-chat-panel__composer-attachment-pending-label">
{upload.status === 'failed'
? upload.reason ?? '업로드 실패'
: upload.status === 'uploaded'
? '첨부 반영 중'
: '업로드 중'}
</span>
{upload.status === 'failed' ? (
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${upload.name} 업로드 실패 항목 닫기`}
onClick={() => {
dismissPendingComposerUpload(upload.key);
}}
/>
) : null}
</div>
))}
{composerAttachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
onRemoveComposerAttachment(attachment.id);
}}
/>
</div>
))}
</div>
) : null;
const composerPlaceholder = isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: isMobileViewport
? '메시지를 입력하세요.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.';
const renderActivityCard = (message: ChatMessage) => {
const requestId = message.clientRequestId?.trim() || String(message.id);
const isExpanded = !collapsedActivityRequestIds.includes(requestId);
@@ -1229,22 +1341,17 @@ export function ChatConversationView({
</label>
<div className="app-chat-panel__resource-strip-list">
{visiblePreviewItems.map((item) => (
<InlineMessagePreview
<button
key={item.id}
target={{
label: item.label,
url: item.url,
kind: normalizePreviewOptionKind(item.kind),
type="button"
className="app-chat-panel__resource-chip"
onClick={() => {
onOpenPreview(item.id);
}}
isExpanded={expandedResourcePreviewKey === item.id}
hasModalPreview
onOpenModalPreview={() => {
onOpenPreview(item.id, { fullscreen: true });
}}
onToggle={() => {
setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id));
}}
/>
>
<span title={item.label}>{item.label}</span>
<span>{item.kind}</span>
</button>
))}
</div>
</>
@@ -1491,22 +1598,24 @@ export function ChatConversationView({
</div>
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
<div
className={`app-chat-panel__system-status${
activeSystemStatus ? '' : ' app-chat-panel__system-status--hidden'
}${isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''}`}
>
<span>{activeSystemStatus ?? ''}</span>
{activeSystemStatus && isSystemStatusPending ? (
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
<span className="app-chat-panel__system-status-dot" />
<span className="app-chat-panel__system-status-dot" />
<span className="app-chat-panel__system-status-dot" />
</div>
) : null}
{activeSystemStatus ? (
<div className="app-chat-panel__system-status-slot app-chat-panel__system-status-slot--bottom">
<div
className={`app-chat-panel__system-status${
isSystemStatusPending ? ' app-chat-panel__system-status--pending' : ''
}`}
>
<span>{activeSystemStatus}</span>
{isSystemStatusPending ? (
<div className="app-chat-panel__system-status-dots" aria-label="처리 중">
<span className="app-chat-panel__system-status-dot" />
<span className="app-chat-panel__system-status-dot" />
<span className="app-chat-panel__system-status-dot" />
</div>
) : null}
</div>
</div>
</div>
) : null}
{showScrollToBottom ? (
<div className="app-chat-panel__scroll-jump">
@@ -1544,11 +1653,6 @@ export function ChatConversationView({
disabled={chatTypeOptions.length === 0 || isChatTypeReadonly}
onChange={onSelectChatType}
/>
{normalizedSelectedChatTypeLabel && normalizedSelectedChatTypeLabel !== '일반 요청' ? (
<Text type="secondary" className="app-chat-panel__composer-type-note">
: {normalizedSelectedChatTypeLabel}
</Text>
) : null}
</div>
<div className="app-chat-panel__composer-actions">
<div className="app-chat-panel__composer-action-buttons">
@@ -1578,6 +1682,8 @@ export function ChatConversationView({
/>
) : null}
{composerAttachmentStrip}
<div
className={`app-chat-panel__composer-input-shell${
queuedRequests.length > 0 ? ' app-chat-panel__composer-input-shell--with-queue' : ''
@@ -1616,12 +1722,8 @@ export function ChatConversationView({
<Input.TextArea
ref={composerRef}
value={draft}
autoSize={{ minRows: 3, maxRows: 8 }}
placeholder={
isComposerDisabled
? '권한이 있는 컨텍스트가 없어 입력할 수 없습니다.'
: '메시지를 입력하세요. Ctrl+Enter로 전송하고, 실시간 응답과 preview 링크를 이 대화에서 바로 이어서 다룰 수 있습니다.'
}
autoSize={false}
placeholder={composerPlaceholder}
disabled={isComposerDisabled}
onChange={(event) => {
onDraftChange(event.target.value);
@@ -1632,7 +1734,8 @@ export function ChatConversationView({
return;
}
if (!event.ctrlKey) {
const hasSubmitModifier = event.ctrlKey || event.metaKey;
if (!hasSubmitModifier) {
event.stopPropagation();
return;
}
@@ -1661,30 +1764,11 @@ export function ChatConversationView({
ref={fileInputRef}
type="file"
multiple
accept="image/*,.heic,.heif,.zip,application/zip,application/x-zip-compressed"
className="app-chat-panel__composer-file-input"
onChange={handleComposerFileChange}
/>
</div>
{composerAttachments.length > 0 ? (
<div className="app-chat-panel__composer-attachment-strip" aria-live="polite">
{composerAttachments.map((attachment) => (
<div key={attachment.id} className="app-chat-panel__composer-attachment-chip">
<span className="app-chat-panel__composer-attachment-name">{attachment.name}</span>
<Button
type="text"
size="small"
className="app-chat-panel__composer-attachment-remove"
icon={<CloseOutlined />}
aria-label={`${attachment.name} 첨부 제거`}
onClick={() => {
onRemoveComposerAttachment(attachment.id);
}}
/>
</div>
))}
</div>
) : null}
</div>
</div>
</div>

View File

@@ -42,6 +42,8 @@ export function resolveChatPreviewGlyph(kind: ChatPreviewKind) {
return <FileTextOutlined />;
case 'pdf':
return <FilePdfOutlined />;
case 'file':
return <DownloadOutlined />;
default:
return <LinkOutlined />;
}
@@ -63,6 +65,8 @@ export function resolveChatPreviewKindLabel(kind: ChatPreviewKind) {
return 'document preview';
case 'pdf':
return 'pdf preview';
case 'file':
return 'file download';
default:
return 'resource preview';
}
@@ -322,6 +326,28 @@ export function ChatPreviewBody({
return <iframe title={target.label} src={target.url} className="app-chat-panel__preview-frame" />;
}
if (target.kind === 'file') {
return (
<div className="app-chat-panel__preview-file">
<Paragraph>
. .
</Paragraph>
<Space wrap>
<Button type="text" href={target.url} target="_blank" rel="noreferrer" aria-label="새 탭 열기" icon={<EyeOutlined />} />
<Button
type="text"
aria-label="다운로드"
icon={<DownloadOutlined />}
onClick={() => {
const fileName = target.url.split('/').pop()?.trim() || target.label;
triggerResourceDownload(target.url, fileName);
}}
/>
</Space>
</div>
);
}
if (target.kind === 'markdown') {
return (
<div className="app-chat-panel__preview-rich app-chat-panel__preview-rich--markdown">

View File

@@ -1,6 +1,7 @@
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 { renderModalWithEnterConfirm } from '../modalKeyboard';
import {
cancelChatRuntimeJob,
fetchChatRuntimeJobDetail,
@@ -198,7 +199,7 @@ function RecentRuntimeList({
<Button
size="small"
icon={<UndoOutlined />}
disabled={item.terminalStatus !== 'completed'}
disabled={item.terminalStatus !== 'completed' && item.terminalStatus !== 'failed'}
loading={pendingActionRequestId === item.requestId}
onClick={() => {
onRollbackJob(item.requestId, item.sessionId);
@@ -272,6 +273,8 @@ export function ChatRuntimeDashboard({
content: options.content,
okText: options.okText,
cancelText: options.cancelText ?? '닫기',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
onOk: () => resolve(true),
onCancel: () => resolve(false),
});

View File

@@ -24,20 +24,16 @@ const CHAT_NOTIFY_OFFLINE_STORAGE_PREFIX = 'main-chat-panel:notify-offline:';
const CHAT_SESSION_LAST_TYPE_STORAGE_PREFIX = 'main-chat-panel:last-chat-type:';
const CHAT_INTRO_MESSAGE = '요청은 기본적으로 순차 처리됩니다. 급한 요청만 즉시 실행을 사용하세요.';
const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]';
const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024;
const KST_TIME_ZONE = 'Asia/Seoul';
const CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS = 1500;
const chatSessionLastTypeMemory = new Map<string, string>();
const chatLastEventIdMemory = new Map<string, number>();
const chatOfflineNotificationMemory = new Map<string, boolean>();
let chatClientSessionIdMemory = '';
let localMessageSequence = 0;
let cachedChatConversationList: ChatConversationSummary[] | null = null;
let cachedChatConversationListAt = 0;
let chatConversationListRequestPromise: Promise<ChatConversationSummary[]> | null = null;
export function invalidateChatConversationListCache() {
cachedChatConversationList = null;
cachedChatConversationListAt = 0;
chatConversationListRequestPromise = null;
}
@@ -817,6 +813,16 @@ async function requestChatApi<T>(path: string, init?: RequestInit): Promise<T> {
headers.set('Content-Type', 'application/json');
}
if (method === 'GET') {
if (!headers.has('Cache-Control')) {
headers.set('Cache-Control', 'no-store, no-cache, max-age=0');
}
if (!headers.has('Pragma')) {
headers.set('Pragma', 'no-cache');
}
}
let response: Response;
try {
@@ -894,16 +900,35 @@ async function readFileAsBase64(file: File) {
});
}
export async function fetchChatConversations() {
const now = Date.now();
const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record<string, string> = {
zip: 'application/zip',
heic: 'image/heic',
heif: 'image/heif',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
pdf: 'application/pdf',
};
if (
cachedChatConversationList &&
now - cachedChatConversationListAt < CHAT_CONVERSATION_LIST_CACHE_WINDOW_MS
) {
return cachedChatConversationList;
function resolveUploadMimeType(file: File) {
const normalizedName = String(file.name ?? '').trim().toLowerCase();
const extension = normalizedName.includes('.') ? normalizedName.split('.').pop()?.trim() ?? '' : '';
const normalizedType = String(file.type ?? '').trim().toLowerCase();
if (normalizedType && normalizedType !== 'application/octet-stream') {
return normalizedType;
}
if (extension && FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension]) {
return FALLBACK_UPLOAD_MIME_BY_EXTENSION[extension];
}
return normalizedType || 'application/octet-stream';
}
export async function fetchChatConversations() {
if (chatConversationListRequestPromise) {
return chatConversationListRequestPromise;
}
@@ -911,16 +936,12 @@ export async function fetchChatConversations() {
const clientId = getOrCreateClientId();
chatConversationListRequestPromise = requestChatApi<{ ok: boolean; items: ChatConversationSummary[] }>('/conversations')
.then((response) => {
const items = sortChatConversationSummaries(
return sortChatConversationSummaries(
response.items.map((item) => ({
...item,
notifyOffline: resolveSyncedChatOfflineNotificationSetting(item.sessionId, item.notifyOffline, clientId),
})),
);
cachedChatConversationList = items;
cachedChatConversationListAt = Date.now();
return items;
})
.finally(() => {
chatConversationListRequestPromise = null;
@@ -1026,23 +1047,75 @@ export async function rollbackChatRuntimeJob(requestId: string, sessionId?: stri
export async function uploadChatComposerFile(sessionId: string, file: File) {
const normalizedSessionId = sessionId.trim();
const resolvedMimeType = resolveUploadMimeType(file);
const reportUploadFailure = async (stage: string, error: Error) => {
await reportClientError({
errorType: 'chat:composer-upload',
errorName: error.name,
errorMessage: error.message,
requestMethod: 'POST',
requestPath: '/api/chat/attachments',
context: {
stage,
sessionId: normalizedSessionId || null,
fileName: file.name,
fileSize: file.size,
fileType: file.type || null,
resolvedMimeType,
},
});
};
if (!normalizedSessionId) {
throw new Error('채팅 세션이 준비되지 않았습니다.');
const uploadError = new Error('채팅 세션이 준비되지 않았습니다.');
await reportUploadFailure('validate-session', uploadError);
throw uploadError;
}
const contentBase64 = await readFileAsBase64(file);
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
method: 'POST',
body: JSON.stringify({
sessionId: normalizedSessionId,
fileName: file.name,
mimeType: file.type,
contentBase64,
}),
});
if (file.size <= 0) {
const uploadError = new Error('업로드할 파일 내용을 찾지 못했습니다.');
await reportUploadFailure('validate-file', uploadError);
throw uploadError;
}
return response.item;
if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) {
const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${file.name})`);
await reportUploadFailure('validate-file', uploadError);
throw uploadError;
}
let contentBase64 = '';
try {
contentBase64 = await readFileAsBase64(file);
} catch (error) {
const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.';
const uploadError = new Error(`${message} (${file.name})`);
uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError';
await reportUploadFailure('read-file', uploadError);
throw uploadError;
}
try {
const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', {
method: 'POST',
body: JSON.stringify({
sessionId: normalizedSessionId,
fileName: file.name,
mimeType: resolvedMimeType,
contentBase64,
}),
});
return response.item;
} catch (error) {
const uploadError =
error instanceof Error && error.message.trim()
? error
: new Error(`${file.name} 업로드에 실패했습니다.`);
await reportUploadFailure('upload-request', uploadError);
throw uploadError;
}
}
export async function createChatConversationRoom(args: {

View File

@@ -0,0 +1,131 @@
import { normalizeChatResourceUrl } from './chatResourceUrl';
import { extractAutoDetectedPreviewUrls } from './inlinePreviewUrls';
import { extractHiddenPreviewUrls } from './previewMarkers';
import type { ChatMessage } from './types';
export type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'diff' | 'document' | 'pdf' | 'file';
export type PreviewItem = {
id: string;
label: string;
url: string;
kind: PreviewKind;
source: 'message' | 'context';
};
function normalizePreviewUrl(value: string) {
return normalizeChatResourceUrl(value);
}
function isPreviewRouteUrl(url: string) {
if (typeof window === 'undefined') {
return false;
}
try {
const parsed = new URL(url, window.location.origin);
const pathname = parsed.pathname.toLowerCase();
const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname);
return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol);
} catch {
return false;
}
}
export function classifyPreviewKind(url: string): PreviewKind {
const pathname = url.toLowerCase().split('?')[0] ?? '';
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) {
return 'image';
}
if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) {
return 'video';
}
if (/\.(md|markdown)$/i.test(pathname)) {
return 'markdown';
}
if (/\.(diff|patch)$/i.test(pathname)) {
return 'diff';
}
if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) {
return 'code';
}
if (/\.(txt|log|csv)$/i.test(pathname)) {
return 'document';
}
if (/\.pdf$/i.test(pathname)) {
return 'pdf';
}
if (isPreviewRouteUrl(url)) {
return 'document';
}
return 'file';
}
export function buildPreviewLabel(url: string, source: PreviewItem['source']) {
try {
const parsed = new URL(url);
const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1);
if (lastSegment) {
return source === 'context' ? `현재 화면 · ${lastSegment}` : lastSegment;
}
return source === 'context' ? '현재 화면 미리보기' : parsed.hostname;
} catch {
return source === 'context' ? '현재 화면 미리보기' : url;
}
}
export function isHtmlPreviewItem(item: PreviewItem | null | undefined) {
if (!item || item.kind !== 'code') {
return false;
}
try {
const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid');
const pathname = parsed.pathname.toLowerCase();
return pathname.endsWith('.html') || pathname.endsWith('.htm');
} catch {
const pathname = item.url.toLowerCase().split('?')[0] ?? '';
return pathname.endsWith('.html') || pathname.endsWith('.htm');
}
}
export function extractPreviewItems(messages: ChatMessage[]) {
const seen = new Set<string>();
const items: PreviewItem[] = [];
const orderedMessages = [...messages].reverse();
orderedMessages.forEach((message) => {
const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)];
matches.forEach((matchedUrl) => {
const normalizedUrl = normalizePreviewUrl(matchedUrl);
const kind = classifyPreviewKind(normalizedUrl);
if (seen.has(normalizedUrl)) {
return;
}
seen.add(normalizedUrl);
items.push({
id: `${message.id}-${normalizedUrl}`,
label: buildPreviewLabel(normalizedUrl, 'message'),
url: normalizedUrl,
kind,
source: 'message',
});
});
});
return items.slice(0, 12);
}

View File

@@ -0,0 +1,37 @@
import type React from 'react';
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof HTMLElement
? Boolean(target.closest('button, a, input, textarea, select, [role="button"], [contenteditable="true"]'))
: false;
}
export function renderModalWithEnterConfirm(node: React.ReactNode) {
return (
<div
onKeyDown={(event) => {
const target = event.target;
const shouldIgnoreInteractiveTarget =
target instanceof HTMLElement &&
event.currentTarget.contains(target) &&
isInteractiveTarget(target);
if (event.key !== 'Enter' || event.nativeEvent.isComposing || shouldIgnoreInteractiveTarget) {
return;
}
const okButton = event.currentTarget.querySelector<HTMLButtonElement>('.ant-modal-footer .ant-btn-primary');
if (!okButton || okButton.disabled) {
return;
}
event.preventDefault();
event.stopPropagation();
okButton.click();
}}
>
{node}
</div>
);
}

View File

@@ -5,9 +5,10 @@ import { resolveSavedLayoutIdFromMenuKey } from '../routes';
export function PlayPage() {
const { selectedPlayMenu, setSavedLayouts } = useMainLayoutContext();
const selectedSavedLayoutId = resolveSavedLayoutIdFromMenuKey(selectedPlayMenu);
const panelClassName = selectedSavedLayoutId ? 'app-main-panel app-main-panel--play app-main-panel--play-saved' : 'app-main-panel app-main-panel--play';
return (
<div className="app-main-panel app-main-panel--play">
<div className={panelClassName}>
{selectedPlayMenu === 'layout' ? <LayoutPlaygroundView onSavedLayoutsChange={setSavedLayouts} /> : null}
{selectedSavedLayoutId ? (
<LayoutPlaygroundView savedLayoutViewId={selectedSavedLayoutId} onSavedLayoutsChange={setSavedLayouts} />

View File

@@ -5,7 +5,7 @@ import type { ReactNode } from 'react';
import type { PlanFilterStatus } from '../../features/planBoard';
export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play';
export type HeaderTopMenuKey = 'docs' | 'plans';
export type HeaderTopMenuKey = 'docs' | 'plans' | 'play';
export type ApiSectionKey = 'components' | 'widgets';
export type PlanSectionKey =
| PlanFilterStatus
@@ -385,6 +385,10 @@ export function resolveTopMenuPath(menu: HeaderTopMenuKey, currentDocsFolder: st
return buildDocsPath(currentDocsFolder);
}
if (menu === 'play') {
return buildPlayPath('layout');
}
return buildPlansPath('all');
}

View File

@@ -1,34 +1,32 @@
import { useEffect, useMemo, useState } from 'react';
import { Empty } from 'antd';
import { CodexDiffPreviewer } from '../previewer';
import type { CodexDiffPreviewerFile, CodexDiffPreviewerFileStatus } from '../previewer';
type RawTextModule = () => Promise<string | { default: string }>;
const repoTextModules = {
...import.meta.glob('/src/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/docs/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/scripts/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/etc/**/*.{ts,tsx,js,jsx,css,scss,html,json,md,mjs,yml,yaml,sh}', {
eager: true,
query: '?raw',
import: 'default',
}),
...import.meta.glob('/{README.md,package.json,package-lock.json,docker-compose.yml}', {
eager: true,
query: '?raw',
import: 'default',
}),
} as Record<string, string>;
} as Record<string, RawTextModule>;
const repoImageModules = {
...import.meta.glob('/src/**/*.{png,jpg,jpeg,gif,webp,svg,avif}', {
@@ -183,12 +181,20 @@ function parseRawDiffText(sourceSectionContent: string) {
return matches.map((match) => match[1].trimEnd()).filter(Boolean).join('\n\n');
}
function buildPreviewFiles(entries: ParsedChangedFile[]): CodexDiffPreviewerFile[] {
function normalizeRawTextModuleValue(value: string | { default: string }) {
return typeof value === 'string' ? value : value.default;
}
function buildPreviewFiles(
entries: ParsedChangedFile[],
loadedTextByPath: Record<string, string | undefined>,
): CodexDiffPreviewerFile[] {
return entries.map((entry) => {
const normalizedPath = `/${entry.path}`;
const isPublicFile = entry.path.startsWith('public/');
const publicAssetUrl = isPublicFile ? `/${entry.path.slice('public/'.length)}` : null;
const rawContent = repoTextModules[normalizedPath];
const canLoadRawContent = Boolean(repoTextModules[normalizedPath]);
const rawContent = loadedTextByPath[normalizedPath];
const imageUrl = repoImageModules[normalizedPath] ?? (IMAGE_FILE_PATTERN.test(entry.path) ? publicAssetUrl : null);
const isBinary = entry.status === 'binary' || BINARY_FILE_PATTERN.test(entry.path);
const isImage = IMAGE_FILE_PATTERN.test(entry.path);
@@ -211,11 +217,13 @@ function buildPreviewFiles(entries: ParsedChangedFile[]): CodexDiffPreviewerFile
: isPublicFile
? 'public 디렉터리 파일은 번들 import 없이 정적 URL로만 제공되어 전체 소스 미리보기를 표시하지 않습니다.'
: rawContent ??
(entry.status === 'deleted'
? '삭제된 파일이라 현재 저장소에서 전체 소스를 불러올 수 없습니다.'
: isImage
? '이미지 파일 URL을 현재 저장소에서 찾지 못했습니다.'
: '현재 저장소에서 파일 내용을 불러올 수 없습니다.'),
(canLoadRawContent
? '파일 내용을 불러오는 중입니다.'
: entry.status === 'deleted'
? '삭제된 파일이라 현재 저장소에서 전체 소스를 불러올 수 없습니다.'
: isImage
? '이미지 파일 URL을 현재 저장소에서 찾지 못했습니다.'
: '현재 저장소에서 파일 내용을 불러올 수 없습니다.'),
};
});
}
@@ -238,14 +246,61 @@ export function WorklogSourcePreview({
sourceSectionContent,
filesSectionContent,
}: WorklogSourcePreviewProps) {
const diffText = parseRawDiffText(sourceSectionContent);
const changedFiles = parseChangedFiles(filesSectionContent);
const sourcePaths = mergeSourceEntries(changedFiles, parseSourcePaths(sourceSectionContent));
const files = buildPreviewFiles(sourcePaths);
const diffText = useMemo(() => parseRawDiffText(sourceSectionContent), [sourceSectionContent]);
const sourcePaths = useMemo(() => {
const changedFiles = parseChangedFiles(filesSectionContent);
return mergeSourceEntries(changedFiles, parseSourcePaths(sourceSectionContent));
}, [filesSectionContent, sourceSectionContent]);
const [loadedTextByPath, setLoadedTextByPath] = useState<Record<string, string | undefined>>({});
const files = useMemo(() => buildPreviewFiles(sourcePaths, loadedTextByPath), [loadedTextByPath, sourcePaths]);
const description = diffText
? '변경 파일 기준 전체 소스와 raw diff를 Codex preview 스타일로 전환해 표시합니다.'
: '변경 파일 기준 전체 소스를 Codex preview 스타일로 표시합니다. raw diff는 작업일지 `## 소스` 섹션의 diff 코드블록을 그대로 사용합니다.';
useEffect(() => {
let isMounted = true;
const textModuleEntries = sourcePaths
.map((entry) => `/${entry.path}`)
.filter((path) => repoTextModules[path] && loadedTextByPath[path] === undefined);
if (!textModuleEntries.length) {
return () => {
isMounted = false;
};
}
Promise.all(
textModuleEntries.map(async (path) => {
const value = await repoTextModules[path]();
return [path, normalizeRawTextModuleValue(value)] as const;
}),
)
.then((loadedEntries) => {
if (!isMounted) {
return;
}
setLoadedTextByPath((current) => ({
...current,
...Object.fromEntries(loadedEntries),
}));
})
.catch(() => {
if (!isMounted) {
return;
}
setLoadedTextByPath((current) => ({
...current,
...Object.fromEntries(textModuleEntries.map((path) => [path, '현재 저장소에서 파일 내용을 불러올 수 없습니다.'])),
}));
});
return () => {
isMounted = false;
};
}, [loadedTextByPath, sourcePaths]);
if (!files.length && !diffText) {
return <Empty description="기록된 소스 미리보기가 없습니다." />;
}

View File

@@ -164,6 +164,7 @@ type PlanNoteResource = {
sourcePath: string;
publicUrl: string;
previewType: 'image' | 'document' | 'link';
isProbablyOpenable: boolean;
};
const PLAN_NOTE_RESOURCE_LINE_PATTERN =
@@ -242,6 +243,22 @@ function resolvePlanNoteResourcePreviewType(sourcePath: string): PlanNoteResourc
return 'link';
}
function isProbablyOpenablePlanNoteResource(sourcePath: string) {
const normalized = normalizePlanNoteResourceSourcePath(sourcePath).replace(/^\/+/, '');
if (!normalized || normalized.endsWith('/')) {
return false;
}
const baseName = getPlanNoteResourceBaseName(normalized).trim().toLowerCase();
if (!baseName || baseName === 'resource' || baseName === 'uploads' || baseName === '.codex_chat') {
return false;
}
return baseName.includes('.');
}
function extractPlanNoteResources(note: string) {
const normalizedNote = String(note ?? '');
const lineEntries = normalizedNote
@@ -283,6 +300,7 @@ function extractPlanNoteResources(note: string) {
sourcePath: item.sourcePath,
publicUrl: normalizePlanNoteResourceUrl(item.sourcePath),
previewType: resolvePlanNoteResourcePreviewType(item.sourcePath),
isProbablyOpenable: isProbablyOpenablePlanNoteResource(item.sourcePath),
}));
}
@@ -501,14 +519,36 @@ function ExpandableDetailText({
}
function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] }) {
const [hideInvalidResources, setHideInvalidResources] = useState(true);
const visibleResources = useMemo(
() => resources.filter((resource) => !hideInvalidResources || resource.isProbablyOpenable),
[hideInvalidResources, resources],
);
const hiddenResourceCount = resources.length - visibleResources.length;
return (
<div className="plan-board-page__note-resources">
<Flex justify="space-between" align="center" gap={8} wrap>
<Text strong> </Text>
<Text type="secondary">{resources.length}</Text>
<Space size={8} wrap>
<Text strong> </Text>
<Text type="secondary">{visibleResources.length}</Text>
{hiddenResourceCount > 0 ? (
<Text type="secondary" className="plan-board-page__note-resource-summary">
{hiddenResourceCount}
</Text>
) : null}
</Space>
<Checkbox
checked={hideInvalidResources}
onChange={(event) => {
setHideInvalidResources(event.target.checked);
}}
>
</Checkbox>
</Flex>
<div className="plan-board-page__note-resource-list">
{resources.map((resource) => (
{visibleResources.map((resource) => (
<div key={resource.id} className="plan-board-page__note-resource-card">
<Flex justify="space-between" align="start" gap={12} wrap>
<Space direction="vertical" size={2} style={{ minWidth: 0, flex: 1 }}>
@@ -549,6 +589,9 @@ function PlanNoteResourcePanel({ resources }: { resources: PlanNoteResource[] })
</div>
))}
</div>
{visibleResources.length === 0 ? (
<Text type="secondary"> .</Text>
) : null}
</div>
);
}

View File

@@ -619,7 +619,7 @@ function PlanScheduleDetail({
onCopyText: (text: string) => Promise<void>;
}) {
return (
<Space direction="vertical" size={14} style={{ width: '100%' }}>
<div className="plan-schedule-page__detail">
{selectedItem ? (
<Alert
showIcon
@@ -865,6 +865,6 @@ function PlanScheduleDetail({
/>
</div>
</div>
</Space>
</div>
);
}

View File

@@ -1,13 +1,18 @@
.plan-schedule-page {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
}
.plan-schedule-page__overview,
.plan-schedule-page__list-card,
.plan-schedule-page__editor-card {
display: flex;
flex-direction: column;
min-height: 0;
border: 0;
border-radius: 20px;
box-shadow: none;
@@ -15,9 +20,12 @@
.plan-schedule-page__split {
display: grid;
flex: 1 1 auto;
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
gap: 16px;
min-width: 0;
min-height: 0;
align-items: stretch;
}
.plan-schedule-page__split--stacked {
@@ -27,7 +35,12 @@
.plan-schedule-page__list-card .ant-card-body,
.plan-schedule-page__editor-card .ant-card-body,
.plan-schedule-page__detail-card .ant-card-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.plan-schedule-page__detail-actions.ant-space {
@@ -55,8 +68,14 @@
.plan-schedule-page__list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-right: 4px;
}
.plan-schedule-page__list-item {
@@ -92,9 +111,22 @@
.plan-schedule-page__form {
width: 100%;
display: grid;
flex: 0 0 auto;
gap: 14px;
}
.plan-schedule-page__detail {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 14px;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding-right: 4px;
}
.plan-schedule-page__form > div {
display: flex;
flex-direction: column;
@@ -146,6 +178,7 @@
.plan-schedule-page__notepad.ant-input {
padding: 20px 18px;
padding-bottom: 26px;
line-height: 1.85;
border-radius: 22px;
border: 1px solid rgba(22, 93, 255, 0.1);
@@ -160,9 +193,10 @@
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.82),
0 18px 40px rgba(23, 61, 130, 0.06);
box-sizing: border-box;
overflow-y: auto;
scrollbar-gutter: stable;
resize: vertical;
resize: none;
}
.plan-schedule-page__notepad.ant-input:focus,
@@ -175,6 +209,8 @@
.plan-schedule-page__notepad-frame {
width: 100%;
min-height: 0;
overflow: hidden;
}
.plan-schedule-page__notepad-frame .ant-input-textarea,

View File

@@ -55,6 +55,7 @@
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
position: relative;
border-radius: 28px;
overflow: hidden;
border: 1px solid rgba(245, 158, 11, 0.18);
@@ -90,14 +91,20 @@
.text-memo-widget__input.ant-input,
.text-memo-widget__editor .ant-input {
display: block;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
padding: 10px 18px 20px;
padding: 10px 18px 32px;
box-sizing: border-box;
color: #3f3a2f;
font-size: 16px;
line-height: 38px;
background: transparent;
resize: none;
overflow-y: auto;
overscroll-behavior: contain;
}
.text-memo-widget__editor .ant-input::placeholder {

View File

@@ -1,6 +1,7 @@
import { CheckOutlined, DeleteOutlined, EditOutlined, LeftOutlined, PlusOutlined, RightOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Button, Empty, Input, Modal, message } from 'antd';
import { forwardRef, useEffect, useMemo, useState } from 'react';
import { renderModalWithEnterConfirm } from '../../app/main/modalKeyboard';
import { WidgetShell } from '../core';
import type { WidgetHandle } from '../core';
import './TextMemoWidget.css';
@@ -176,6 +177,8 @@ export const TextMemoWidget = forwardRef<WidgetHandle, TextMemoWidgetProps>(func
: '삭제한 메모는 다시 복구할 수 없습니다.',
okText: '삭제',
cancelText: '취소',
autoFocusButton: 'ok',
modalRender: renderModalWithEnterConfirm,
okButtonProps: { danger: true },
onOk: () => {
if (isDraftOnly) {