From 20a6333ed23c28057a7d230fdb384a4b71be3437 Mon Sep 17 00:00:00 2001 From: how2ice Date: Sun, 26 Apr 2026 16:37:06 +0900 Subject: [PATCH] chore: update live chat and work server changes --- docker-compose.preview.yml | 26 +- etc/commands/server-command/restart-test.sh | 6 +- etc/servers/work-server/README.md | 2 +- etc/servers/work-server/src/routes/chat.ts | 15 + .../src/services/app-config-service.ts | 16 +- .../src/services/chat-room-service.test.ts | 7 + .../src/services/chat-room-service.ts | 14 +- .../src/services/chat-service.test.ts | 28 + .../work-server/src/services/chat-service.ts | 466 +++-- scripts/run-plan-codex-once.mjs | 3 + scripts/run-server-command-runner.mjs | 56 +- src/app/main/AppShell.tsx | 5 +- src/app/main/ChatNotificationBridgeV2.tsx | 6 + src/app/main/MainChatPanel.hotfix.css | 374 +++- src/app/main/MainChatPanel.tsx | 519 +++-- src/app/main/MainHeader.tsx | 101 +- src/app/main/MainLayout.css | 78 +- src/app/main/appConfig.ts | 28 + src/app/main/chatTypeAccess.ts | 2 +- .../components/ConversationRoomPane.tsx | 1723 +---------------- .../useConversationComposerController.ts | 45 +- .../chatV2/hooks/useConversationRoomData.ts | 7 +- .../hooks/useConversationViewController.ts | 13 +- .../mainChatPanel/ChatConversationView.tsx | 272 ++- .../main/mainChatPanel/ChatPreviewBody.tsx | 26 + .../mainChatPanel/ChatRuntimeDashboard.tsx | 5 +- src/app/main/mainChatPanel/chatUtils.ts | 131 +- src/app/main/mainChatPanel/previewItems.ts | 131 ++ src/app/main/modalKeyboard.tsx | 37 + src/app/main/pages/PlayPage.tsx | 3 +- src/app/main/routes.tsx | 6 +- .../markdownPreview/WorklogSourcePreview.tsx | 89 +- src/features/planBoard/PlanBoardPage.tsx | 49 +- src/features/planBoard/PlanSchedulePage.tsx | 4 +- src/features/planBoard/planSchedule.css | 38 +- .../text-memo-widget/TextMemoWidget.css | 9 +- .../text-memo-widget/TextMemoWidget.tsx | 3 + vite.config.ts | 16 +- 38 files changed, 2078 insertions(+), 2281 deletions(-) create mode 100644 src/app/main/mainChatPanel/previewItems.ts create mode 100644 src/app/main/modalKeyboard.tsx diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml index 32abaf9..074c045 100644 --- a/docker-compose.preview.yml +++ b/docker-compose.preview.yml @@ -1,16 +1,28 @@ services: preview-app: container_name: ai-code-app-preview - build: - context: . - dockerfile: Dockerfile.preview + image: node:${NODE_VERSION:-22.22.2}-bookworm + user: "0:0" + working_dir: /app ports: - "${PREVIEW_APP_PORT:-4173}:5173" - extra_hosts: - - "host.docker.internal:host-gateway" + volumes: + - ./:/app + - ./.docker/preview-app/node_modules:/app/node_modules + - ./.docker/preview-app/home:/home/how2ice + networks: + - default + - work-backend environment: + HOME: /home/how2ice + NPM_CONFIG_CACHE: /home/how2ice/.npm PORT: 5173 - APP_DIST_DIR: /tmp/ai-code-test-app-dist - WORK_SERVER_URL: ${WORK_SERVER_URL:-http://host.docker.internal:3100} + WORK_SERVER_URL: ${WORK_SERVER_URL:-http://work-server:3100} VITE_DISABLE_APP_UPDATE: "true" + command: > + sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173 --strictPort" restart: unless-stopped + +networks: + work-backend: + external: true diff --git a/etc/commands/server-command/restart-test.sh b/etc/commands/server-command/restart-test.sh index a8b137b..db7861e 100755 --- a/etc/commands/server-command/restart-test.sh +++ b/etc/commands/server-command/restart-test.sh @@ -12,11 +12,7 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cd "$MAIN_PROJECT_ROOT" if command -v docker >/dev/null 2>&1; then - if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then - exit 0 - fi - - exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE" + exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE" fi if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md index 998199a..ddedea6 100644 --- a/etc/servers/work-server/README.md +++ b/etc/servers/work-server/README.md @@ -59,7 +59,7 @@ npm run server-command:runner 현재 운영 기준에서는 `Codex Live`, 일반 채팅, 일반 작업메모 반영 요청은 **현재 프로젝트 루트의 로컬 작업본을 직접 기준으로 처리**합니다. -`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형과 현재 화면 문맥을 기준으로 동작하고, 자동화 유형 context를 기본 문맥으로 섞지 않습니다. +`Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다. 브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다. diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index 2843830..0f9df76 100755 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -148,7 +148,22 @@ function canViewAllConversations(request: { headers: Record }) return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined); } +function applyChatApiNoStoreHeaders(reply: FastifyReply) { + reply.header('Cache-Control', 'no-store, no-cache, max-age=0, must-revalidate'); + reply.header('Pragma', 'no-cache'); + reply.header('Expires', '0'); + reply.header('Surrogate-Control', 'no-store'); +} + export async function registerChatRoutes(app: FastifyInstance) { + app.addHook('onSend', async (request, reply, payload) => { + if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) { + applyChatApiNoStoreHeaders(reply); + } + + return payload; + }); + app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => { const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index 9254ebe..61d0700 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -6,6 +6,8 @@ const DEFAULT_CHAT_APP_CONFIG = { maxContextMessages: 12, maxContextChars: 3200, codexLiveMaxExecutionSeconds: 600, + codexLiveIdleTimeoutSeconds: 180, + receiveRoomNotifications: true, } as const; type ChatPermissionRole = 'guest' | 'token-user'; @@ -24,7 +26,7 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ id: 'general-request', name: '일반 요청', description: - '## 기본 처리\n- 실제 수정 범위와 방식은 현재 요청 문맥과 AGENTS.md 규칙을 우선 따릅니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 대화방 내용은 `context`로 우선 참조합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//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//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-23T00:00:00.000Z', @@ -234,6 +236,8 @@ export type AppConfigSnapshot = { maxContextMessages?: number; maxContextChars?: number; codexLiveMaxExecutionSeconds?: number; + codexLiveIdleTimeoutSeconds?: number; + receiveRoomNotifications?: boolean; }; automation?: { autoRefreshEnabled?: boolean; @@ -290,6 +294,16 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot { 60, 7200, ), + codexLiveIdleTimeoutSeconds: normalizeIntegerInRange( + chat.codexLiveIdleTimeoutSeconds, + DEFAULT_CHAT_APP_CONFIG.codexLiveIdleTimeoutSeconds, + 30, + 3600, + ), + receiveRoomNotifications: + typeof chat.receiveRoomNotifications === 'boolean' + ? chat.receiveRoomNotifications + : DEFAULT_CHAT_APP_CONFIG.receiveRoomNotifications, }, worklogAutomation: Object.keys(worklogAutomation).length > 0 diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index 0896805..04b77d1 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -4,6 +4,7 @@ import { buildChatConversationRequestPatchFromMessage, isVisibleConversationMessage, mergeChatConversationRequestStatus, + resolveNextConversationContextValue, resolveNextConversationChatTypeId, shouldClearConversationJobState, selectChatConversationResponseCandidate, @@ -27,6 +28,12 @@ test('resolveNextConversationChatTypeId falls back to the stored chat type when assert.equal(resolveNextConversationChatTypeId(null, null), null); }); +test('resolveNextConversationContextValue prefers the requested chat type context', () => { + assert.equal(resolveNextConversationContextValue('old context', 'new context'), 'new context'); + assert.equal(resolveNextConversationContextValue('old context', ' '), 'old context'); + assert.equal(resolveNextConversationContextValue(null, 'new context'), 'new context'); +}); + test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => { assert.equal( buildChatConversationRequestPatchFromMessage({ diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index ec808a8..fd05372 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -1083,12 +1083,8 @@ export async function updateChatConversationContext( client_id: normalizedClientId || current.client_id || null, chat_type_id: nextChatTypeId, last_chat_type_id: nextChatTypeId || payload.lastChatTypeId?.trim() || current.last_chat_type_id || null, - context_label: - currentChatTypeId != null ? current.context_label || null : requestedContextLabel || current.context_label || null, - context_description: - currentChatTypeId != null - ? current.context_description || null - : requestedContextDescription || current.context_description || null, + context_label: resolveNextConversationContextValue(current.context_label, requestedContextLabel), + context_description: resolveNextConversationContextValue(current.context_description, requestedContextDescription), notify_offline: normalizedClientId == null && payload.notifyOffline != null ? payload.notifyOffline @@ -1109,6 +1105,12 @@ export function resolveNextConversationChatTypeId(currentChatTypeId?: string | n return normalizedRequestedChatTypeId ?? normalizedCurrentChatTypeId ?? null; } +export function resolveNextConversationContextValue(currentValue?: string | null, requestedValue?: string | null) { + const normalizedRequestedValue = String(requestedValue ?? '').trim() || null; + const normalizedCurrentValue = String(currentValue ?? '').trim() || null; + return normalizedRequestedValue ?? normalizedCurrentValue ?? null; +} + export async function listChatConversations( clientId?: string | null, limit = 50, diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 8df37b3..446cfde 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -7,6 +7,7 @@ import { env } from '../config/env.js'; import { collectOfflineNotificationClientIds, createActivityLogMessage, + buildAgenticCodexPrompt, extractDiffCodeBlocks, extractCodexStreamText, fitActivityLogLines, @@ -91,6 +92,33 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => { ); }); +test('buildAgenticCodexPrompt treats chat type context as required instructions', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live', + chatTypeLabel: '모바일 검증', + chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.', + }, + '화면 확인해줘', + 'session-a', + { + recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'], + omittedHistoryCount: 2, + }, + ); + + assert.match(prompt, /## 채팅 유형 context 필수 규칙/); + assert.match(prompt, /상위 필수 지시/); + assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/); + assert.match(prompt, /### 반드시 지킬 context 원문/); + assert.match(prompt, /모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다\./); + assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)')); +}); + test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => { const lines = Array.from({ length: 13 }, (_, index) => `# 진행: step ${index + 1}`); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 409aed7..e981ade 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -168,6 +168,7 @@ type ChatSessionState = { text: string; mode: 'queue' | 'direct'; requestedAtMs: number; + context: ChatContext | null; }>; activeRequestCount: number; pendingQueueReleaseEventId: number | null; @@ -894,6 +895,16 @@ function summarizeCodexOutput(output: string) { return lines.slice(-12).join('\n'); } +class ChatRuntimeExecutionError extends Error { + responseText: string; + + constructor(message: string, responseText = '') { + super(message); + this.name = 'ChatRuntimeExecutionError'; + this.responseText = responseText.trim(); + } +} + function summarizeCommand(command: string, limit = 180) { const normalized = String(command ?? '').replace(/\s+/g, ' ').trim(); @@ -1429,7 +1440,41 @@ async function buildRecentChatPromptHistory( }; } -function buildAgenticCodexPrompt( +function cloneChatContext(context: ChatContext | null): ChatContext | null { + return context ? { ...context } : null; +} + +function buildChatTypeInstructionBlock(context: ChatContext | null) { + const chatTypeLabel = context?.chatTypeLabel?.trim() || ''; + const chatTypeDescription = context?.chatTypeDescription?.trim() || ''; + const hasSpecificChatType = Boolean(chatTypeLabel && chatTypeLabel !== '일반 요청'); + const hasContextDescription = Boolean(chatTypeDescription && chatTypeDescription !== '없음'); + + if (!hasSpecificChatType && !hasContextDescription) { + return [ + '## 채팅 유형 context 필수 규칙', + '- 선택된 채팅 유형 context가 없습니다.', + '- 그래도 AGENTS.md와 현재 사용자 요청을 기준으로 처리하되, Plan 자동화용 자동화 유형 context는 섞지 마세요.', + ]; + } + + return [ + '## 채팅 유형 context 필수 규칙', + '- 아래 채팅 유형 context는 선택 사항이나 참고 메모가 아니라 이 Codex Live 실행의 상위 필수 지시입니다.', + '- 사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선하세요.', + '- context 안의 작업 범위, 금지사항, 검증 방식, 답변 스타일, 산출물 규칙을 반드시 지키세요.', + '- context가 모호하면 무시하지 말고, 가장 보수적으로 해석해 지킬 수 있는 범위에서 처리하세요.', + '- 실행 전 내부적으로 context 준수 여부를 점검하고, 최종 답변도 context 기준에 맞추세요.', + '', + '### 선택된 채팅 유형', + `- label: ${chatTypeLabel || '없음'}`, + '', + '### 반드시 지킬 context 원문', + chatTypeDescription || '선택된 채팅 유형 context 원문 없음', + ]; +} + +export function buildAgenticCodexPrompt( context: ChatContext | null, input: string, sessionId: string, @@ -1460,6 +1505,8 @@ function buildAgenticCodexPrompt( '- 채팅에서 파일/문서/이미지/코드 리소스를 제공할 때는 반드시 위 세션 전용 경로를 우선 사용하세요.', '- 새로 생성하는 문서, 이미지, 코드 스니펫 파일, 로그, 산출물은 가능하면 처음부터 위 세션 전용 경로 아래에 만들고, 다른 위치에 만들었다면 최종 응답 전에 위 경로로 이동하거나 복사한 뒤 그 경로를 응답하세요.', '- 원본 파일 경로만 출력해도 서버가 해당 위치를 세션 리소스로 복사해 링크로 바꿀 수 있지만, 가능하면 세션 전용 경로 자체를 직접 사용하세요.', + ...buildChatTypeInstructionBlock(context), + '', '응답 규칙:', '- 사실성보다 추측을 우선하지 마세요. 오늘/최신/건수 질문은 직접 확인하세요.', '- 코드 수정이 필요 없는 질문이면 파일을 수정하지 마세요.', @@ -1468,21 +1515,13 @@ function buildAgenticCodexPrompt( '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', '', - '채팅 유형 문맥(우선 적용):', - context?.chatTypeLabel && context.chatTypeLabel.trim() !== '일반 요청' - ? `- chatTypeLabel: ${context.chatTypeLabel}` - : '- chatTypeLabel: 없음', - `- chatTypeDescription: ${context?.chatTypeDescription ?? '없음'}`, - '- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.', - '- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', - '', '참고 화면 정보:', `- pageTitle: ${context?.pageTitle ?? '없음'}`, `- topMenu: ${context?.topMenu ?? '없음'}`, `- focusedComponentId: ${context?.focusedComponentId ?? '없음'}`, `- pageUrl: ${context?.pageUrl ?? '없음'}`, '', - '최근 대화 문맥:', + '최근 대화 문맥(보조 참조):', ...(recentHistoryLines.length > 0 ? [ ...recentHistoryLines.map((line) => `- ${line}`), @@ -1652,6 +1691,11 @@ async function runAgenticCodexReply( Number.isFinite(appConfig.chat.codexLiveMaxExecutionSeconds) ? Math.min(7200, Math.max(60, Math.round(appConfig.chat.codexLiveMaxExecutionSeconds))) : null; + const codexLiveIdleTimeoutSeconds = + typeof appConfig.chat?.codexLiveIdleTimeoutSeconds === 'number' && + Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds) + ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds))) + : null; const prompt = buildAgenticCodexPrompt(context, input, sessionId, { recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, @@ -1663,6 +1707,23 @@ async function runAgenticCodexReply( let lastProgressText = ''; let completedAgentMessage = ''; let hasIncrementalDelta = false; + const finalizeReplyOutput = async () => { + const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail); + const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId); + + if (!rewrittenOutput) { + return ''; + } + + // If the CLI only produced a final completed event, avoid sending it as one big batch. + if (!hasIncrementalDelta && rewrittenOutput) { + await streamReplyChunks(rewrittenOutput, onProgress); + } else if (rewrittenOutput !== lastProgressText) { + onProgress?.(rewrittenOutput); + } + + return rewrittenOutput; + }; const throwIfCancelled = async () => { if (!isCancellationRequested?.()) { return; @@ -1680,177 +1741,222 @@ async function runAgenticCodexReply( }, }); - await new Promise(async (resolve, reject) => { - const emitProgress = (nextText: string) => { - const normalizedProgress = nextText.trim(); + chatRuntimeService.appendLog( + requestId, + `실행 제한 설정을 적용했습니다. 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, + ); + onActivity?.( + `# 설정: 최대 실행 ${codexLiveMaxExecutionSeconds ?? 600}초 / 무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초`, + ); - if (!normalizedProgress || normalizedProgress === lastProgressText) { - return; - } + try { + await new Promise(async (resolve, reject) => { + const emitProgress = (nextText: string) => { + const normalizedProgress = nextText.trim(); - lastProgressText = normalizedProgress; - streamedOutput = normalizedProgress; - onProgress?.(normalizedProgress); - }; - - try { - await throwIfCancelled(); - const response = await requestCommandRunner('/api/codex-live/execute', { - method: 'POST', - body: JSON.stringify({ - requestId, - sessionId, - repoPath, - prompt, - resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), - uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), - maxExecutionSeconds: codexLiveMaxExecutionSeconds, - }), - }); - - if (!response.ok) { - reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.')); - return; - } - - await throwIfCancelled(); - - if (!response.body) { - reject(new Error('command-runner Codex 스트림이 비어 있습니다.')); - return; - } - - chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.'); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let remoteErrorMessage = ''; - - const handleRunnerLine = (line: string) => { - let parsed: Record; - - try { - parsed = JSON.parse(line) as Record; - } catch { + if (!normalizedProgress || normalizedProgress === lastProgressText) { return; } - const eventType = typeof parsed.type === 'string' ? parsed.type : ''; - - if (eventType === 'started') { - const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null; - chatRuntimeService.attachProcess(requestId, pid); - chatRuntimeService.appendLog( - requestId, - pid ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.', - ); - return; - } - - if (eventType === 'activity') { - const activityLog = String(parsed.line ?? '').trim(); - - if (activityLog) { - chatRuntimeService.appendLog(requestId, activityLog); - onActivity?.(activityLog); - } - return; - } - - if (eventType === 'delta') { - const deltaText = String(parsed.text ?? ''); - - if (deltaText) { - hasIncrementalDelta = true; - emitProgress(`${streamedOutput}${deltaText}`); - } - return; - } - - if (eventType === 'completed') { - completedAgentMessage = String(parsed.text ?? '').trim(); - if (completedAgentMessage && hasIncrementalDelta) { - emitProgress(completedAgentMessage); - } - return; - } - - if (eventType === 'stdout') { - const stdoutLine = String(parsed.line ?? '').trim(); - - if (stdoutLine) { - stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); - chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); - onActivity?.(`[stdout] ${stdoutLine}`); - } - return; - } - - if (eventType === 'stderr') { - const stderrLine = String(parsed.line ?? '').trim(); - - if (stderrLine) { - stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT); - chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`); - onActivity?.(`[stderr] ${stderrLine}`); - } - return; - } - - if (eventType === 'error') { - remoteErrorMessage = String(parsed.message ?? '').trim(); - } + lastProgressText = normalizedProgress; + streamedOutput = normalizedProgress; + onProgress?.(normalizedProgress); }; - while (true) { + try { await throwIfCancelled(); - const { value, done } = await reader.read(); + const response = await requestCommandRunner('/api/codex-live/execute', { + method: 'POST', + body: JSON.stringify({ + requestId, + sessionId, + repoPath, + prompt, + resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), + uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), + maxExecutionSeconds: codexLiveMaxExecutionSeconds, + idleTimeoutSeconds: codexLiveIdleTimeoutSeconds, + }), + }); - if (done) { - break; + if (!response.ok) { + reject(new Error((await response.text()) || 'command-runner Codex 실행 요청에 실패했습니다.')); + return; } - jsonLineBuffer += decoder.decode(value, { stream: true }); - const lines = jsonLineBuffer.split('\n'); - jsonLineBuffer = lines.pop() ?? ''; + await throwIfCancelled(); - for (const rawLine of lines) { - const line = rawLine.trim(); + if (!response.body) { + reject(new Error('command-runner Codex 스트림이 비어 있습니다.')); + return; + } - if (!line) { - continue; + chatRuntimeService.appendLog(requestId, 'Codex 실행을 command-runner API로 요청했습니다.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let remoteErrorMessage = ''; + + const handleRunnerLine = (line: string) => { + let parsed: Record; + + try { + parsed = JSON.parse(line) as Record; + } catch { + return; } - handleRunnerLine(line); + const eventType = typeof parsed.type === 'string' ? parsed.type : ''; + + if (eventType === 'started') { + const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null; + const appliedIdleTimeoutSeconds = + typeof parsed.configuredIdleTimeoutSeconds === 'number' && + Number.isFinite(parsed.configuredIdleTimeoutSeconds) + ? Math.round(parsed.configuredIdleTimeoutSeconds) + : null; + const appliedMaxExecutionSeconds = + typeof parsed.configuredMaxExecutionSeconds === 'number' && + Number.isFinite(parsed.configuredMaxExecutionSeconds) + ? Math.round(parsed.configuredMaxExecutionSeconds) + : null; + chatRuntimeService.attachProcess(requestId, pid); + chatRuntimeService.appendLog( + requestId, + pid + ? `호스트 command-runner에서 Codex 프로세스를 시작했습니다. pid=${pid}` + : '호스트 command-runner에서 Codex 프로세스를 시작했습니다.', + ); + if (appliedMaxExecutionSeconds != null || appliedIdleTimeoutSeconds != null) { + const appliedSummary = + `command-runner 적용값: 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; + chatRuntimeService.appendLog(requestId, appliedSummary); + onActivity?.(`# ${appliedSummary}`); + + if ( + (appliedMaxExecutionSeconds != null && + codexLiveMaxExecutionSeconds != null && + appliedMaxExecutionSeconds !== codexLiveMaxExecutionSeconds) || + (appliedIdleTimeoutSeconds != null && + codexLiveIdleTimeoutSeconds != null && + appliedIdleTimeoutSeconds !== codexLiveIdleTimeoutSeconds) + ) { + const mismatchSummary = + `설정 불일치 감지: 요청값 최대 ${codexLiveMaxExecutionSeconds ?? 600}초 / ` + + `무출력 실패 ${codexLiveIdleTimeoutSeconds ?? 180}초, ` + + `실제 적용값 최대 ${appliedMaxExecutionSeconds ?? codexLiveMaxExecutionSeconds ?? 600}초 / ` + + `무출력 실패 ${appliedIdleTimeoutSeconds ?? codexLiveIdleTimeoutSeconds ?? 180}초`; + chatRuntimeService.appendLog(requestId, mismatchSummary); + onActivity?.(`# 경고: ${mismatchSummary}`); + } + } + return; + } + + if (eventType === 'activity') { + const activityLog = String(parsed.line ?? '').trim(); + + if (activityLog) { + chatRuntimeService.appendLog(requestId, activityLog); + onActivity?.(activityLog); + } + return; + } + + if (eventType === 'delta') { + const deltaText = String(parsed.text ?? ''); + + if (deltaText) { + hasIncrementalDelta = true; + emitProgress(`${streamedOutput}${deltaText}`); + } + return; + } + + if (eventType === 'completed') { + completedAgentMessage = String(parsed.text ?? '').trim(); + if (completedAgentMessage && hasIncrementalDelta) { + emitProgress(completedAgentMessage); + } + return; + } + + if (eventType === 'stdout') { + const stdoutLine = String(parsed.line ?? '').trim(); + + if (stdoutLine) { + stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); + chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); + onActivity?.(`[stdout] ${stdoutLine}`); + } + return; + } + + if (eventType === 'stderr') { + const stderrLine = String(parsed.line ?? '').trim(); + + if (stderrLine) { + stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT); + chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`); + onActivity?.(`[stderr] ${stderrLine}`); + } + return; + } + + if (eventType === 'error') { + remoteErrorMessage = String(parsed.message ?? '').trim(); + } + }; + + while (true) { + await throwIfCancelled(); + const { value, done } = await reader.read(); + + if (done) { + break; + } + + jsonLineBuffer += decoder.decode(value, { stream: true }); + const lines = jsonLineBuffer.split('\n'); + jsonLineBuffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line) { + continue; + } + + handleRunnerLine(line); + } } - } - const trailingLine = jsonLineBuffer.trim(); - if (trailingLine) { - handleRunnerLine(trailingLine); - } + const trailingLine = jsonLineBuffer.trim(); + if (trailingLine) { + handleRunnerLine(trailingLine); + } - if (remoteErrorMessage) { - reject(new Error(remoteErrorMessage)); - return; - } + if (remoteErrorMessage) { + reject(new Error(remoteErrorMessage)); + return; + } - resolve(); - } catch (error) { - reject(error); + resolve(); + } catch (error) { + reject(error); + } + }); + } catch (error) { + const failureResponseText = await finalizeReplyOutput(); + + if (failureResponseText) { + throw new ChatRuntimeExecutionError(error instanceof Error ? error.message : 'Codex 실행에 실패했습니다.', failureResponseText); } - }); - const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail); - const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId); - - // If the CLI only produced a final completed event, avoid sending it as one big batch. - if (!hasIncrementalDelta && rewrittenOutput) { - await streamReplyChunks(rewrittenOutput, onProgress); - } else if (rewrittenOutput !== lastProgressText) { - onProgress?.(rewrittenOutput); + throw error; } - return rewrittenOutput; + return await finalizeReplyOutput(); } async function getTodayAutomationRegistrationCounts() { @@ -3066,6 +3172,15 @@ export class ChatService { }), ...contextOverride, }; + void updateChatConversationContext(state.sessionId, { + clientId: state.clientId, + chatTypeId: state.context.chatTypeId ?? null, + lastChatTypeId: state.context.chatTypeId ?? null, + contextLabel: state.context.chatTypeLabel ?? null, + contextDescription: state.context.chatTypeDescription ?? null, + }).catch((error: unknown) => { + this.logger.error(error, 'failed to persist chat context from message send'); + }); } this.normalizeSessionExecutionState(state); @@ -3078,6 +3193,7 @@ export class ChatService { text: trimmed, mode, requestedAtMs, + context: cloneChatContext(state.context), }; if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) { @@ -3129,6 +3245,7 @@ export class ChatService { text: string; mode: 'queue' | 'direct'; requestedAtMs: number; + context: ChatContext | null; }, ) { let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; @@ -3258,7 +3375,7 @@ export class ChatService { }); const reply = await buildCodexReply( - session.context ?? null, + request.context ?? session.context ?? null, request.text, session.sessionId, request.requestId, @@ -3334,6 +3451,37 @@ export class ChatService { } catch (error) { const wasCancelled = this.cancelledRequestIds.has(request.requestId); terminalStatus = wasCancelled ? 'cancelled' : 'failed'; + const failureResponseText = + error instanceof ChatRuntimeExecutionError ? error.responseText : ''; + + if (failureResponseText) { + const failedCodexReplyMessage = { + ...codexReplyMessage, + text: failureResponseText, + timestamp: resolveResponseTimestamp(request.requestedAtMs), + }; + + this.sendToSession( + session, + { + type: 'chat:message', + payload: failedCodexReplyMessage, + }, + { + skipOfflineNotification: true, + }, + ); + + await this.persistConversationMessage(session, failedCodexReplyMessage); + await upsertChatConversationRequest(session.sessionId, { + requestId: request.requestId, + status: wasCancelled ? 'cancelled' : 'failed', + statusMessage: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', + responseMessageId: failedCodexReplyMessage.id, + responseText: failedCodexReplyMessage.text, + }); + } + chatRuntimeService.appendLog( request.requestId, wasCancelled diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs index c494981..4ad3839 100755 --- a/scripts/run-plan-codex-once.mjs +++ b/scripts/run-plan-codex-once.mjs @@ -13,6 +13,7 @@ const apiBaseUrl = process.env.PLAN_API_BASE_URL ?? 'http://127.0.0.1:3100/api'; const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? ''; const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null; const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex'; +const codexModel = process.env.PLAN_CODEX_MODEL?.trim() || process.env.SERVER_COMMAND_RUNNER_CODEX_MODEL?.trim() || 'gpt-5.4'; const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true'; const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true'; const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice'; @@ -1124,6 +1125,8 @@ async function runCodexForPlan(item) { codexBin, [ 'exec', + '--model', + codexModel, '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 68370eb..5b3f3e3 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -31,12 +31,16 @@ const runnerLogTrimIntervalMs = Math.max( const STREAM_CAPTURE_LIMIT = 256 * 1024; const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max( 30_000, - Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'), + Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '180000'), ); const CODEX_LIVE_MAX_EXECUTION_MS = Math.max( CODEX_LIVE_IDLE_TIMEOUT_MS, Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`), ); +const CODEX_LIVE_MODEL = + process.env.SERVER_COMMAND_RUNNER_CODEX_MODEL?.trim() || + process.env.PLAN_CODEX_MODEL?.trim() || + 'gpt-5.4'; const CODEX_HOME_RUNTIME_PATHS = [ 'auth.json', 'config.toml', @@ -206,6 +210,25 @@ function sendJsonLine(response, payload) { response.write(`${JSON.stringify(payload)}\n`); } +function normalizeNumericSeconds(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + + if (!trimmed) { + return null; + } + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; + } + + return null; +} + function readRequestBody(request, maxBytes = 4 * 1024 * 1024) { return new Promise((resolve, reject) => { let total = 0; @@ -505,7 +528,8 @@ async function runCodexLiveExecution(payload, response) { const resourceDir = path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'); const uploadDir = path.join(resourceDir, 'uploads'); const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; - const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds); + const configuredIdleTimeoutMs = resolveCodexLiveIdleTimeoutMs(payload?.idleTimeoutSeconds); + const configuredMaxExecutionMs = resolveCodexLiveMaxExecutionMs(payload?.maxExecutionSeconds, configuredIdleTimeoutMs); if (!requestId || !sessionId || !prompt.trim()) { sendJson(response, 400, { @@ -536,7 +560,7 @@ async function runCodexLiveExecution(payload, response) { const child = spawn( codexBin, - ['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'], + ['exec', '--model', CODEX_LIVE_MODEL, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'], { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'], @@ -557,6 +581,8 @@ async function runCodexLiveExecution(payload, response) { sendJsonLine(response, { type: 'started', pid: child.pid ?? null, + configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000), + configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000), }); const cleanup = async () => { @@ -612,9 +638,9 @@ async function runCodexLiveExecution(payload, response) { idleTimer = setTimeout(() => { requestTermination( - `Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`, + `Codex Live 실행이 ${Math.round(configuredIdleTimeoutMs / 1000)}초 동안 출력이 없어 중단되었습니다.`, ); - }, CODEX_LIVE_IDLE_TIMEOUT_MS); + }, configuredIdleTimeoutMs); idleTimer.unref?.(); }; @@ -759,12 +785,24 @@ async function runCodexLiveExecution(payload, response) { child.stdin?.end(prompt); } -function resolveCodexLiveMaxExecutionMs(value) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return CODEX_LIVE_MAX_EXECUTION_MS; +function resolveCodexLiveMaxExecutionMs(value, minimumMs = CODEX_LIVE_IDLE_TIMEOUT_MS) { + const normalizedValue = normalizeNumericSeconds(value); + + if (normalizedValue == null) { + return Math.max(minimumMs, CODEX_LIVE_MAX_EXECUTION_MS); } - return Math.max(CODEX_LIVE_IDLE_TIMEOUT_MS, Math.min(7_200_000, Math.max(60_000, Math.round(value * 1000)))); + return Math.max(minimumMs, Math.min(7_200_000, Math.max(60_000, Math.round(normalizedValue * 1000)))); +} + +function resolveCodexLiveIdleTimeoutMs(value) { + const normalizedValue = normalizeNumericSeconds(value); + + if (normalizedValue == null) { + return CODEX_LIVE_IDLE_TIMEOUT_MS; + } + + return Math.min(3_600_000, Math.max(30_000, Math.round(normalizedValue * 1000))); } function isAuthorized(request) { diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx index b30895b..806fa9a 100755 --- a/src/app/main/AppShell.tsx +++ b/src/app/main/AppShell.tsx @@ -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() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/src/app/main/ChatNotificationBridgeV2.tsx b/src/app/main/ChatNotificationBridgeV2.tsx index 12bf8b4..7d01986 100644 --- a/src/app/main/ChatNotificationBridgeV2.tsx +++ b/src/app/main/ChatNotificationBridgeV2.tsx @@ -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([]); const lastPolledCodexMessageIdBySessionRef = useRef>({}); const lastFailedRequestKeyBySessionRef = useRef>({}); @@ -257,5 +259,9 @@ export function ChatNotificationBridgeV2() { }) .catch(() => undefined); }; + + if (!appConfig.chat.receiveRoomNotifications) { + return null; + } return null; } diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index 0f97d81..6f2f72c 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -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) { diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 5636449..6663215 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -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(); - 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(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( + 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>(() => {}); 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(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((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('.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('.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 = {item.title || '새 대화'} - {formatConversationListTimestamp(item.lastMessageAt || item.createdAt)} + {formatConversationListTimestamp(item.lastMessageAt || item.updatedAt || item.createdAt)} {item.sessionId} @@ -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={} aria-label="새 대화 생성" title="새 대화 생성" + disabled={availableChatTypes.length === 0} onClick={() => { - void handleCreateConversation(); + openCreateConversationModal(); }} /> {isConversationListLoading ? '불러오는 중' : `${conversationItems.length}건`} @@ -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 = )} + { + setIsCreateConversationModalOpen(false); + }} + onOk={() => { + void handleConfirmCreateConversation(); + }} + > + {availableChatTypes.length > 0 ? ( +
+ + 신규 채팅방은 선택한 채팅유형으로 생성되고, 방명도 같은 이름으로 시작합니다. + + { + setCreateConversationChatTypeId(event.target.value); + }} + > + + {availableChatTypes.map((item) => ( + + ))} + + +
+ ) : ( + + )} +
{ setPendingContextConfirm(null); @@ -3273,7 +3450,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = {`${activePreview.label} preview`} @@ -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={닫기} > {activePreview ? (
-
- - }>{activePreview.kind} - {activePreview.source === 'context' ? '현재 화면' : '채팅 결과'} - -
- {canSearchActivePreview && isPreviewFindOpen ? ( + {!isHtmlPreviewFullscreen ? ( +
+ + }>{activePreview.kind} + {activePreview.source === 'context' ? '현재 화면' : '채팅 결과'} + +
+ ) : null} + {!isHtmlPreviewFullscreen && canSearchActivePreview && isPreviewFindOpen ? (
{ diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 00fe3be..f280d6b 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -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 ? : 'Docs', + value: 'docs', + icon: isMobileViewport ? undefined : , + }, + { + label: isMobileViewport ? : '작업', + value: 'plans', + icon: isMobileViewport ? undefined : , + }, + { + label: isMobileViewport ? : 'Play', + value: 'play', + icon: isMobileViewport ? undefined : , + }, + ] + : [ + { + label: isMobileViewport ? : 'Docs', + value: 'docs', + icon: isMobileViewport ? undefined : , + }, + ]; 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({ />
+
+ { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + receiveRoomNotifications: event.target.checked, + }, + })); + }} + > + 채팅방 알림 수신 + + + 꺼두면 채팅방별 벨 설정과 관계없이 Codex Live 채팅방 알림을 이 기기에서 받지 않습니다. + +
+
Codex Live 최대 실행 시간(초) Codex Live 요청 1건이 강제 종료되기 전까지 허용할 최대 실행 시간입니다. @@ -2216,6 +2276,29 @@ export function MainHeader({ }} />
+ +
+ Codex Live 무출력 실패 시간(초) + 이 시간 동안 출력이나 활동 로그가 없으면 해당 요청을 실패 처리합니다. + { + 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, + }, + })); + }} + /> +
); @@ -2855,17 +2938,11 @@ export function MainHeader({ onClick={onToggleSidebar} /> }, - { label: '작업', value: 'plans', icon: }, - ] - : [{ label: 'Docs', value: 'docs', icon: }] - } + options={headerTopMenuOptions} onChange={(value) => { - onChangeTopMenu(value as 'docs' | 'plans'); + onChangeTopMenu(value as 'docs' | 'plans' | 'play'); }} /> diff --git a/src/app/main/MainLayout.css b/src/app/main/MainLayout.css index 85be240..85c2e76 100755 --- a/src/app/main/MainLayout.css +++ b/src/app/main/MainLayout.css @@ -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; diff --git a/src/app/main/appConfig.ts b/src/app/main/appConfig.ts index 8c98557..a89250c 100755 --- a/src/app/main/appConfig.ts +++ b/src/app/main/appConfig.ts @@ -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 { const chat = raw?.chat; const automation = raw?.automation; @@ -272,6 +292,14 @@ function normalizeConfig(raw?: Partial): 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, diff --git a/src/app/main/chatTypeAccess.ts b/src/app/main/chatTypeAccess.ts index 0a6b316..fe10d19 100755 --- a/src/app/main/chatTypeAccess.ts +++ b/src/app/main/chatTypeAccess.ts @@ -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//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//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', diff --git a/src/app/main/chatV2/components/ConversationRoomPane.tsx b/src/app/main/chatV2/components/ConversationRoomPane.tsx index 0ffaab7..9f9eb0a 100644 --- a/src/app/main/chatV2/components/ConversationRoomPane.tsx +++ b/src/app/main/chatV2/components/ConversationRoomPane.tsx @@ -1,739 +1,7 @@ -import { - CodeOutlined, - CloseOutlined, - CopyOutlined, - DeleteOutlined, - DownloadOutlined, - DownOutlined, - ExclamationCircleOutlined, - FullscreenExitOutlined, - FullscreenOutlined, - MessageOutlined, - PaperClipOutlined, - PlusOutlined, - RedoOutlined, - SendOutlined, - SyncOutlined, - ThunderboltOutlined, - UpOutlined, -} from '@ant-design/icons'; -import { Alert, Button, Checkbox, Input, Select, Spin, Typography, message } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; -import { - useEffect, - useMemo, - useRef, - useState, - type ChangeEvent, - type ClipboardEvent, - type ReactNode, - type RefObject, - type TouchEvent, -} from 'react'; -import { InlineImage } from '../../../../components/common/InlineImage'; -import { CodexDiffBlock } from '../../../../components/previewer'; -import { - ChatPreviewBody, - resolveChatPreviewGlyph, - resolveChatPreviewKindLabel, - type ChatPreviewKind, -} from '../../mainChatPanel/ChatPreviewBody'; -import { extractAutoDetectedPreviewUrls } from '../../mainChatPanel/inlinePreviewUrls'; -import { extractHiddenPreviewUrls, stripHiddenPreviewTags } from '../../mainChatPanel/previewMarkers'; -import { normalizeChatResourceUrl } from '../../mainChatPanel/chatResourceUrl'; -import { copyPreviewContent, copyText } from '../../mainChatPanel/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, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, -}); - -type ChatTypeOption = { - value: string; - label: string; - description: string; - disabled?: boolean; -}; - -type PreviewOption = { - id: string; - label: string; - url: string; - kind: string; -}; - -type QueuedRequestOption = { - requestId: string; - order: number; - text: string; -}; - -type InlinePreviewKind = ChatPreviewKind; - -type InlinePreviewTarget = { - url: string; - label: string; - kind: InlinePreviewKind; -}; - -type PreviewFetchError = Error & { - status?: number; -}; - -const MARKDOWN_IMAGE_LINE_PATTERN = /^\s*!\[([^\]]*)\]\(([^)]+)\)\s*$/; -const MARKDOWN_LINK_PATTERN = /\[([^\]]+)\]\(([^)]+)\)/g; -const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; -const COLLAPSIBLE_MESSAGE_LINE_COUNT = 6; -const COLLAPSIBLE_MESSAGE_CHAR_COUNT = 280; -const DIFF_CODE_BLOCK_PATTERN = /```diff[^\n]*\n([\s\S]*?)```/g; - -type MessageRenderPayload = { - previewSourceText: string; - visibleText: string; - diffBlocks: string[]; -}; - -function normalizeInlinePreviewUrl(value: string) { - return normalizeChatResourceUrl(value); -} - -function classifyInlinePreviewKind(url: string): InlinePreviewKind { - const pathname = url.toLowerCase().split('?')[0] ?? ''; - - if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { - return 'image'; - } - - if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) { - return 'video'; - } - - if (/\.(md|markdown)$/i.test(pathname)) { - return 'markdown'; - } - - if (/\.(diff|patch)$/i.test(pathname)) { - return 'diff'; - } - - if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { - return 'code'; - } - - if (/\.(txt|log|csv)$/i.test(pathname)) { - return 'document'; - } - - if (/\.pdf$/i.test(pathname)) { - return 'pdf'; - } - - return 'file'; -} - -function downloadTextFile(content: string, fileName: string, mimeType = 'text/plain;charset=utf-8') { - if (typeof document === 'undefined') { - return; - } - - const blob = new Blob([content], { type: mimeType }); - const objectUrl = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = objectUrl; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(objectUrl); -} - -function buildInlinePreviewLabel(url: string) { - try { - const parsed = new URL(url); - return parsed.pathname.split('/').filter(Boolean).at(-1) || parsed.hostname; - } catch { - return url; - } -} - -function buildPreviewFileName(item: PreviewOption) { - try { - const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); - const fileName = parsed.pathname.split('/').filter(Boolean).at(-1)?.trim(); - return fileName || item.label.trim() || item.url; - } catch { - return item.label.trim() || item.url; - } -} - -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'; - } -} - -async function createPreviewFetchError(response: Response): Promise { - const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; - let responseMessage = ''; - - try { - if (contentType.includes('application/json')) { - const payload = (await response.json()) as { message?: string }; - responseMessage = String(payload.message ?? '').trim(); - } else { - responseMessage = (await response.text()).trim(); - } - } catch { - responseMessage = ''; - } - - const statusLabel = - response.status === 403 - ? '이 문서는 현재 권한으로 열 수 없습니다.' - : response.status === 404 - ? '이 문서를 찾을 수 없습니다.' - : response.status === 401 - ? '이 문서를 열기 위한 인증이 필요합니다.' - : `preview 요청이 실패했습니다. (${response.status})`; - const detail = responseMessage && responseMessage !== response.statusText ? responseMessage : response.statusText.trim(); - const error = new Error(detail ? `${statusLabel} ${detail}` : statusLabel) as PreviewFetchError; - error.status = response.status; - return error; -} - -function extractInlinePreviewTargets(text: string): InlinePreviewTarget[] { - const matches = [...extractAutoDetectedPreviewUrls(text), ...extractHiddenPreviewUrls(text)]; - const seen = new Set(); - const targets: InlinePreviewTarget[] = []; - - for (const matchedUrl of matches) { - const normalizedUrl = normalizeInlinePreviewUrl(matchedUrl); - const kind = classifyInlinePreviewKind(normalizedUrl); - - if (kind === 'file') { - continue; - } - - if (seen.has(normalizedUrl)) { - continue; - } - - seen.add(normalizedUrl); - targets.push({ - url: normalizedUrl, - label: buildInlinePreviewLabel(normalizedUrl), - kind, - }); - } - - return targets; -} - -function renderMessageInlineParts(line: string): ReactNode[] { - const renderedParts: ReactNode[] = []; - let cursor = 0; - - for (const match of line.matchAll(MARKDOWN_LINK_PATTERN)) { - const [fullMatch, label, rawHref] = match; - const start = match.index ?? 0; - - if (start > cursor) { - renderedParts.push(line.slice(cursor, start)); - } - - const href = normalizeInlinePreviewUrl(rawHref.trim()); - renderedParts.push( - - {label.trim() || href} - , - ); - cursor = start + fullMatch.length; - } - - if (cursor < line.length) { - renderedParts.push(line.slice(cursor)); - } - - return renderedParts.length > 0 ? renderedParts : [line]; -} - -function renderMessageBody(text: string) { - const lines = text.split('\n'); - - return lines.map((line, index) => { - const imageMatch = line.match(MARKDOWN_IMAGE_LINE_PATTERN); - - if (imageMatch) { - const [, alt, rawSrc] = imageMatch; - const src = normalizeInlinePreviewUrl(rawSrc.trim()); - - return ( -
- -
- ); - } - - if (!line.length) { - return