From 6e863feafdce59fa244a19213cf26cfb16061f65 Mon Sep 17 00:00:00 2001 From: how2ice Date: Thu, 23 Apr 2026 18:10:43 +0900 Subject: [PATCH] feat: update codex live runtime and restart flow --- .gitignore | 1 + README.md | 2 +- docs/worklogs/2026-04-23.md | 34 ++ etc/commands/server-command/restart-prod.sh | 51 ++ etc/commands/server-command/restart-rel.sh | 6 +- .../restart-server-command-runner.sh | 10 - etc/servers/work-server/.env.example | 6 + etc/servers/work-server/README.md | 6 +- etc/servers/work-server/src/config/env.ts | 2 + etc/servers/work-server/src/routes/chat.ts | 50 ++ .../src/services/chat-room-service.ts | 13 + .../chat-runtime-rollback-service.test.ts | 26 + .../services/chat-runtime-rollback-service.ts | 174 ++++++ .../src/services/chat-runtime-service.ts | 26 + .../src/services/chat-service.test.ts | 47 ++ .../work-server/src/services/chat-service.ts | 118 +++- .../services/server-command-service.test.ts | 37 +- .../src/services/server-command-service.ts | 78 ++- scripts/run-server-command-runner.mjs | 214 +------ src/app/main/MainChatPanel.hotfix.css | 55 +- src/app/main/MainChatPanel.tsx | 527 +++++++++++++++++- src/app/main/MainHeader.tsx | 155 +++++- .../components/ConversationRoomPane.tsx | 1 + src/app/main/chatV2/data/chatGateway.ts | 6 +- .../mainChatPanel/ChatConversationView.tsx | 1 + .../main/mainChatPanel/ChatPreviewBody.tsx | 7 +- .../mainChatPanel/ChatRuntimeDashboard.tsx | 147 +++-- src/app/main/mainChatPanel/chatUtils.ts | 16 + src/app/main/mainChatPanel/types.ts | 1 + src/components/previewer/PreviewerUI.css | 46 ++ src/components/previewer/PreviewerUI.tsx | 99 +++- src/components/search/SearchCommandModal.tsx | 5 +- .../serverCommand/ServerCommandPage.tsx | 3 +- src/features/serverCommand/api.ts | 2 +- src/features/serverCommand/types.ts | 2 +- .../search/context/SearchLayerContext.tsx | 20 - 36 files changed, 1636 insertions(+), 358 deletions(-) create mode 100644 docs/worklogs/2026-04-23.md create mode 100644 etc/commands/server-command/restart-prod.sh create mode 100644 etc/servers/work-server/src/services/chat-runtime-rollback-service.test.ts create mode 100644 etc/servers/work-server/src/services/chat-runtime-rollback-service.ts diff --git a/.gitignore b/.gitignore index 03a97b9..5f6dfe0 100755 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ etc/**/.DS_Store *.tsbuildinfo *.swp *.root-owned-backup +*.root-owned-backup-*/ vite.config.js vite.config.d.ts diff --git a/README.md b/README.md index 114dc32..9ec1bed 100755 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ docs/ - 앱 문서는 Vite `import.meta.glob`으로 Markdown 파일을 수집합니다. - 작업일지는 날짜별 파일로 누적하며 캡처 이미지는 `docs/assets/worklogs/YYYY-MM-DD/` 기준으로 관리합니다. - Plan 자동화 스크립트는 `scripts/run-plan-codex-once.mjs`를 사용합니다. -- 서버 재기동을 호스트 프로젝트 루트 기준으로 처리하려면 `npm run server-command:runner`를 실행합니다. +- command runner는 별도 명시적 요청이 있을 때만 `npm run server-command:runner`로 직접 기동하거나 재기동합니다. - 문서/작업일지 일일 정리는 `npm run docs:daily`와 `.github/workflows/daily-docs-maintenance.yml` 기준으로 실행합니다. ## 프로젝트 현황 diff --git a/docs/worklogs/2026-04-23.md b/docs/worklogs/2026-04-23.md new file mode 100644 index 0000000..d7fd4d2 --- /dev/null +++ b/docs/worklogs/2026-04-23.md @@ -0,0 +1,34 @@ +# 2026-04-23 작업일지 + +## 오늘 작업 + +- 화면 캡처 추가 예정 + +## 스크린샷 + +![settings-app](../assets/worklogs/2026-04-23/settings-app.png) + +## 소스 + +### 파일 1: `path/to/file.tsx` + +- 변경 목적과 핵심 수정 내용을 한 줄로 정리 + +```diff +# 이 파일의 핵심 diff +- before ++ after +``` + +### 파일 2: `path/to/another-file.ts` + +- 필요 없으면 이 섹션은 삭제 + +## 실행 커맨드 + +```bash +``` + +## 변경 파일 + +- diff --git a/etc/commands/server-command/restart-prod.sh b/etc/commands/server-command/restart-prod.sh new file mode 100644 index 0000000..7a712d5 --- /dev/null +++ b/etc/commands/server-command/restart-prod.sh @@ -0,0 +1,51 @@ +#!/bin/sh + +set -eu + +MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}" +SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}" +SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-prod-app}" +SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod}" +SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}" +SERVER_COMMAND_PROD_GIT_REMOTE="${SERVER_COMMAND_PROD_GIT_REMOTE:-origin}" +SERVER_COMMAND_PROD_GIT_BRANCH="${SERVER_COMMAND_PROD_GIT_BRANCH:-main}" +SERVER_COMMAND_PROD_GIT_USERNAME="${SERVER_COMMAND_PROD_GIT_USERNAME:-}" +SERVER_COMMAND_PROD_GIT_PASSWORD="${SERVER_COMMAND_PROD_GIT_PASSWORD:-}" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +cd "$MAIN_PROJECT_ROOT" + +if ! command -v git >/dev/null 2>&1; then + echo "git CLI not found: cannot pull ${SERVER_COMMAND_PROD_GIT_REMOTE}/${SERVER_COMMAND_PROD_GIT_BRANCH}" >&2 + exit 127 +fi + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true) + +if [ "$CURRENT_BRANCH" != "$SERVER_COMMAND_PROD_GIT_BRANCH" ]; then + echo "expected branch ${SERVER_COMMAND_PROD_GIT_BRANCH} in $MAIN_PROJECT_ROOT, got ${CURRENT_BRANCH:-unknown}" >&2 + exit 1 +fi + +if [ -n "$SERVER_COMMAND_PROD_GIT_USERNAME" ] && [ -n "$SERVER_COMMAND_PROD_GIT_PASSWORD" ]; then + REMOTE_URL=$(git remote get-url "$SERVER_COMMAND_PROD_GIT_REMOTE") + CREDENTIAL_STORE=$(mktemp) + trap 'rm -f "$CREDENTIAL_STORE"' EXIT INT TERM + printf '%s\n' "$REMOTE_URL" \ + | sed "s#^https://#https://${SERVER_COMMAND_PROD_GIT_USERNAME}:${SERVER_COMMAND_PROD_GIT_PASSWORD}@#" \ + >"$CREDENTIAL_STORE" + git -c "credential.helper=store --file=$CREDENTIAL_STORE" pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH" +else + git pull --ff-only "$SERVER_COMMAND_PROD_GIT_REMOTE" "$SERVER_COMMAND_PROD_GIT_BRANCH" +fi + +if command -v docker >/dev/null 2>&1; then + exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE" +fi + +if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then + exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME" +fi + +echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2 +exit 127 diff --git a/etc/commands/server-command/restart-rel.sh b/etc/commands/server-command/restart-rel.sh index 0a8968f..3614525 100755 --- a/etc/commands/server-command/restart-rel.sh +++ b/etc/commands/server-command/restart-rel.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 --build --force-recreate --no-deps "$SERVER_COMMAND_SERVICE" fi if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then diff --git a/etc/commands/server-command/restart-server-command-runner.sh b/etc/commands/server-command/restart-server-command-runner.sh index e9a3526..42c99b6 100755 --- a/etc/commands/server-command/restart-server-command-runner.sh +++ b/etc/commands/server-command/restart-server-command-runner.sh @@ -12,11 +12,6 @@ RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}" RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}" RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}" RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}" -RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}" -RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}" -RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}" -RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}" -RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}" RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT") if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then @@ -43,11 +38,6 @@ setsid env \ SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \ SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \ SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \ - SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \ - SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \ - SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \ - SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \ - SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \ "$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 main` 동기화는 기본 전제로 사용하지 않으며, Git 관련 작업은 사용자가 명시적으로 요청할 때만 수행합니다. diff --git a/etc/servers/work-server/src/config/env.ts b/etc/servers/work-server/src/config/env.ts index c9f686e..bead190 100644 --- a/etc/servers/work-server/src/config/env.ts +++ b/etc/servers/work-server/src/config/env.ts @@ -71,12 +71,14 @@ const envSchema = z.object({ SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'), SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'), SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'), + SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'), SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'), SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'), SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'), SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(), SERVER_COMMAND_TEST_SERVICE: z.string().default('app'), SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'), + SERVER_COMMAND_PROD_SERVICE: z.string().default('prod-app'), SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'), }); diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index c37841b..2843830 100755 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { env } from '../config/env.js'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; import { getActiveChatService, getChatRuntimeController } from '../services/chat-service.js'; +import { rollbackChatRuntimeRequest } from '../services/chat-runtime-rollback-service.js'; import { createChatConversation, deleteUnansweredChatConversationRequest, @@ -16,6 +17,7 @@ import { listChatConversationDetailPage, listChatConversations, markChatConversationResponsesRead, + upsertChatConversationRequest, updateChatConversationContext, } from '../services/chat-room-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; @@ -308,11 +310,56 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.post('/api/chat/runtime/jobs/:requestId/rollback', async (request, reply) => { + const params = z.object({ + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const payload = z + .object({ + sessionId: z.string().trim().min(1).max(120).optional(), + }) + .parse(request.body ?? {}); + + chatRuntimeService.appendLog(params.requestId, '사용자 요청으로 최근 실행 롤백을 시작합니다.'); + + try { + const result = await rollbackChatRuntimeRequest({ + requestId: params.requestId, + sessionId: payload.sessionId, + }); + + await upsertChatConversationRequest(result.sessionId, { + requestId: result.requestId, + status: 'cancelled', + statusMessage: '사용자 요청으로 최근 실행 변경을 롤백했습니다.', + }); + chatRuntimeService.setArchivedJobTerminalStatus( + params.requestId, + 'cancelled', + '최근 실행이 롤백되어 상태를 취소로 변경했습니다.', + ); + chatRuntimeService.appendLog(params.requestId, '최근 실행 롤백이 완료되었습니다.'); + + return { + ok: true, + ...result, + }; + } catch (error) { + const message = error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.'; + chatRuntimeService.appendLog(params.requestId, `최근 실행 롤백 실패: ${message}`); + + return reply.code(409).send({ + message, + }); + } + }); + app.post('/api/chat/conversations', async (request) => { const payload = z.object({ sessionId: z.string().trim().min(1).max(120), title: z.string().trim().max(200).optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), + lastChatTypeId: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).optional(), contextDescription: z.string().trim().max(2000).optional(), notifyOffline: z.boolean().optional(), @@ -324,6 +371,7 @@ export async function registerChatRoutes(app: FastifyInstance) { clientId: clientId || null, title: payload.title ?? '새 대화', chatTypeId: payload.chatTypeId ?? null, + lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null, contextLabel: payload.contextLabel ?? null, contextDescription: payload.contextDescription ?? null, notifyOffline: payload.notifyOffline ?? true, @@ -438,6 +486,7 @@ export async function registerChatRoutes(app: FastifyInstance) { const payload = z.object({ title: z.string().trim().min(1).max(200).optional(), chatTypeId: z.string().trim().max(120).optional().nullable(), + lastChatTypeId: z.string().trim().max(120).optional().nullable(), contextLabel: z.string().trim().max(200).optional().nullable(), contextDescription: z.string().trim().max(2000).optional().nullable(), notifyOffline: z.boolean().optional(), @@ -456,6 +505,7 @@ export async function registerChatRoutes(app: FastifyInstance) { title: payload.title ?? current.title, clientId: current.clientId, chatTypeId: payload.chatTypeId ?? current.chatTypeId, + lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId, contextLabel: payload.contextLabel ?? current.contextLabel, contextDescription: payload.contextDescription ?? current.contextDescription, notifyOffline: payload.notifyOffline ?? current.notifyOffline, 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 e5aece6..09a7271 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -15,6 +15,7 @@ const conversationPayloadSchema = z.object({ clientId: z.string().trim().max(120).nullable().optional(), title: z.string().trim().max(200).nullable().optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), + lastChatTypeId: z.string().trim().max(120).nullable().optional(), contextLabel: z.string().trim().max(200).nullable().optional(), contextDescription: z.string().trim().max(2000).nullable().optional(), notifyOffline: z.boolean().optional(), @@ -34,6 +35,7 @@ export type ChatConversationItem = { clientId: string | null; title: string; chatTypeId: string | null; + lastChatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; @@ -173,6 +175,7 @@ function mapConversationRow(row: Record): ChatConversationItem clientId: row.client_id == null ? null : String(row.client_id), title: String(row.title ?? '새 대화'), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), + lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), contextLabel: row.context_label == null ? null : String(row.context_label), contextDescription: row.context_description == null ? null : String(row.context_description), notifyOffline: Boolean(row.notify_offline), @@ -709,6 +712,7 @@ export async function ensureChatConversationTables() { table.string('client_id', 120).nullable().index(); table.string('title', 200).notNullable().defaultTo('새 대화'); table.string('chat_type_id', 120).nullable(); + table.string('last_chat_type_id', 120).nullable(); table.string('context_label', 200).nullable(); table.text('context_description').nullable(); table.boolean('notify_offline').notNullable().defaultTo(false); @@ -728,6 +732,7 @@ export async function ensureChatConversationTables() { ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], + ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()], ['context_label', (table) => table.string('context_label', 200).nullable()], ['context_description', (table) => table.text('context_description').nullable()], ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], @@ -1012,6 +1017,7 @@ export async function createChatConversation(payload: z.input { + const normalized = normalizeRollbackDiffText([ + 'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx', + '--- a/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx', + '+++ b/api/chat/resources/.codex_chat/chat-room/resource/src/app/main/MainHeader.tsx', + '@@', + '+const value = 1;', + '', + ].join('\n')); + + assert.match(normalized, /^diff --git a\/src\/app\/main\/MainHeader\.tsx b\/src\/app\/main\/MainHeader\.tsx/m); + assert.match(normalized, /^--- a\/src\/app\/main\/MainHeader\.tsx$/m); + assert.match(normalized, /^\+\+\+ b\/src\/app\/main\/MainHeader\.tsx$/m); +}); + +test('normalizeRollbackDiffText keeps the final repo path when resource prefixes were duplicated', () => { + const normalized = normalizeRollbackDiffText( + 'diff --git a/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md b/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/api/chat/resources/.codex_chat/chat-room/resource/README.md\n', + ); + + assert.equal(normalized.trim(), 'diff --git a/README.md b/README.md'); +}); diff --git a/etc/servers/work-server/src/services/chat-runtime-rollback-service.ts b/etc/servers/work-server/src/services/chat-runtime-rollback-service.ts new file mode 100644 index 0000000..1467b93 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-runtime-rollback-service.ts @@ -0,0 +1,174 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { env } from '../config/env.js'; +import { db } from '../db/client.js'; +import { CHAT_CONVERSATION_REQUEST_TABLE } from './chat-room-service.js'; + +function extractDiffBlocks(text: string) { + return Array.from(String(text ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g)) + .map((match) => (typeof match[1] === 'string' ? match[1].trim() : '')) + .filter(Boolean); +} + +function resolveChatRepoPath() { + const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; + + if (!repoPath?.trim()) { + throw new Error('롤백 대상 저장소 경로가 설정되지 않았습니다.'); + } + + return path.resolve(repoPath); +} + +function summarizeGitApplyError(stderr: string) { + const normalized = String(stderr ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (normalized.length === 0) { + return '현재 작업트리가 이 요청 시점과 달라 자동 롤백을 적용할 수 없습니다.'; + } + + return normalized.slice(0, 3).join('\n'); +} + +function stripChatResourcePrefix(candidate: string) { + let normalized = String(candidate ?? '').replace(/\\/g, '/'); + const marker = '/resource/'; + + while (normalized.includes(marker)) { + normalized = normalized.slice(normalized.indexOf(marker) + marker.length); + } + + return normalized.replace(/^\/+/, ''); +} + +function normalizeRollbackDiffLine(line: string) { + const normalizedLine = String(line ?? ''); + + if (normalizedLine.startsWith('diff --git ')) { + return normalizedLine.replace(/^diff --git a\/(.+?) b\/(.+)$/, (_match, left, right) => { + return `diff --git a/${stripChatResourcePrefix(left)} b/${stripChatResourcePrefix(right)}`; + }); + } + + if (normalizedLine.startsWith('--- a/')) { + return `--- a/${stripChatResourcePrefix(normalizedLine.slice('--- a/'.length))}`; + } + + if (normalizedLine.startsWith('+++ b/')) { + return `+++ b/${stripChatResourcePrefix(normalizedLine.slice('+++ b/'.length))}`; + } + + if (normalizedLine.startsWith('rename from ')) { + return `rename from ${stripChatResourcePrefix(normalizedLine.slice('rename from '.length))}`; + } + + if (normalizedLine.startsWith('rename to ')) { + return `rename to ${stripChatResourcePrefix(normalizedLine.slice('rename to '.length))}`; + } + + return normalizedLine; +} + +export function normalizeRollbackDiffText(diffText: string) { + return String(diffText ?? '') + .split('\n') + .map((line) => normalizeRollbackDiffLine(line)) + .join('\n'); +} + +async function runGitApplyReverse(repoPath: string, diffText: string, checkOnly: boolean) { + return await new Promise<{ ok: boolean; stderr: string }>((resolve, reject) => { + const args = ['apply', '--reverse', '--whitespace=nowarn']; + + if (checkOnly) { + args.push('--check'); + } + + args.push('-'); + + const child = spawn('git', args, { + cwd: repoPath, + stdio: ['pipe', 'ignore', 'pipe'], + }); + + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += String(chunk ?? ''); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve({ + ok: code === 0, + stderr, + }); + }); + + child.stdin.write(diffText); + child.stdin.end(); + }); +} + +export async function rollbackChatRuntimeRequest(args: { requestId: string; sessionId?: string | null }) { + const normalizedRequestId = args.requestId.trim(); + const normalizedSessionId = args.sessionId?.trim() || null; + + if (!normalizedRequestId) { + throw new Error('롤백할 요청 ID가 비어 있습니다.'); + } + + const repoPath = resolveChatRepoPath(); + const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('session_id', 'request_id', 'status', 'response_text') + .where('request_id', normalizedRequestId) + .modify((query) => { + if (normalizedSessionId) { + query.andWhere('session_id', normalizedSessionId); + } + }) + .orderBy('updated_at', 'desc') + .first(); + + if (!row) { + throw new Error('롤백할 Codex Live 실행 기록을 찾지 못했습니다.'); + } + + const status = String(row.status ?? '').trim(); + + if (status === 'cancelled' || status === 'removed') { + throw new Error('취소되었거나 제거된 요청은 롤백할 수 없습니다.'); + } + + const diffBlocks = extractDiffBlocks(String(row.response_text ?? '')); + + if (diffBlocks.length === 0) { + throw new Error('이 요청에는 롤백 가능한 diff 기록이 없습니다.'); + } + + const diffText = normalizeRollbackDiffText(`${diffBlocks.join('\n\n')}\n`); + const checkResult = await runGitApplyReverse(repoPath, diffText, true); + + if (!checkResult.ok) { + throw new Error(summarizeGitApplyError(checkResult.stderr)); + } + + const applyResult = await runGitApplyReverse(repoPath, diffText, false); + + if (!applyResult.ok) { + throw new Error(summarizeGitApplyError(applyResult.stderr)); + } + + return { + rolledBack: true, + requestId: normalizedRequestId, + sessionId: String(row.session_id ?? normalizedSessionId ?? ''), + diffBlockCount: diffBlocks.length, + }; +} diff --git a/etc/servers/work-server/src/services/chat-runtime-service.ts b/etc/servers/work-server/src/services/chat-runtime-service.ts index db1e8a4..fc36d3e 100755 --- a/etc/servers/work-server/src/services/chat-runtime-service.ts +++ b/etc/servers/work-server/src/services/chat-runtime-service.ts @@ -335,6 +335,32 @@ class ChatRuntimeService { this.emit(); } + setArchivedJobTerminalStatus(requestId: string, terminalStatus: ChatRuntimeTerminalStatus, logLine?: string | null) { + const current = this.archivedJobs.get(requestId); + + if (!current) { + return false; + } + + const nextLogs = [...current.logs]; + const normalizedLogLine = normalizeLogLine(String(logLine ?? '')); + + if (normalizedLogLine) { + nextLogs.push(normalizedLogLine); + } + + const next: RuntimeJobRecord = { + ...current, + terminalStatus, + lastUpdatedAt: nowIso(), + logs: nextLogs.slice(-MAX_LOG_LINES), + }; + + this.archivedJobs.set(requestId, next); + this.emit(); + return true; + } + clearAll() { if ( this.runningJobs.size === 0 && 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 5c35597..19d2a6e 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -155,6 +155,53 @@ test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n'); }); +test('rewriteCodexOutputWithChatResources keeps diff paths intact while rewriting prose file paths', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + const sourcePath = path.join(repoPath, 'src', 'a.ts'); + await mkdir(path.dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, 'export const value = 1;\n', 'utf8'); + + const output = [ + '변경 파일: src/a.ts', + '', + '```diff', + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@', + '-export const value = 1;', + '+export const value = 2;', + '```', + ].join('\n'); + + const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); + const savedDiffPath = path.join( + repoPath, + 'public', + '.codex_chat', + 'chat-room', + 'resource', + '_generated', + 'response.diff', + ); + + assert.match(rewritten, /변경 파일: \/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/a\.ts/); + assert.match(rewritten, /diff --git a\/src\/a\.ts b\/src\/a\.ts/); + assert.doesNotMatch(rewritten, /diff --git a\/api\/chat\/resources\//); + assert.equal( + await readFile(savedDiffPath, 'utf8'), + [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@', + '-export const value = 1;', + '+export const value = 2;', + '', + ].join('\n'), + ); +}); + test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => { const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx'); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index a11c072..5db5a79 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -1205,6 +1205,24 @@ export function extractDiffCodeBlocks(output: string) { .filter(Boolean); } +function protectDiffCodeBlocks(output: string) { + const blocks: string[] = []; + const text = String(output ?? '').replace(/```diff[^\n]*\n[\s\S]*?\n```/g, (match) => { + const token = `__CODEX_DIFF_BLOCK_${blocks.length}__`; + blocks.push(match); + return token; + }); + + return { text, blocks }; +} + +function restoreDiffCodeBlocks(output: string, blocks: string[]) { + return blocks.reduce( + (current, block, index) => current.replace(`__CODEX_DIFF_BLOCK_${index}__`, block), + String(output ?? ''), + ); +} + async function resolveChatResourceSourcePath(repoPath: string, candidate: string) { const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); @@ -1323,6 +1341,7 @@ function appendDiffResourceLinks(output: string, diffUrls: string[]) { } export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { + const { text: outputWithoutDiffBlocks, blocks: diffBlocks } = protectDiffCodeBlocks(output); const escapedRepoPath = escapeRegExp(path.resolve(repoPath)); const filePathPattern = "[^\\n\\s)\\]\"'`,]+"; const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`; @@ -1330,39 +1349,36 @@ export async function rewriteCodexOutputWithChatResources(output: string, repoPa `${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`, 'g', ); - const matches = [...output.matchAll(candidatePattern)]; + const matches = [...outputWithoutDiffBlocks.matchAll(candidatePattern)]; + let rewrittenOutput = outputWithoutDiffBlocks; + if (matches.length > 0) { + const replacementMap = new Map(); - if (matches.length === 0) { - return output; - } + for (const match of matches) { + const rawCandidate = match[0]?.trim(); - const replacementMap = new Map(); + if (!rawCandidate || replacementMap.has(rawCandidate)) { + continue; + } - for (const match of matches) { - const rawCandidate = match[0]?.trim(); + const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate); - if (!rawCandidate || replacementMap.has(rawCandidate)) { - continue; + if (stagedUrl) { + replacementMap.set(rawCandidate, stagedUrl); + } } - const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate); + const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length); - if (stagedUrl) { - replacementMap.set(rawCandidate, stagedUrl); + for (const [sourcePath, publicUrl] of replacements) { + rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl); } } - let rewrittenOutput = output; - - const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length); - - for (const [sourcePath, publicUrl] of replacements) { - rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl); - } - rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput); + rewrittenOutput = restoreDiffCodeBlocks(rewrittenOutput, diffBlocks); - const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, rewrittenOutput); + const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, output); return appendDiffResourceLinks(rewrittenOutput, diffUrls); } @@ -1648,6 +1664,7 @@ async function runAgenticCodexReply( requestId: string, onProgress?: (text: string) => void, onActivity?: (line: string) => void, + isCancellationRequested?: () => boolean, ) { const repoPath = env.SERVER_COMMAND_MAIN_PROJECT_ROOT || env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); @@ -1670,8 +1687,21 @@ async function runAgenticCodexReply( let lastProgressText = ''; let completedAgentMessage = ''; let hasIncrementalDelta = false; + const throwIfCancelled = async () => { + if (!isCancellationRequested?.()) { + return; + } + + await cancelRunnerCodexExecution(requestId).catch(() => false); + throw new Error('CHAT_RUNTIME_CANCELLED'); + }; + + await throwIfCancelled(); activeChatProcessRegistry.set(requestId, { - cancel: () => cancelRunnerCodexExecution(requestId), + cancel: async () => { + const cancelled = await cancelRunnerCodexExecution(requestId); + return cancelled || isCancellationRequested?.() === true; + }, }); await new Promise(async (resolve, reject) => { @@ -1688,6 +1718,7 @@ async function runAgenticCodexReply( }; try { + await throwIfCancelled(); const response = await requestCommandRunner('/api/codex-live/execute', { method: 'POST', body: JSON.stringify({ @@ -1705,6 +1736,8 @@ async function runAgenticCodexReply( return; } + await throwIfCancelled(); + if (!response.body) { reject(new Error('command-runner Codex 스트림이 비어 있습니다.')); return; @@ -1792,6 +1825,7 @@ async function runAgenticCodexReply( }; while (true) { + await throwIfCancelled(); const { value, done } = await reader.read(); if (done) { @@ -2130,8 +2164,17 @@ async function buildCodexReply( requestId: string, onProgress?: (text: string) => void, onActivity?: (line: string) => void, + isCancellationRequested?: () => boolean, ) { - return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); + return runAgenticCodexReply( + context, + input, + sessionId, + requestId, + onProgress, + onActivity, + isCancellationRequested, + ); } export class ChatService { @@ -2364,6 +2407,25 @@ export class ChatService { private async cancelRuntimeJob(requestId: string) { const execution = activeChatProcessRegistry.get(requestId); + const detail = chatRuntimeService.getJobDetail(requestId); + + if (!execution && detail.item && detail.terminalStatus == null) { + chatRuntimeService.appendLog(requestId, '실행 준비 단계에서 취소 요청을 접수했습니다.'); + this.cancelledRequestIds.add(requestId); + const session = this.findSessionByRequestId(requestId); + + if (session) { + void upsertChatConversationRequest(session.sessionId, { + requestId, + status: 'cancelled', + statusMessage: '사용자 요청으로 실행 취소를 대기합니다.', + }).catch((error: unknown) => { + this.logger.warn(error, 'failed to persist pending chat request cancellation state'); + }); + } + + return true; + } if (!execution) { return false; @@ -2384,7 +2446,14 @@ export class ChatService { } try { - return await execution.cancel(); + const cancelled = await execution.cancel(); + + if (!cancelled && this.cancelledRequestIds.has(requestId)) { + chatRuntimeService.appendLog(requestId, '취소 신호를 재시도 대기 중입니다.'); + return true; + } + + return cancelled; } catch (error) { this.logger.warn(error, 'failed to cancel chat runtime job'); chatRuntimeService.appendLog(requestId, '실행 취소 요청에 실패했습니다.'); @@ -3221,6 +3290,7 @@ export class ChatService { (activityLine) => { appendActivityLine(activityLine); }, + () => this.cancelledRequestIds.has(request.requestId), ); chatRuntimeService.appendLog(request.requestId, '응답 생성이 완료되었습니다.'); appendActivityLine('# 상태: 응답 생성이 완료되었습니다.'); diff --git a/etc/servers/work-server/src/services/server-command-service.test.ts b/etc/servers/work-server/src/services/server-command-service.test.ts index d86d4ce..af4b3c0 100644 --- a/etc/servers/work-server/src/services/server-command-service.test.ts +++ b/etc/servers/work-server/src/services/server-command-service.test.ts @@ -36,6 +36,15 @@ test('listServerCommands uses app as the default test restart service', async () assert.equal(testCommand.serviceName, 'app'); }); +test('listServerCommands exposes prod restart command', async () => { + const commands = await listServerCommands(); + const prodCommand = commands.find((item) => item.key === 'prod'); + + assert.ok(prodCommand); + assert.equal(prodCommand.serviceName, 'prod-app'); + assert.match(prodCommand.commandScript, /\/etc\/commands\/server-command\/restart-prod\.sh$/); +}); + test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => { const commands = await listServerCommands(); const testCommand = commands.find((item) => item.key === 'test'); @@ -45,10 +54,11 @@ test('listServerCommands resolves restart script from main project when project assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh'); }); -test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => { +test('test, release and prod restart scripts fall back to Docker socket when docker CLI is unavailable', () => { const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8'); const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8'); + const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8'); const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8'); const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8'); @@ -58,10 +68,22 @@ test('test and release restart scripts fall back to Docker socket when docker CL assert.match(testScript, /restart-via-docker-socket\.mjs/); assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/); assert.match(relScript, /command -v docker >/); - assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/); - assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/); + assert.match( + relScript, + /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/, + ); assert.match(relScript, /restart-via-docker-socket\.mjs/); assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/); + assert.match(prodScript, /command -v docker >/); + assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/); + assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_REMOTE="\$\{SERVER_COMMAND_PROD_GIT_REMOTE:-origin\}"/); + assert.match(prodScript, /SERVER_COMMAND_PROD_GIT_BRANCH="\$\{SERVER_COMMAND_PROD_GIT_BRANCH:-main\}"/); + assert.match( + prodScript, + /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --build --force-recreate --no-deps "\$SERVER_COMMAND_SERVICE"/, + ); + assert.match(prodScript, /restart-via-docker-socket\.mjs/); + assert.match(prodScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-prod\}"/); assert.match( workServerScript, /docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/, @@ -69,6 +91,15 @@ test('test and release restart scripts fall back to Docker socket when docker CL assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); }); +test('prod restart script pulls the configured remote main branch before restart', () => { + const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); + const prodScript = fs.readFileSync(new URL('restart-prod.sh', commandsRoot), 'utf8'); + + assert.match(prodScript, /CURRENT_BRANCH=\$\(git rev-parse --abbrev-ref HEAD 2>\/dev\/null \|\| true\)/); + assert.match(prodScript, /git -c "credential\.helper=store --file=\$CREDENTIAL_STORE" pull --ff-only/); + assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/); +}); + test('work-server package dev script does not use watch mode and rebuilds before start', async () => { const packageJsonPath = new URL('../../package.json', import.meta.url); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index 2d8f7c6..05d312e 100755 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -13,7 +13,7 @@ import { const execFileAsync = promisify(execFile); -export const serverCommandKeys = ['test', 'rel', 'work-server', 'command-runner'] as const; +export const serverCommandKeys = ['test', 'rel', 'prod', 'work-server', 'command-runner'] as const; export type ServerCommandKey = (typeof serverCommandKeys)[number]; @@ -135,6 +135,26 @@ const APP_BUILD_INFO_FILE_CANDIDATES = [ '/tmp/ai-code-test-app-dist/assets', ] as const; +export async function readAppBuildTimestamp(definition: ServerDefinition, options?: { allowLocal?: boolean }) { + const allowLocal = options?.allowLocal ?? false; + let latestBuiltAt: string | null = null; + + for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) { + const candidates = [ + allowLocal ? await readLocalBuildTimestamp(targetPath) : null, + await readContainerBuildTimestamp(definition, targetPath), + ].filter((value): value is string => Boolean(value)); + + for (const candidate of candidates) { + if (!latestBuiltAt || candidate > latestBuiltAt) { + latestBuiltAt = candidate; + } + } + } + + return latestBuiltAt; +} + async function readLocalBuildTimestamp(targetPath: string) { try { const targetStat = await stat(targetPath); @@ -587,6 +607,26 @@ function getServerDefinitions(): ServerDefinition[] { }, restartStrategy: 'wait', }, + { + key: 'prod', + label: 'PROD', + summary: '프로덕션 앱 컨테이너', + environment: 'production', + publicUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL), + checkUrl: normalizeUrl(env.SERVER_COMMAND_PROD_URL), + composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), + serviceName: env.SERVER_COMMAND_PROD_SERVICE, + containerName: 'ai-code-app-prod', + commandScript: resolveCommandScriptPath('restart-prod.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']), + commandWorkingDirectory: mainProjectRoot, + commandEnvironment: { + MAIN_PROJECT_ROOT: mainProjectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_PROD_SERVICE, + SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-prod', + }, + restartStrategy: 'deferred', + }, { key: 'work-server', label: 'WORK-SERVER', @@ -1223,21 +1263,39 @@ async function inspectRuntime(definition: ServerDefinition): Promise { - if (definition.key !== 'test') { + if (definition.key !== 'test' && definition.key !== 'prod') { return null; } + if (definition.key === 'prod') { + const testDefinition = getServerDefinition('test'); + const testBuiltAt = await readAppBuildTimestamp(testDefinition, { allowLocal: true }); + const prodBuiltAt = await readAppBuildTimestamp(definition); + const updateAvailable = Boolean(testBuiltAt && (!prodBuiltAt || prodBuiltAt < testBuiltAt)); + + return { + runningVersion: null, + runningBuiltAt: prodBuiltAt, + latestVersion: null, + latestBuiltAt: testBuiltAt, + latestSourceChangeAt: null, + latestSourceChangePath: null, + buildRequired: false, + updateAvailable, + updateSummary: + updateAvailable && testBuiltAt + ? `운영 반영 시각이 TEST보다 이전입니다. TEST ${testBuiltAt}, 운영 ${prodBuiltAt ?? '미확인'}` + : prodBuiltAt + ? `운영 반영 기준: ${prodBuiltAt}` + : '운영 빌드 시각을 읽지 못했습니다.', + }; + } + const latestSourceChange = await readLatestAppSourceChange(); const latestSourceChangedAt = latestSourceChange?.changedAt ?? null; + const builtAt = await readAppBuildTimestamp(definition, { allowLocal: true }); - for (const targetPath of APP_BUILD_INFO_FILE_CANDIDATES) { - const builtAt = - (await readLocalBuildTimestamp(targetPath)) ?? (await readContainerBuildTimestamp(definition, targetPath)); - - if (!builtAt) { - continue; - } - + if (builtAt) { return { runningVersion: null, runningBuiltAt: builtAt, diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index be49ef0..6caf811 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -28,20 +28,6 @@ const runnerLogTrimIntervalMs = Math.max( 15_000, Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'), ); -const cpuWatchdogEnabled = process.env.SERVER_COMMAND_CPU_WATCHDOG_ENABLED?.trim() !== 'false'; -const cpuWatchdogIntervalMs = Math.max(15_000, Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS?.trim() || '60000')); -const cpuWatchdogThresholdPercent = Math.max( - 10, - Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT?.trim() || '120'), -); -const cpuWatchdogConsecutiveLimit = Math.max( - 2, - Math.round(Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT?.trim() || '8')), -); -const cpuWatchdogCooldownMs = Math.max( - 60_000, - Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS?.trim() || '1200000'), -); const STREAM_CAPTURE_LIMIT = 256 * 1024; const CODEX_HOME_RUNTIME_PATHS = [ 'auth.json', @@ -78,6 +64,18 @@ const commandDefinitions = { }, restartStrategy: 'deferred', }, + prod: { + label: 'PROD', + scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-prod.sh'), + workingDirectory: projectRoot, + env: { + MAIN_PROJECT_ROOT: projectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_PROD_SERVICE?.trim() || 'prod-app', + SERVER_COMMAND_CONTAINER_NAME: process.env.SERVER_COMMAND_PROD_CONTAINER_NAME?.trim() || 'ai-code-app-prod', + }, + restartStrategy: 'deferred', + }, 'work-server': { label: 'WORK-SERVER', scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-work-server.sh'), @@ -133,46 +131,6 @@ function translateWorkspacePathToHost(inputPath) { return normalizedInput; } -const cpuWatchdogTargets = [ - { - name: 'test-app', - containerName: 'ai-code-app-app-1', - restartMode: 'command', - restartKey: 'test', - }, - { - name: 'release-app', - containerName: 'ai-code-app-release', - restartMode: 'command', - restartKey: 'rel', - }, - { - name: 'prod-app', - containerName: 'ai-code-app-prod', - restartMode: 'docker', - }, - { - name: 'work-server', - containerName: 'work-server', - restartMode: 'command', - restartKey: 'work-server', - }, -]; - -const cpuWatchdogState = new Map( - cpuWatchdogTargets.map((target) => [ - target.containerName, - { - lastCpuPercent: null, - breachCount: 0, - lastSampleAt: null, - lastRestartAt: null, - lastRestartReason: null, - }, - ]), -); -let cpuWatchdogBusy = false; - function trimOutput(value, maxLength = 400) { const normalized = value.replace(/\s+/g, ' ').trim(); if (!normalized) { @@ -223,26 +181,6 @@ async function writeHeartbeat() { cwd: projectRoot, startedAt, updatedAt: new Date().toISOString(), - cpuWatchdog: { - enabled: cpuWatchdogEnabled, - intervalMs: cpuWatchdogIntervalMs, - thresholdPercent: cpuWatchdogThresholdPercent, - consecutiveLimit: cpuWatchdogConsecutiveLimit, - cooldownMs: cpuWatchdogCooldownMs, - targets: cpuWatchdogTargets.map((target) => { - const state = cpuWatchdogState.get(target.containerName); - return { - name: target.name, - containerName: target.containerName, - restartMode: target.restartMode, - lastCpuPercent: state?.lastCpuPercent ?? null, - breachCount: state?.breachCount ?? 0, - lastSampleAt: state?.lastSampleAt ?? null, - lastRestartAt: state?.lastRestartAt ?? null, - lastRestartReason: state?.lastRestartReason ?? null, - }; - }), - }, }, null, 2, @@ -251,127 +189,6 @@ async function writeHeartbeat() { ); } -function parseCpuPercentage(value) { - const numeric = Number(String(value ?? '').replace('%', '').trim()); - return Number.isFinite(numeric) ? numeric : null; -} - -async function restartContainerByDocker(containerName) { - await execFileAsync('docker', ['restart', containerName], { - cwd: projectRoot, - timeout: 30_000, - maxBuffer: 1024 * 1024, - }); -} - -async function sampleCpuWatchdog() { - if (!cpuWatchdogEnabled || cpuWatchdogBusy || cpuWatchdogTargets.length === 0) { - return; - } - - cpuWatchdogBusy = true; - - try { - const { stdout } = await execFileAsync( - 'docker', - [ - 'stats', - '--no-stream', - '--format', - '{{json .}}', - ...cpuWatchdogTargets.map((target) => target.containerName), - ], - { - cwd: projectRoot, - timeout: 15_000, - maxBuffer: 1024 * 1024, - }, - ); - const now = new Date().toISOString(); - const sampledContainers = new Set(); - - for (const line of stdout.split('\n')) { - const trimmedLine = line.trim(); - - if (!trimmedLine) { - continue; - } - - let parsed; - - try { - parsed = JSON.parse(trimmedLine); - } catch { - continue; - } - - const containerName = String(parsed.Name ?? '').trim(); - const cpuPercent = parseCpuPercentage(parsed.CPUPerc); - const target = cpuWatchdogTargets.find((entry) => entry.containerName === containerName); - const state = cpuWatchdogState.get(containerName); - - if (!target || !state) { - continue; - } - - sampledContainers.add(containerName); - state.lastCpuPercent = cpuPercent; - state.lastSampleAt = now; - state.breachCount = cpuPercent != null && cpuPercent >= cpuWatchdogThresholdPercent ? state.breachCount + 1 : 0; - - const cooldownPassed = - !state.lastRestartAt || Date.now() - new Date(state.lastRestartAt).getTime() >= cpuWatchdogCooldownMs; - - if (state.breachCount < cpuWatchdogConsecutiveLimit || !cooldownPassed) { - continue; - } - - const restartReason = `cpu ${cpuPercent?.toFixed(1) ?? '?'}% sustained for ${ - state.breachCount - } samples`; - - process.stdout.write( - `[cpu-watchdog] restarting ${target.containerName} because ${restartReason} (threshold ${cpuWatchdogThresholdPercent}%)\n`, - ); - - if (target.restartMode === 'command' && target.restartKey) { - await runRestartCommand(target.restartKey); - } else { - await restartContainerByDocker(target.containerName); - } - - state.breachCount = 0; - state.lastRestartAt = new Date().toISOString(); - state.lastRestartReason = restartReason; - await writeHeartbeat().catch(() => { - // noop - }); - } - - for (const target of cpuWatchdogTargets) { - if (sampledContainers.has(target.containerName)) { - continue; - } - - const state = cpuWatchdogState.get(target.containerName); - - if (!state) { - continue; - } - - state.lastCpuPercent = null; - state.lastSampleAt = now; - state.breachCount = 0; - } - } catch (error) { - process.stdout.write( - `[cpu-watchdog] sample failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); - } finally { - cpuWatchdogBusy = false; - } -} - void trimRunnerLogIfNeeded(); setInterval(() => { void trimRunnerLogIfNeeded(); @@ -1030,12 +847,5 @@ server.listen(port, host, () => { }); }, 10_000); heartbeatTimer.unref(); - if (cpuWatchdogEnabled) { - const cpuWatchdogTimer = setInterval(() => { - void sampleCpuWatchdog(); - }, cpuWatchdogIntervalMs); - cpuWatchdogTimer.unref(); - void sampleCpuWatchdog(); - } process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`); }); diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index 3c2d317..d4aa44f 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -1784,9 +1784,20 @@ } .app-chat-panel__preview-rich--markdown { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: auto; + max-height: min(420px, 70vh); padding: 4px 2px 0; } +.app-chat-panel__preview-rich--markdown .markdown-preview { + width: 100%; + min-width: 0; +} + .app-chat-message__preview-image, .app-chat-message__preview-video, .app-chat-message__preview-frame { @@ -2175,6 +2186,7 @@ .app-chat-panel__preview-stage > * { width: 100%; height: 100%; + min-height: 0; } .app-chat-panel__preview-loading { @@ -2366,12 +2378,32 @@ padding: 0 20px 12px; } -.app-chat-panel__preview-modal-footer { +.app-chat-panel__preview-modal-title { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 12px; width: 100%; + min-width: 0; +} + +.app-chat-panel__preview-modal-title-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-panel__preview-modal-findbar { + display: flex; + align-items: center; + gap: 8px; + padding: 0 20px 12px; +} + +.app-chat-panel__preview-modal-findbar .ant-input-affix-wrapper { + flex: 1 1 auto; + min-width: 0; } .app-chat-panel__preview-modal .app-chat-panel__preview-rich, @@ -2387,6 +2419,12 @@ max-width: none; } +.app-chat-panel__preview-modal .app-chat-panel__preview-rich--markdown, +.app-chat-preview-card--fullscreen .app-chat-panel__preview-rich--markdown { + height: 100%; + max-height: none; +} + .app-chat-panel__preview-modal .previewer-ui__editor, .app-chat-panel__preview-modal .codex-diff-previewer__diff-section, .app-chat-panel__preview-modal .app-chat-panel__preview-image, @@ -2403,8 +2441,17 @@ } @media (max-width: 720px) { - .app-chat-panel__preview-modal-footer { - justify-content: flex-end; + .app-chat-panel__preview-modal-title { + align-items: flex-start; + flex-direction: column; + } + + .app-chat-panel__preview-modal-findbar { + flex-wrap: wrap; + } + + .app-chat-panel__preview-modal-findbar .ant-btn { + flex: 1 1 calc(50% - 4px); } } diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index 1a6b4bb..6ecfac2 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -17,8 +17,9 @@ import { DeleteOutlined, } from '@ant-design/icons'; import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd'; +import type { InputRef } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; -import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type SetStateAction } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAppStore } from '../../store'; import { useAppConfig } from './appConfig'; @@ -192,6 +193,267 @@ function createConversationPreviewText(text: string) { return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } +type PreviewTextMatch = { + node: Text; + start: number; + end: number; +}; + +type PreviewMatchAnchor = { + match: PreviewTextMatch; + target: HTMLElement; + offsetTop: number; +}; + +function collectPreviewTextMatches(root: HTMLElement, keyword: string) { + const normalizedKeyword = keyword.trim().toLocaleLowerCase(); + + if (!normalizedKeyword || typeof document === 'undefined') { + return [] as PreviewTextMatch[]; + } + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const matches: PreviewTextMatch[] = []; + let currentNode = walker.nextNode(); + + while (currentNode) { + if (currentNode.nodeType === Node.TEXT_NODE) { + const textNode = currentNode as Text; + const textContent = textNode.textContent ?? ''; + const normalizedText = textContent.toLocaleLowerCase(); + + if (normalizedText) { + let fromIndex = 0; + + while (fromIndex < normalizedText.length) { + const matchIndex = normalizedText.indexOf(normalizedKeyword, fromIndex); + + if (matchIndex < 0) { + break; + } + + matches.push({ + node: textNode, + start: matchIndex, + end: matchIndex + normalizedKeyword.length, + }); + fromIndex = matchIndex + Math.max(1, normalizedKeyword.length); + } + } + } + + currentNode = walker.nextNode(); + } + + return matches; +} + +function focusPreviewTextMatch(match: PreviewTextMatch) { + if (typeof document === 'undefined' || typeof window === 'undefined') { + return false; + } + + const selection = window.getSelection(); + + if (!selection) { + return false; + } + + const range = document.createRange(); + range.setStart(match.node, match.start); + range.setEnd(match.node, match.end); + selection.removeAllRanges(); + selection.addRange(range); + + return true; +} + +function resolvePreviewMatchTargetElement(match: PreviewTextMatch) { + return ( + match.node.parentElement?.closest( + '.previewer-ui__line, .markdown-preview__block, .codex-diff-previewer__diff-line, p, li, h1, h2, h3, pre, code, div', + ) ?? match.node.parentElement + ); +} + +function resolvePreviewScrollContainer(root: HTMLElement) { + const preferredSelectors = [ + '.app-chat-panel__preview-rich--markdown', + '.app-chat-panel__preview-rich', + '.codex-diff-previewer__diff-body', + '.previewer-ui__editor-body', + ]; + + if (root.scrollHeight > root.clientHeight + 4) { + return root; + } + + for (const selector of preferredSelectors) { + const candidate = root.querySelector(selector); + + if (candidate && candidate.scrollHeight > candidate.clientHeight + 4) { + return candidate; + } + } + + return root; +} + +function getElementOffsetWithinContainer(target: HTMLElement, container: HTMLElement) { + const targetRect = target.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + return targetRect.top - containerRect.top + container.scrollTop; +} + +function getPreviewMatchOffsetWithinContainer(match: PreviewTextMatch, container: HTMLElement) { + if (typeof document === 'undefined') { + return null; + } + + const range = document.createRange(); + range.setStart(match.node, match.start); + range.setEnd(match.node, match.end); + + const rects = range.getClientRects(); + const firstRect = rects.item(0); + + if (!firstRect) { + return null; + } + + const containerRect = container.getBoundingClientRect(); + return firstRect.top - containerRect.top + container.scrollTop; +} + +function getPreviewMatchHeight(match: PreviewTextMatch) { + if (typeof document === 'undefined') { + return null; + } + + const range = document.createRange(); + range.setStart(match.node, match.start); + range.setEnd(match.node, match.end); + + const rects = range.getClientRects(); + const firstRect = rects.item(0); + + if (!firstRect) { + return null; + } + + return firstRect.height; +} + +function buildPreviewMatchAnchors(matches: PreviewTextMatch[], container: HTMLElement) { + const anchors: PreviewMatchAnchor[] = []; + + matches.forEach((match) => { + const target = resolvePreviewMatchTargetElement(match); + + if (!target) { + return; + } + + anchors.push({ + match, + target, + offsetTop: getPreviewMatchOffsetWithinContainer(match, container) ?? getElementOffsetWithinContainer(target, container), + }); + }); + + return anchors; +} + +function resolvePreviewSearchAnchorIndex( + anchors: PreviewMatchAnchor[], + container: HTMLElement, + direction: 'forward' | 'backward', +) { + if (anchors.length === 0) { + return -1; + } + + const viewportAnchor = container.scrollTop + container.clientHeight / 2; + const threshold = 4; + + if (direction === 'backward') { + for (let index = anchors.length - 1; index >= 0; index -= 1) { + if (anchors[index].offsetTop < viewportAnchor - threshold) { + return index; + } + } + + return anchors.length - 1; + } + + for (let index = 0; index < anchors.length; index += 1) { + if (anchors[index].offsetTop > viewportAnchor + threshold) { + return index; + } + } + + return 0; +} + +function resolvePreviewSearchResultIndex(args: { + anchors: PreviewMatchAnchor[]; + container: HTMLElement; + direction: 'forward' | 'backward'; + currentIndex: number; +}) { + const { anchors, container, direction, currentIndex } = args; + + if (anchors.length === 0) { + return -1; + } + + if (currentIndex >= 0 && currentIndex < anchors.length) { + if (direction === 'backward') { + return currentIndex === 0 ? anchors.length - 1 : currentIndex - 1; + } + + return currentIndex === anchors.length - 1 ? 0 : currentIndex + 1; + } + + return resolvePreviewSearchAnchorIndex(anchors, container, direction); +} + +function focusPreviewTextMatchInContainer(match: PreviewTextMatch, container: HTMLElement) { + if (!focusPreviewTextMatch(match)) { + return false; + } + + const matchTop = getPreviewMatchOffsetWithinContainer(match, container); + + if (matchTop != null) { + const matchHeight = getPreviewMatchHeight(match) ?? 0; + const nextScrollTop = Math.max(0, matchTop - container.clientHeight / 2 + matchHeight / 2); + + container.scrollTo({ + top: nextScrollTop, + behavior: 'smooth', + }); + + return true; + } + + const target = resolvePreviewMatchTargetElement(match); + + if (!target) { + return false; + } + + const targetTop = getElementOffsetWithinContainer(target, container); + const nextScrollTop = Math.max(0, targetTop - container.clientHeight / 2 + target.clientHeight / 2); + + container.scrollTo({ + top: nextScrollTop, + behavior: 'smooth', + }); + + return true; +} + function resolveConversationListPreviewText(preview: string) { const normalized = createConversationPreviewText(preview); @@ -646,12 +908,19 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const [isMaximized, setIsMaximized] = useState(false); const [isResourceStripOpen, setIsResourceStripOpen] = useState(false); const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false); + const [isPreviewFindOpen, setIsPreviewFindOpen] = useState(false); + const [previewFindQuery, setPreviewFindQuery] = useState(''); const [notificationToggleSessionId, setNotificationToggleSessionId] = useState(null); const [renamingConversationSessionId, setRenamingConversationSessionId] = useState(null); const [messageApi, messageContextHolder] = message.useMessage(); const [pendingContextConfirm, setPendingContextConfirm] = useState(null); const viewportRef = useRef(null); const composerRef = useRef(null); + const previewFindInputRef = useRef(null); + const previewSearchRootRef = useRef(null); + const previewSearchMatchesRef = useRef([]); + const previewSearchMatchIndexRef = useRef(-1); + const previewSearchKeyRef = useRef(''); const titleClusterRef = useRef(null); const copyFeedbackTimerRef = useRef(null); const pendingRequestsRef = useRef([]); @@ -661,6 +930,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting'); const shouldRestoreConversationAfterReconnectRef = useRef(false); const handledRequestedSessionIdRef = useRef(''); + const isClosingConversationRef = useRef(false); const lastChatTypeSessionIdRef = useRef(''); const notifiedTerminalJobKeysRef = useRef([]); const lastMarkedReadResponseIdBySessionRef = useRef>({}); @@ -746,6 +1016,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = clientId: null, title: '새 대화', chatTypeId: selectedChatType?.id ?? null, + lastChatTypeId: selectedChatType?.id ?? null, contextLabel: selectedChatType?.name ?? null, contextDescription: selectedChatType?.description ?? null, notifyOffline: true, @@ -771,6 +1042,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = sessionId, title: '새 대화', chatTypeId: selectedChatType?.id ?? null, + lastChatTypeId: selectedChatType?.id ?? null, contextLabel: selectedChatType?.name, contextDescription: selectedChatType?.description, notifyOffline: true, @@ -1344,6 +1616,130 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } }, [activePreview, messageApi]); + const canSearchActivePreview = + Boolean(activePreview) && + !isPreviewLoading && + !previewError.trim() && + (activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document'); + + const resetActivePreviewSearchState = useCallback(() => { + previewSearchMatchesRef.current = []; + previewSearchMatchIndexRef.current = -1; + previewSearchKeyRef.current = ''; + }, []); + + const clearActivePreviewSearchSelection = useCallback(() => { + if (typeof window !== 'undefined') { + window.getSelection()?.removeAllRanges(); + } + }, []); + + useEffect(() => { + if (!isPreviewModalOpen) { + setIsPreviewFindOpen(false); + setPreviewFindQuery(''); + resetActivePreviewSearchState(); + clearActivePreviewSearchSelection(); + return; + } + + if (isPreviewFindOpen) { + window.setTimeout(() => { + previewFindInputRef.current?.focus(); + }, 0); + } + }, [clearActivePreviewSearchSelection, isPreviewFindOpen, isPreviewModalOpen, resetActivePreviewSearchState]); + + useEffect(() => { + resetActivePreviewSearchState(); + clearActivePreviewSearchSelection(); + }, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]); + + useEffect(() => { + resetActivePreviewSearchState(); + }, [previewFindQuery, resetActivePreviewSearchState]); + + const handlePreviewSearchRootPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!isMobileViewport || !isPreviewFindOpen) { + return; + } + + const inputElement = previewFindInputRef.current?.input; + + if (!inputElement || document.activeElement !== inputElement) { + return; + } + + if (event.target instanceof Node && inputElement.contains(event.target)) { + return; + } + + const selection = window.getSelection(); + + if (!selection || selection.rangeCount === 0) { + return; + } + + event.preventDefault(); + inputElement.blur(); + }, + [isMobileViewport, isPreviewFindOpen], + ); + + const handleFindActivePreview = useCallback( + (direction: 'forward' | 'backward' = 'forward') => { + const keyword = previewFindQuery.trim(); + + if (!keyword) { + void messageApi.info('찾을 단어를 입력해주세요.'); + return; + } + + const root = previewSearchRootRef.current; + + if (!root) { + void messageApi.error('미리보기 본문을 아직 찾을 수 없습니다.'); + return; + } + + const searchKey = `${activePreview?.id ?? 'preview'}:${keyword.toLocaleLowerCase()}`; + const matches = + previewSearchKeyRef.current === searchKey + ? previewSearchMatchesRef.current + : collectPreviewTextMatches(root, keyword); + + if (previewSearchKeyRef.current !== searchKey) { + previewSearchKeyRef.current = searchKey; + previewSearchMatchesRef.current = matches; + previewSearchMatchIndexRef.current = -1; + } + + if (matches.length === 0) { + void messageApi.info('일치하는 단어를 찾지 못했습니다.'); + return; + } + + const scrollContainer = resolvePreviewScrollContainer(root); + const anchors = buildPreviewMatchAnchors(matches, scrollContainer); + const nextIndex = resolvePreviewSearchResultIndex({ + anchors, + container: scrollContainer, + direction, + currentIndex: previewSearchMatchIndexRef.current, + }); + const nextAnchor = nextIndex >= 0 ? anchors[nextIndex] : null; + + if (!nextAnchor || !focusPreviewTextMatchInContainer(nextAnchor.match, scrollContainer)) { + void messageApi.error('검색 결과 위치로 이동하지 못했습니다.'); + return; + } + + previewSearchMatchIndexRef.current = nextIndex; + }, + [activePreview?.id, messageApi, previewFindQuery], + ); + const markConversationReadLocally = (sessionId: string) => { const normalizedSessionId = sessionId.trim(); @@ -1532,7 +1928,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [location.pathname, location.search, navigate]); const openConversationSession = (sessionId: string) => { + isClosingConversationRef.current = false; replaceChatSessionInUrl(sessionId); + clearRequestedRuntimeLogInUrl(); + setActiveView('chat'); const now = new Date().toISOString(); const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? []; const hasCachedMessages = cachedMessages.length > 0; @@ -1566,6 +1965,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = clientId: null, title: '대화 내용을 불러오는 중입니다.', chatTypeId: null, + lastChatTypeId: null, contextLabel: null, contextDescription: null, notifyOffline: true, @@ -1842,7 +2242,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (activeSessionId) { if (hasSessionChanged) { - const lastUsedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId); + const lastUsedChatTypeId = + activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId); if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) { if (selectedChatTypeId !== lastUsedChatTypeId) { @@ -1865,7 +2266,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } setSelectedChatTypeId(availableChatTypes[0]?.id ?? null); - }, [activeSessionId, availableChatTypes, selectedChatTypeId]); + }, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]); useEffect(() => { if (!activeSessionId || !selectedChatTypeId) { @@ -1873,7 +2274,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId); - }, [activeSessionId, selectedChatTypeId]); + setConversationItems((previous) => + previous.map((item) => + item.sessionId === activeSessionId && item.lastChatTypeId !== selectedChatTypeId + ? { ...item, lastChatTypeId: selectedChatTypeId } + : item, + ), + ); + + const currentLastChatTypeId = activeConversation?.lastChatTypeId?.trim() || null; + + if (currentLastChatTypeId === selectedChatTypeId) { + return; + } + + void chatGateway.updateConversation(activeSessionId, { + lastChatTypeId: selectedChatTypeId, + }).catch(() => { + // Ignore background sync failures and keep local in-memory fallback. + }); + }, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]); useEffect(() => { const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat'); @@ -1925,7 +2345,12 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } + if (requestedSessionId && !requestedChatView) { + setActiveView('chat'); + } + if (!requestedSessionId) { + isClosingConversationRef.current = false; handledRequestedSessionIdRef.current = ''; return; } @@ -1934,21 +2359,40 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } - if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) { + if (isClosingConversationRef.current && requestedSessionId === activeSessionId) { + return; + } + + if ( + requestedSessionId === activeSessionId && + handledRequestedSessionIdRef.current === requestedSessionId && + activeView === 'chat' && + !isConversationPaneClosed && + (!isMobileViewport || isMobileConversationView) + ) { return; } handledRequestedSessionIdRef.current = requestedSessionId; if (requestedSessionId === activeSessionId) { - if (isMobileViewport && !isConversationPaneClosed) { - setIsMobileConversationView(true); - } + openConversationSession(requestedSessionId); return; } openConversationSession(requestedSessionId); - }, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, location.pathname, requestedSessionId]); + }, [ + activeSessionId, + activeView, + conversationItems, + isConversationListLoading, + isConversationPaneClosed, + isMobileConversationView, + isMobileViewport, + location.pathname, + requestedChatView, + requestedSessionId, + ]); useEffect(() => { if (requestedSessionId) { @@ -2329,6 +2773,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = icon={} aria-label="대화창 닫기" onClick={() => { + isClosingConversationRef.current = true; handledRequestedSessionIdRef.current = ''; replaceChatSessionInUrl(''); setIsConversationPaneClosed(true); @@ -2594,17 +3039,30 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = - +
+ {`${activePreview.label} preview`} + + {canSearchActivePreview ? ( +
- ) : null + ) : ( + 'preview' + ) } + footer={null} onCancel={() => { setIsPreviewModalOpen(false); }} @@ -2620,13 +3078,52 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = {activePreview.source === 'context' ? '현재 화면' : '채팅 결과'}
-
+ {canSearchActivePreview && isPreviewFindOpen ? ( +
+ { + setPreviewFindQuery(event.target.value); + }} + onPressEnter={(event) => { + handleFindActivePreview(event.shiftKey ? 'backward' : 'forward'); + }} + /> + + +
+ ) : null} +
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index c1ee352..6aea2c6 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -15,6 +15,7 @@ import { Alert, Button, Checkbox, + Divider, Drawer, Dropdown, Grid, @@ -869,6 +870,7 @@ export function MainHeader({ void contentExpanded; void onToggleContentExpanded; const screens = useBreakpoint(); + const [modalApi, modalContextHolder] = Modal.useModal(); const navigate = useNavigate(); const location = useLocation(); const [settingsOpen, setSettingsOpen] = useState(false); @@ -911,9 +913,12 @@ export function MainHeader({ const [clientResetFeedback, setClientResetFeedback] = useState(null); const [clientResetCopyFeedback, setClientResetCopyFeedback] = useState(null); const [testServerStatus, setTestServerStatus] = useState(null); + const [prodServerStatus, setProdServerStatus] = useState(null); const [workServerStatus, setWorkServerStatus] = useState(null); const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); - const [serverRestartingKey, setServerRestartingKey] = useState<'test' | 'work-server' | 'all' | null>(null); + const [serverRestartingKey, setServerRestartingKey] = useState< + 'test' | 'prod' | 'work-server' | 'command-runner' | 'all' | null + >(null); const [serverRestartFeedback, setServerRestartFeedback] = useState(null); const [serverRestartCopyFeedback, setServerRestartCopyFeedback] = useState(null); const { registeredToken, hasAccess } = useTokenAccess(); @@ -941,9 +946,11 @@ export function MainHeader({ : 'app-header__status-dot--inactive'; const testServerPendingUpdateCount = testServerStatus && (testServerStatus.updateAvailable || testServerStatus.buildRequired) ? 1 : 0; + const prodServerPendingUpdateCount = + prodServerStatus && (prodServerStatus.updateAvailable || prodServerStatus.buildRequired) ? 1 : 0; const workServerPendingUpdateCount = workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; - const totalPendingUpdateCount = testServerPendingUpdateCount + workServerPendingUpdateCount; + const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount; const settingsStatusClassName = totalPendingUpdateCount >= 2 ? 'app-header__status-dot--inactive' @@ -989,6 +996,8 @@ export function MainHeader({ const searchParams = new URLSearchParams(location.search); searchParams.set('topMenu', 'chat'); searchParams.set('sessionId', sessionId); + searchParams.delete('chatView'); + searchParams.delete('runtimeRequestId'); navigate({ pathname: buildChatPath('live'), search: `?${searchParams.toString()}`, @@ -1482,18 +1491,22 @@ export function MainHeader({ const refreshServerStatuses = async () => { const items = await fetchServerCommands(); const nextTestServerStatus = items.find((item) => item.key === 'test') ?? null; + const nextProdServerStatus = items.find((item) => item.key === 'prod') ?? null; const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null; setTestServerStatus(nextTestServerStatus); + setProdServerStatus(nextProdServerStatus); setWorkServerStatus(nextWorkServerStatus); return { test: nextTestServerStatus, + prod: nextProdServerStatus, 'work-server': nextWorkServerStatus, - } satisfies Record<'test' | 'work-server', ServerCommandItem | null>; + } satisfies Record<'test' | 'prod' | 'work-server', ServerCommandItem | null>; }; const refreshUpdateTargets = async (silent = false) => { if (!hasAccess) { setTestServerStatus(null); + setProdServerStatus(null); setWorkServerStatus(null); if (!silent) { setUpdateCheckFeedback({ tone: 'warning', message: '업데이트 확인은 권한 토큰 등록 후 사용할 수 있습니다.' }); @@ -1515,7 +1528,7 @@ export function MainHeader({ if (!silent) { setUpdateCheckFeedback({ tone: 'error', - message: error instanceof Error ? error.message : 'TEST/WORK 서버 업데이트 상태를 불러오지 못했습니다.', + message: error instanceof Error ? error.message : 'TEST/PROD/WORK 서버 업데이트 상태를 불러오지 못했습니다.', }); } return null; @@ -1524,7 +1537,7 @@ export function MainHeader({ } }; - const waitForServerRestart = async (key: 'test' | 'work-server', baseline: ServerCommandItem | null) => { + const waitForServerRestart = async (key: 'test' | 'prod' | 'work-server', baseline: ServerCommandItem | null) => { for (let attempt = 0; attempt < 16; attempt += 1) { await waitForDuration(2500); @@ -1549,7 +1562,10 @@ export function MainHeader({ } } - return { ok: false, item: key === 'test' ? testServerStatus : workServerStatus }; + return { + ok: false, + item: key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus, + }; }; const handleResetClientState = async () => { @@ -1589,14 +1605,19 @@ export function MainHeader({ } }; - const restartServerWithVerification = async (key: 'test' | 'work-server', busyKey: 'test' | 'work-server' | 'all') => { - const baseline = key === 'test' ? testServerStatus : workServerStatus; - const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버'; + const restartServerWithVerification = async ( + key: 'test' | 'prod' | 'work-server', + busyKey: 'test' | 'prod' | 'work-server' | 'all', + ) => { + const baseline = key === 'test' ? testServerStatus : key === 'prod' ? prodServerStatus : workServerStatus; + const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; const result = await restartServerCommand(key); if (key === 'test') { setTestServerStatus(result.item); + } else if (key === 'prod') { + setProdServerStatus(result.item); } else { setWorkServerStatus(result.item); } @@ -1627,7 +1648,7 @@ export function MainHeader({ return true; }; - const handleRestartSingleServer = async (key: 'test' | 'work-server') => { + const handleRestartSingleServer = async (key: 'test' | 'prod' | 'work-server') => { if (!hasAccess || serverRestartingKey) { return false; } @@ -1639,7 +1660,7 @@ export function MainHeader({ try { return await restartServerWithVerification(key, key); } catch (error) { - const targetLabel = key === 'test' ? 'TEST 서버' : 'WORK 서버'; + const targetLabel = key === 'test' ? 'TEST 서버' : key === 'prod' ? 'PROD 컨테이너' : 'WORK 서버'; setServerRestartFeedback({ tone: 'error', message: error instanceof Error ? error.message : `${targetLabel} 재기동에 실패했습니다.`, @@ -1650,6 +1671,67 @@ export function MainHeader({ } }; + const handleConfirmRestartProdServer = () => { + if (!hasAccess || serverRestartingKey) { + return; + } + + modalApi.confirm({ + title: 'PROD 빌드 반영', + content: 'PROD 컨테이너를 빌드 후 재기동합니다. 진행할까요?', + okText: '빌드 및 재기동', + cancelText: '취소', + okButtonProps: { danger: true }, + onOk: async () => { + await handleRestartSingleServer('prod'); + }, + }); + }; + + const handleRestartCommandRunner = async () => { + if (!hasAccess || serverRestartingKey) { + return; + } + + setServerRestartCopyFeedback(null); + setServerRestartFeedback(null); + setServerRestartingKey('command-runner'); + + try { + const result = await restartServerCommand('command-runner'); + setServerRestartFeedback({ + tone: 'success', + message: + result.restartState === 'accepted' + ? 'Command runner 배포 및 재기동 요청을 접수했습니다.' + : 'Command runner 배포 및 재기동을 완료했습니다.', + }); + } catch (error) { + setServerRestartFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : 'Command runner 배포 및 재기동에 실패했습니다.', + }); + } finally { + setServerRestartingKey(null); + } + }; + + const handleConfirmRestartCommandRunner = () => { + if (!hasAccess || serverRestartingKey) { + return; + } + + modalApi.confirm({ + title: 'Command runner 배포 및 재기동', + content: '현재 command runner를 다시 배포하고 재기동합니다. 진행할까요?', + okText: '배포 및 재기동', + cancelText: '취소', + onOk: async () => { + await handleRestartCommandRunner(); + }, + }); + }; + const handleRestartBothServers = async () => { if (!hasAccess || serverRestartingKey) { return; @@ -1737,7 +1819,7 @@ export function MainHeader({ }; const handleResetNotificationIdentity = () => { - Modal.confirm({ + modalApi.confirm({ title: '알림 클라이언트 초기화', content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.', okText: '초기화', @@ -2718,6 +2800,7 @@ export function MainHeader({ return ( <> + {modalContextHolder}
@@ -3027,6 +3110,24 @@ export function MainHeader({ {activeAppSettingsSection === 'worklogAutomation' ? worklogAutomationPanel : null} {activeAppSettingsSection === 'automationNotifications' ? automationNotificationPanel : null} {activeAppSettingsSection === 'gestureShortcuts' ? gestureShortcutsPanel : null} + + + Command runner + + 별도 명시적 요청이 있을 때만 command runner 배포 및 재기동을 실행합니다. + + {renderFeedback(serverRestartFeedback, serverRestartCopyFeedback, setServerRestartCopyFeedback)} + + ) : null} {activeSettingsModal === 'notification' ? ( @@ -3154,6 +3255,17 @@ export function MainHeader({ 소스 수정일: {getServerLastSourceChangedDateLabel(workServerStatus)} + + 운영 + + {formatDateTimeLabel(prodServerStatus?.runningBuiltAt ?? null)} {renderFeedback(updateCheckFeedback, updateCheckCopyFeedback, setUpdateCheckCopyFeedback)} + + PROD 빌드 반영 + + 운영 마지막 확인: {formatDateTimeLabel(prodServerStatus?.checkedAt ?? null)} + + PROD 컨테이너는 전체 재기동에 포함하지 않고, 별도 확인 후 빌드와 재기동을 진행합니다. + + ) : null} diff --git a/src/app/main/chatV2/components/ConversationRoomPane.tsx b/src/app/main/chatV2/components/ConversationRoomPane.tsx index 016a437..2f1adc1 100644 --- a/src/app/main/chatV2/components/ConversationRoomPane.tsx +++ b/src/app/main/chatV2/components/ConversationRoomPane.tsx @@ -392,6 +392,7 @@ function InlineMessagePreview({ isPreviewLoading={isPreviewLoading} previewError={previewError} previewContentType={previewContentType} + maxMarkdownBlocks={12} /> ) : null} diff --git a/src/app/main/chatV2/data/chatGateway.ts b/src/app/main/chatV2/data/chatGateway.ts index 046d2ee..1a5b7b1 100644 --- a/src/app/main/chatV2/data/chatGateway.ts +++ b/src/app/main/chatV2/data/chatGateway.ts @@ -36,6 +36,7 @@ export type ChatGateway = { sessionId: string; title: string; chatTypeId?: string | null; + lastChatTypeId?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; @@ -44,7 +45,10 @@ export type ChatGateway = { updateConversation: ( sessionId: string, payload: Partial< - Pick + Pick< + ChatConversationSummary, + 'title' | 'chatTypeId' | 'lastChatTypeId' | 'contextLabel' | 'contextDescription' | 'notifyOffline' + > >, ) => Promise; deleteConversation: (sessionId: string) => Promise; diff --git a/src/app/main/mainChatPanel/ChatConversationView.tsx b/src/app/main/mainChatPanel/ChatConversationView.tsx index c7e9964..9594eee 100755 --- a/src/app/main/mainChatPanel/ChatConversationView.tsx +++ b/src/app/main/mainChatPanel/ChatConversationView.tsx @@ -553,6 +553,7 @@ function InlineMessagePreview({ isPreviewLoading={isLoading} previewError={previewError} previewContentType={previewContentType} + maxMarkdownBlocks={12} /> ) : null} diff --git a/src/app/main/mainChatPanel/ChatPreviewBody.tsx b/src/app/main/mainChatPanel/ChatPreviewBody.tsx index b561e56..669afbd 100755 --- a/src/app/main/mainChatPanel/ChatPreviewBody.tsx +++ b/src/app/main/mainChatPanel/ChatPreviewBody.tsx @@ -209,6 +209,7 @@ type ChatPreviewBodyProps = { isPreviewLoading: boolean; previewError: string; previewContentType?: string; + maxMarkdownBlocks?: number; }; function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) { @@ -236,6 +237,7 @@ export function ChatPreviewBody({ isPreviewLoading, previewError, previewContentType, + maxMarkdownBlocks, }: ChatPreviewBodyProps) { if (!target) { return ; @@ -294,7 +296,10 @@ export function ChatPreviewBody({ if (target.kind === 'markdown') { return (
- +
); } diff --git a/src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx b/src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx index c753392..23aaaa8 100755 --- a/src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx +++ b/src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx @@ -1,10 +1,11 @@ -import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined } from '@ant-design/icons'; -import { Button, Drawer, Empty, Modal, Space, Typography } from 'antd'; +import { ClockCircleOutlined, EyeOutlined, LoadingOutlined, StopOutlined, UndoOutlined } from '@ant-design/icons'; +import { Button, Drawer, Empty, Modal, Space, Typography, message } from 'antd'; import { useEffect, useRef, useState } from 'react'; import { cancelChatRuntimeJob, fetchChatRuntimeJobDetail, removeChatRuntimeJob, + rollbackChatRuntimeJob, } from './chatUtils'; import type { ChatRuntimeJobDetail, ChatRuntimeJobItem, ChatRuntimeSnapshot } from './types'; @@ -157,10 +158,14 @@ function RecentRuntimeList({ items, onSelectSession, onOpenLog, + onRollbackJob, + pendingActionRequestId, }: { items: ChatRuntimeSnapshot['recent']; onSelectSession: (sessionId: string) => void; onOpenLog: (requestId: string) => void; + onRollbackJob: (requestId: string, sessionId: string) => void; + pendingActionRequestId: string | null; }) { return (
@@ -181,22 +186,35 @@ function RecentRuntimeList({ {buildTerminalLabel(item.terminalStatus)} {item.mode === 'direct' ? '즉시' : '큐'} - - + + + + + {item.summary || '요약 없음'}
@@ -233,6 +251,8 @@ export function ChatRuntimeDashboard({ onRequestedLogHandled?: () => void; }) { const sessions = snapshot?.sessions ?? []; + const [messageApi, messageContextHolder] = message.useMessage(); + const [modalApi, modalContextHolder] = Modal.useModal(); const [selectedDetail, setSelectedDetail] = useState(null); const [isLogModalOpen, setIsLogModalOpen] = useState(false); const [logLoadError, setLogLoadError] = useState(''); @@ -240,6 +260,23 @@ export function ChatRuntimeDashboard({ const [pendingActionRequestId, setPendingActionRequestId] = useState(null); const logViewerRef = useRef(null); + const confirmAction = (options: { + title: string; + content: string; + okText: string; + cancelText?: string; + }) => + new Promise((resolve) => { + modalApi.confirm({ + title: options.title, + content: options.content, + okText: options.okText, + cancelText: options.cancelText ?? '닫기', + onOk: () => resolve(true), + onCancel: () => resolve(false), + }); + }); + const loadLogDetail = async (requestId: string) => { setIsLogLoading(true); setLogLoadError(''); @@ -261,15 +298,10 @@ export function ChatRuntimeDashboard({ }; const handleCancel = async (requestId: string) => { - const confirmed = await new Promise((resolve) => { - Modal.confirm({ - title: '실행 중 요청을 취소할까요?', - content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.', - okText: '취소 실행', - cancelText: '닫기', - onOk: () => resolve(true), - onCancel: () => resolve(false), - }); + const confirmed = await confirmAction({ + title: '실행 중 요청을 취소할까요?', + content: '이미 실행 중인 Codex 프로세스에 종료 신호를 보냅니다.', + okText: '취소 실행', }); if (!confirmed) { @@ -280,21 +312,22 @@ export function ChatRuntimeDashboard({ try { await cancelChatRuntimeJob(requestId); + messageApi.success('취소 요청을 보냈습니다.'); + if (selectedDetail?.item?.requestId === requestId) { + await loadLogDetail(requestId); + } + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '실행 취소 요청에 실패했습니다.'); } finally { setPendingActionRequestId(null); } }; const handleRemove = async (requestId: string) => { - const confirmed = await new Promise((resolve) => { - Modal.confirm({ - title: '대기열 요청을 제거할까요?', - content: '아직 실행되지 않은 대기 요청만 제거됩니다.', - okText: '제거', - cancelText: '닫기', - onOk: () => resolve(true), - onCancel: () => resolve(false), - }); + const confirmed = await confirmAction({ + title: '대기열 요청을 제거할까요?', + content: '아직 실행되지 않은 대기 요청만 제거됩니다.', + okText: '제거', }); if (!confirmed) { @@ -305,6 +338,38 @@ export function ChatRuntimeDashboard({ try { await removeChatRuntimeJob(requestId); + messageApi.success('대기 요청을 제거했습니다.'); + if (selectedDetail?.item?.requestId === requestId) { + await loadLogDetail(requestId); + } + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '대기 요청 제거에 실패했습니다.'); + } finally { + setPendingActionRequestId(null); + } + }; + + const handleRollback = async (requestId: string, sessionId: string) => { + const confirmed = await confirmAction({ + title: '최근 실행 변경을 롤백할까요?', + content: '이 실행이 남긴 diff만 역적용합니다. 이후 다른 세션이 같은 라인을 수정했다면 실패할 수 있습니다.', + okText: '롤백', + }); + + if (!confirmed) { + return; + } + + setPendingActionRequestId(requestId); + + try { + await rollbackChatRuntimeJob(requestId, sessionId); + messageApi.success('최근 실행 변경을 롤백했습니다.'); + if (selectedDetail?.item?.requestId === requestId) { + await loadLogDetail(requestId); + } + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '최근 실행 롤백에 실패했습니다.'); } finally { setPendingActionRequestId(null); } @@ -362,6 +427,8 @@ export function ChatRuntimeDashboard({ return ( <> + {messageContextHolder} + {modalContextHolder}
@@ -431,7 +498,13 @@ export function ChatRuntimeDashboard({ onRemoveJob={handleRemove} pendingActionRequestId={pendingActionRequestId} /> - +
diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index 8d5d7b0..bf7bc21 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -1003,6 +1003,19 @@ export async function removeChatRuntimeJob(requestId: string) { return response.removed; } +export async function rollbackChatRuntimeJob(requestId: string, sessionId?: string | null) { + const response = await requestChatApi<{ ok: boolean; rolledBack: boolean }>( + `/runtime/jobs/${encodeURIComponent(requestId)}/rollback`, + { + method: 'POST', + body: JSON.stringify({ + sessionId: sessionId?.trim() || undefined, + }), + }, + ); + return response.rolledBack; +} + export async function uploadChatComposerFile(sessionId: string, file: File) { const normalizedSessionId = sessionId.trim(); @@ -1028,6 +1041,7 @@ export async function createChatConversationRoom(args: { sessionId: string; title?: string; chatTypeId?: string | null; + lastChatTypeId?: string | null; contextLabel?: string; contextDescription?: string; notifyOffline?: boolean; @@ -1040,6 +1054,7 @@ export async function createChatConversationRoom(args: { sessionId: args.sessionId, title: args.title ?? '새 대화', chatTypeId: args.chatTypeId ?? null, + lastChatTypeId: args.lastChatTypeId ?? args.chatTypeId ?? null, contextLabel: args.contextLabel ?? null, contextDescription: args.contextDescription ?? null, notifyOffline, @@ -1076,6 +1091,7 @@ export async function updateChatConversationRoom( payload: { title?: string; chatTypeId?: string | null; + lastChatTypeId?: string | null; contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; diff --git a/src/app/main/mainChatPanel/types.ts b/src/app/main/mainChatPanel/types.ts index 2c925b7..468f0cb 100755 --- a/src/app/main/mainChatPanel/types.ts +++ b/src/app/main/mainChatPanel/types.ts @@ -38,6 +38,7 @@ export type ChatConversationSummary = { clientId: string | null; title: string; chatTypeId: string | null; + lastChatTypeId: string | null; contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; diff --git a/src/components/previewer/PreviewerUI.css b/src/components/previewer/PreviewerUI.css index 6b48dc2..4c38e28 100755 --- a/src/components/previewer/PreviewerUI.css +++ b/src/components/previewer/PreviewerUI.css @@ -93,6 +93,25 @@ padding: 4px; } +.previewer-ui__findbar { + position: sticky; + top: 0; + z-index: 3; + display: flex; + align-items: center; + gap: 8px; + margin: -4px -4px 12px; + padding: 8px; + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid rgba(148, 163, 184, 0.2); + backdrop-filter: blur(10px); +} + +.previewer-ui__findbar .ant-input-affix-wrapper { + flex: 1 1 auto; + min-width: 0; +} + .previewer-ui__action-button { display: inline-flex; align-items: center; @@ -124,6 +143,12 @@ 0 12px 28px rgba(15, 23, 42, 0.16); } +.previewer-ui--expanded .previewer-ui__header { + position: sticky; + top: 0; + z-index: 4; +} + .previewer-ui--expanded .previewer-ui__body { height: calc(100vh - 53px) !important; } @@ -305,3 +330,24 @@ .previewer-ui__token--option { color: #c586c0; } + +@media (max-width: 720px) { + .previewer-ui__header { + align-items: flex-start; + flex-direction: column; + } + + .previewer-ui__toolbar { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + } + + .previewer-ui__findbar { + flex-wrap: wrap; + } + + .previewer-ui__findbar .ant-btn { + flex: 1 1 calc(50% - 4px); + } +} diff --git a/src/components/previewer/PreviewerUI.tsx b/src/components/previewer/PreviewerUI.tsx index 249acd1..7575028 100755 --- a/src/components/previewer/PreviewerUI.tsx +++ b/src/components/previewer/PreviewerUI.tsx @@ -1,7 +1,13 @@ -import { CopyOutlined, DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons'; -import { Button, Empty, Select, message } from 'antd'; +import { + CopyOutlined, + DownloadOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + SearchOutlined, +} from '@ant-design/icons'; +import { Button, Empty, Input, Select, message } from 'antd'; import type { ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { InlineImage } from '../common/InlineImage'; import { CodexDiffBlock } from './CodexDiffBlock'; import type { PreviewerUIProps } from './types'; @@ -283,13 +289,17 @@ export function PreviewerUI({ }: PreviewerUIProps) { const [messageApi, contextHolder] = message.useMessage(); const [isExpanded, setIsExpanded] = useState(false); + const [isFindOpen, setIsFindOpen] = useState(false); + const [findQuery, setFindQuery] = useState(''); + const findInputRef = useRef(null); const hasLanguageSelector = type === 'code' && languageOptions && languageOptions.length > 0; const resolvedCopyValue = copyValue ?? resolveCopyValue({ type, value }); const resolvedDownloadValue = resolveDownloadValue({ type, value, downloadValue }); const resolvedDownloadFileName = resolveDownloadFileName({ type, language, downloadFileName }); const canCopy = copyable && resolvedCopyValue.trim().length > 0; const canDownload = downloadable && (Boolean(downloadUrl) || resolvedDownloadValue.trim().length > 0); - const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || Boolean(toolbar); + const canFind = type !== 'image' && type !== 'empty'; + const shouldShowActions = hasLanguageSelector || canCopy || canDownload || maximizable || canFind || Boolean(toolbar); useEffect(() => { if (!isExpanded || typeof document === 'undefined') { @@ -312,6 +322,18 @@ export function PreviewerUI({ }; }, [isExpanded]); + useEffect(() => { + if (!isExpanded) { + setIsFindOpen(false); + setFindQuery(''); + return; + } + + if (isFindOpen) { + window.setTimeout(() => findInputRef.current?.focus(), 0); + } + }, [isExpanded, isFindOpen]); + async function handleCopy() { if (!canCopy) { return; @@ -333,6 +355,44 @@ export function PreviewerUI({ setIsExpanded((previous) => !previous); } + function handleFind(direction: 'forward' | 'backward' = 'forward') { + const keyword = findQuery.trim(); + if (!keyword) { + messageApi.info('찾을 단어를 입력해주세요.'); + return; + } + + const browserWindow = typeof window === 'undefined' ? null : (window as Window & { + find?: ( + text: string, + caseSensitive?: boolean, + backwards?: boolean, + wrapAround?: boolean, + wholeWord?: boolean, + searchInFrames?: boolean, + showDialog?: boolean, + ) => boolean; + }); + + if (!browserWindow || typeof browserWindow.find !== 'function') { + messageApi.error('이 브라우저에서는 단어 찾기를 지원하지 않습니다.'); + return; + } + + const matched = browserWindow.find(keyword, false, direction === 'backward', true, false, false, false); + if (!matched) { + messageApi.info('일치하는 단어를 찾지 못했습니다.'); + } + } + + function toggleFind() { + if (!canFind) { + return; + } + + setIsFindOpen((previous) => !previous); + } + function handleDownload() { if (!canDownload) { return; @@ -405,6 +465,16 @@ export function PreviewerUI({ onClick={() => void toggleFullscreen()} /> ) : null} + {canFind ? ( + + +
+ ) : null} {renderContent({ type, value, diff --git a/src/components/search/SearchCommandModal.tsx b/src/components/search/SearchCommandModal.tsx index a943596..14c6935 100755 --- a/src/components/search/SearchCommandModal.tsx +++ b/src/components/search/SearchCommandModal.tsx @@ -30,7 +30,6 @@ export function SearchCommandModal({ submitHint, }: SearchCommandModalProps) { const inputRef = useRef(null); - const selectionLockRef = useRef(false); const [query, setQuery] = useState(''); const [isMobileViewport, setIsMobileViewport] = useState(() => { if (typeof window === 'undefined') { @@ -47,11 +46,10 @@ export function SearchCommandModal({ }, [open]); const submitOption = (option: SearchKeywordOption | undefined) => { - if (!option || selectionLockRef.current) { + if (!option) { return; } - selectionLockRef.current = true; onSelectOption(option); onClose(); }; @@ -127,7 +125,6 @@ export function SearchCommandModal({ return; } - selectionLockRef.current = false; window.requestAnimationFrame(() => { inputRef.current?.focus({ cursor: 'all', diff --git a/src/features/serverCommand/ServerCommandPage.tsx b/src/features/serverCommand/ServerCommandPage.tsx index ba57847..5c1b833 100755 --- a/src/features/serverCommand/ServerCommandPage.tsx +++ b/src/features/serverCommand/ServerCommandPage.tsx @@ -108,6 +108,7 @@ export function ServerCommandPage() { const [lastActionByKey, setLastActionByKey] = useState>({ test: { output: null, executedAt: '', restartState: 'completed' }, rel: { output: null, executedAt: '', restartState: 'completed' }, + prod: { output: null, executedAt: '', restartState: 'completed' }, 'work-server': { output: null, executedAt: '', restartState: 'completed' }, 'command-runner': { output: null, executedAt: '', restartState: 'completed' }, }); @@ -211,7 +212,7 @@ export function ServerCommandPage() { Server Command - TEST, REL, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다. + TEST, REL, PROD, WORK-SERVER, COMMAND-RUNNER 상태를 확인하고 허용된 재기동 명령만 실행합니다. diff --git a/src/features/serverCommand/api.ts b/src/features/serverCommand/api.ts index 9d4e2bd..bf0aae2 100755 --- a/src/features/serverCommand/api.ts +++ b/src/features/serverCommand/api.ts @@ -129,7 +129,7 @@ function normalizeServerCommandItem(value: unknown): ServerCommandItem { const item = value as Partial>; const key = typeof item.key === 'string' ? item.key : ''; - if (key !== 'test' && key !== 'rel' && key !== 'work-server' && key !== 'command-runner') { + if (key !== 'test' && key !== 'rel' && key !== 'prod' && key !== 'work-server' && key !== 'command-runner') { throw new Error('지원하지 않는 서버 키입니다.'); } diff --git a/src/features/serverCommand/types.ts b/src/features/serverCommand/types.ts index c7fcd91..8733ec5 100755 --- a/src/features/serverCommand/types.ts +++ b/src/features/serverCommand/types.ts @@ -1,4 +1,4 @@ -export type ServerCommandKey = 'test' | 'rel' | 'work-server' | 'command-runner'; +export type ServerCommandKey = 'test' | 'rel' | 'prod' | 'work-server' | 'command-runner'; export type ServerCommandItem = { key: ServerCommandKey; diff --git a/src/layer/search/context/SearchLayerContext.tsx b/src/layer/search/context/SearchLayerContext.tsx index 667e142..8e6deb8 100755 --- a/src/layer/search/context/SearchLayerContext.tsx +++ b/src/layer/search/context/SearchLayerContext.tsx @@ -2,7 +2,6 @@ import { createContext, useContext, useMemo, - useRef, useState, type PropsWithChildren, } from 'react'; @@ -18,14 +17,12 @@ type SearchLayerContextValue = SearchLayerSnapshot & { }; const SearchLayerContext = createContext(null); -const WINDOW_SELECTION_DEDUP_MS = 500; export function SearchLayerProvider({ children }: PropsWithChildren) { const [open, setOpen] = useState(false); const [options, setOptions] = useState([]); const [mode, setMode] = useState('navigate'); const [windowSelections, setWindowSelections] = useState([]); - const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null); const value = useMemo( () => ({ @@ -35,7 +32,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) { windowSelections, setOptions, openSearch: (nextMode = 'navigate') => { - lastWindowSelectionRef.current = null; setMode(nextMode); setOpen(true); }, @@ -72,22 +68,6 @@ export function SearchLayerProvider({ children }: PropsWithChildren) { onClose={value.closeSearch} onSelectOption={(option) => { if (mode === 'window') { - const now = Date.now(); - const previousSelection = lastWindowSelectionRef.current; - - if ( - previousSelection && - previousSelection.id === option.id && - now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS - ) { - return; - } - - lastWindowSelectionRef.current = { - id: option.id, - at: now, - }; - setOpen(false); setWindowSelections((previous) => [ ...previous,