diff --git a/AGENTS.md b/AGENTS.md index 7414db3..7d2d122 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ * 채팅 첨부 파일은 `public/.codex_chat//resource/uploads/` 아래 경로를 기준으로 사용한다 * 원본 파일 경로만 응답해도 서버가 해당 리소스를 위 세션 경로로 복사해 공개 링크로 바꿔 줄 수 있지만, 문서와 안내 문구에는 위 공개 경로 기준을 우선 명시한다 * 토큰 등록이 필요한 화면 검증이나 모바일 스크린샷은 **비로그인 기본 화면(Docs)으로 확인하지 말고**, `.env`에 저장된 등록 토큰을 브라우저 `localStorage`에 주입한 상태로 캡처한다 +* 관리/등록 화면 UI를 만들 때는 버튼 클릭 후 뜨는 모달/드로어가 기존 하단 액션을 가리거나, 스크롤 마지막 입력이 잘리는 구조를 만들지 않는다. 데스크톱에서는 유사 항목을 한 줄에 묶고 좌우 여백을 적극 활용하며, 전역 헤더 우측 액션으로 바로 열 수 있는 기능은 중첩 모달보다 우선 검토한다 --- diff --git a/docs/features/plan-automation.md b/docs/features/plan-automation.md index 5e56d1e..7181958 100755 --- a/docs/features/plan-automation.md +++ b/docs/features/plan-automation.md @@ -4,7 +4,7 @@ Plan 자동화 기능의 데이터 구조, API 연동 방식, release 검수 연계 방식을 정리합니다. -현재 문서는 자동화 브랜치 전략 자체를 고정 규칙으로 설명하지 않습니다. 자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다. +자동화 처리 방식은 실제 서버 설정, 요청 문맥, 현재 운영 규칙을 함께 확인해 판단합니다. 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비하고, 작업 결과를 `release` 반영 후 `main`까지 반영하는 흐름입니다. 자동화와 `Codex Live`는 별개이며, 자동화 실행기는 선택된 자동화 유형의 context만 우선 참조합니다. 반면 `Codex Live`나 일반 수동 요청은 로컬 작업본 기준으로 처리합니다. ## 구현 위치 @@ -122,7 +122,7 @@ Plan 상세에서는 최신 자동 작업 결과를 탭 형태로 확인할 수 Codex 실행 로그에 `tokens used`가 잡히면 source work 기록과 상세 상단 상태 영역에 함께 표기합니다. -자동화 작업의 Git 절차나 동기화 순서는 이 문서에 고정 규칙으로 적지 않습니다. 필요 시 실제 worker 구현과 현재 설정값을 함께 확인합니다. +자동화 작업의 세부 Git 절차와 예외 처리 순서는 실제 worker 구현과 현재 설정값을 함께 확인합니다. ## 차트 집계 방식 diff --git a/etc/commands/server-command/restart-server-command-runner.sh b/etc/commands/server-command/restart-server-command-runner.sh index 42c99b6..844b53b 100755 --- a/etc/commands/server-command/restart-server-command-runner.sh +++ b/etc/commands/server-command/restart-server-command-runner.sh @@ -6,12 +6,14 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}" RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}" RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}" +SUPERVISOR_SCRIPT="${SERVER_COMMAND_RUNNER_SUPERVISOR_SCRIPT:-$PROJECT_ROOT/scripts/server-command-runner-supervisor.sh}" RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}" RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}" 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}" +SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}" RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT") if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then @@ -27,17 +29,36 @@ if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then exit 1 fi -RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true) -if [ -n "$RUNNER_PIDS" ]; then - kill $RUNNER_PIDS || true +if [ ! -x "$SUPERVISOR_SCRIPT" ]; then + chmod +x "$SUPERVISOR_SCRIPT" +fi + +if [ -f "$SUPERVISOR_PID_FILE" ]; then + SUPERVISOR_PID=$(cat "$SUPERVISOR_PID_FILE" 2>/dev/null || true) + if [ -n "${SUPERVISOR_PID:-}" ] && kill -0 "$SUPERVISOR_PID" 2>/dev/null; then + kill -HUP "$SUPERVISOR_PID" + echo "server-command-runner reload requested" + exit 0 + fi + + rm -f "$SUPERVISOR_PID_FILE" +fi + +LEGACY_RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true) +if [ -n "$LEGACY_RUNNER_PIDS" ]; then + kill $LEGACY_RUNNER_PIDS || true sleep 1 fi setsid env \ + PROJECT_ROOT="$PROJECT_ROOT" \ SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \ SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \ SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \ SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \ - "$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 >"$RUNNER_LOG_FILE" 2>&1 /dev/null 2>&1; then + RUNNING=$(docker inspect -f '{{.State.Running}}' work-server 2>/dev/null || printf 'false') + SUPERVISOR_CMD=$(docker inspect -f '{{json .Config.Cmd}}' work-server 2>/dev/null || printf '') + case "$SUPERVISOR_CMD" in + *work-server-supervisor*) + if [ "$RUNNING" = "true" ] && docker exec work-server kill -HUP 1 >/dev/null 2>&1; then + echo "work-server reload requested" + exit 0 + fi + ;; + esac +fi + +exec docker compose -f "$COMPOSE_FILE" up -d --build --no-deps work-server diff --git a/etc/db/work-db/sql/board-posts.sql b/etc/db/work-db/sql/board-posts.sql index 93c9985..c87d1c1 100755 --- a/etc/db/work-db/sql/board-posts.sql +++ b/etc/db/work-db/sql/board-posts.sql @@ -2,6 +2,7 @@ create table if not exists board_posts ( id serial primary key, title varchar(200) not null, content text not null, + attachments_json text not null default '[]', automation_plan_item_id integer null, automation_received_at timestamptz null, created_at timestamptz not null default now(), diff --git a/etc/servers/work-server/.dockerignore b/etc/servers/work-server/.dockerignore new file mode 100644 index 0000000..b50193e --- /dev/null +++ b/etc/servers/work-server/.dockerignore @@ -0,0 +1,4 @@ +.docker +node_modules +dist +npm-debug.log* diff --git a/etc/servers/work-server/Dockerfile b/etc/servers/work-server/Dockerfile index 4f447cf..abf4672 100644 --- a/etc/servers/work-server/Dockerfile +++ b/etc/servers/work-server/Dockerfile @@ -11,4 +11,7 @@ COPY src ./src RUN npm run build -CMD ["npm", "run", "start"] +COPY scripts/container-supervisor.sh /usr/local/bin/work-server-supervisor +RUN chmod +x /usr/local/bin/work-server-supervisor + +CMD ["/usr/local/bin/work-server-supervisor"] diff --git a/etc/servers/work-server/docker-compose.yml b/etc/servers/work-server/docker-compose.yml index 424f1c7..4ff9837 100644 --- a/etc/servers/work-server/docker-compose.yml +++ b/etc/servers/work-server/docker-compose.yml @@ -22,6 +22,8 @@ services: ports: - '127.0.0.1:3100:3100' volumes: + - ./:/app + - work-server-node-modules:/app/node_modules - ../../../:/workspace/main-project - ../../../.auto_codex:/workspace/auto_codex - ../../../scripts:/workspace/repo-scripts:ro @@ -50,3 +52,6 @@ services: networks: work-backend: name: work-backend + +volumes: + work-server-node-modules: diff --git a/etc/servers/work-server/scripts/container-supervisor.sh b/etc/servers/work-server/scripts/container-supervisor.sh new file mode 100755 index 0000000..9506cb2 --- /dev/null +++ b/etc/servers/work-server/scripts/container-supervisor.sh @@ -0,0 +1,96 @@ +#!/bin/sh + +set -eu + +APP_ROOT="${APP_ROOT:-/app}" +STATE_DIR="${WORK_SERVER_STATE_DIR:-/tmp/work-server-runtime}" +LOCK_FILE="$APP_ROOT/package-lock.json" +LOCK_HASH_FILE="$STATE_DIR/package-lock.sha256" +CHILD_PID="" +STOP_REQUESTED="0" +RELOAD_REQUESTED="0" + +mkdir -p "$STATE_DIR" +cd "$APP_ROOT" + +log() { + printf '[work-server-supervisor] %s\n' "$*" +} + +ensure_dependencies() { + if [ ! -f "$LOCK_FILE" ]; then + log "package-lock.json not found; skipping npm ci" + return 0 + fi + + CURRENT_HASH=$(sha256sum "$LOCK_FILE" | awk '{print $1}') + PREVIOUS_HASH="" + + if [ -f "$LOCK_HASH_FILE" ]; then + PREVIOUS_HASH=$(cat "$LOCK_HASH_FILE") + fi + + if [ ! -d "$APP_ROOT/node_modules" ] || [ "$CURRENT_HASH" != "$PREVIOUS_HASH" ]; then + log "installing dependencies" + npm ci --legacy-peer-deps + printf '%s' "$CURRENT_HASH" >"$LOCK_HASH_FILE" + fi +} + +prepare_runtime() { + ensure_dependencies + log "building latest source" + npm run build +} + +start_child() { + log "starting server process" + npm run start & + CHILD_PID=$! +} + +request_reload() { + log "reload requested" + if prepare_runtime; then + RELOAD_REQUESTED="1" + if [ -n "$CHILD_PID" ]; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + fi + else + log "reload aborted because build failed; keeping current process" + fi +} + +request_stop() { + STOP_REQUESTED="1" + log "shutdown requested" + if [ -n "$CHILD_PID" ]; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + fi +} + +trap 'request_reload' HUP +trap 'request_stop' INT TERM + +prepare_runtime + +while :; do + start_child + set +e + wait "$CHILD_PID" + EXIT_CODE=$? + set -e + CHILD_PID="" + + if [ "$STOP_REQUESTED" = "1" ]; then + exit "$EXIT_CODE" + fi + + if [ "$RELOAD_REQUESTED" = "1" ]; then + RELOAD_REQUESTED="0" + continue + fi + + log "server exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds" + sleep 2 +done diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts index 81b4057..d89bd31 100755 --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -32,6 +32,15 @@ const DEFAULT_CHAT_TYPES: ChatTypeRecord[] = [ enabled: true, updatedAt: '2026-04-23T00:00:00.000Z', }, + { + id: 'general-inquiry', + name: '일반 문의', + description: + '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-04-24T00:00:00.000Z', + }, ]; async function ensureAppConfigTable() { diff --git a/etc/servers/work-server/src/services/automation-type-config-service.ts b/etc/servers/work-server/src/services/automation-type-config-service.ts index d682efd..2e394f9 100644 --- a/etc/servers/work-server/src/services/automation-type-config-service.ts +++ b/etc/servers/work-server/src/services/automation-type-config-service.ts @@ -28,7 +28,7 @@ export const DEFAULT_AUTOMATION_TYPES: AutomationTypeRecord[] = [ id: 'none', name: '기본유형', description: - '## 기본 처리\n- 실제 작업 범위와 절차는 현재 요청 문맥과 운영 설정을 기준으로 판단합니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.\n- 세부 Git 흐름은 여기서 하드코딩하지 않습니다.', + '## 기본 처리\n- 기본 전략은 `main` 기준 `feature/*` 작업 브랜치를 준비해 진행합니다.\n- 작업 결과는 `release` 반영 후 `main`까지 반영하는 흐름을 따릅니다.\n- 소스 수정이 필요하면 관련 파일을 수정하고, 필요 없으면 확인 결과만 기록합니다.\n\n## 기록\n- 작업 결과에는 변경 파일, diff, preview 같은 증적을 남길 수 있습니다.', behaviorType: 'none', enabled: true, updatedAt: '2026-04-23T00:00:00.000Z', @@ -82,7 +82,7 @@ function mergeDefaultAutomationTypes(items: AutomationTypeRecord[]) { byId.set(defaultItem.id, { ...existingItem, name: defaultItem.name, - description: defaultItem.description, + description: existingItem.description || defaultItem.description, behaviorType: defaultItem.behaviorType, }); } diff --git a/etc/servers/work-server/src/services/board-service.test.ts b/etc/servers/work-server/src/services/board-service.test.ts index a1c05e5..5b7fe80 100644 --- a/etc/servers/work-server/src/services/board-service.test.ts +++ b/etc/servers/work-server/src/services/board-service.test.ts @@ -4,10 +4,15 @@ import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board- test('buildBoardPostPlanNote formats automation work memo with clear sections', () => { assert.equal( - buildBoardPostPlanNote(' 알림 개선 ', '본문 첫 줄\n본문 둘째 줄\n', { - name: '자동화 메모', - description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', - }), + buildBoardPostPlanNote( + ' 알림 개선 ', + '본문 첫 줄\n본문 둘째 줄\n', + [], + { + name: '자동화 메모', + description: '## 처리 기준\n- 선택된 유형의 context를 먼저 읽습니다.', + }, + ), [ '# 자동화 작업메모', '', @@ -27,10 +32,15 @@ test('buildBoardPostPlanNote formats automation work memo with clear sections', test('buildBoardPostPlanNote keeps context section even when automation type description is empty', () => { assert.equal( - buildBoardPostPlanNote('작업', '본문', { - name: '빈 context 유형', - description: ' ', - }), + buildBoardPostPlanNote( + '작업', + '본문', + [], + { + name: '빈 context 유형', + description: ' ', + }, + ), [ '# 자동화 작업메모', '', @@ -48,6 +58,42 @@ test('buildBoardPostPlanNote keeps context section even when automation type des ); }); +test('buildBoardPostPlanNote appends attachment lines when files exist', () => { + assert.equal( + buildBoardPostPlanNote( + '작업', + '본문', + [ + { + id: 'attachment-1', + name: 'spec.png', + path: 'public/.codex_chat/test/resource/uploads/spec.png', + publicUrl: '/api/chat/resources/.codex_chat/test/resource/uploads/spec.png', + size: 1280, + mimeType: 'image/png', + }, + ], + null, + ), + [ + '# 자동화 작업메모', + '', + '- 게시판 제목: 작업', + '- 메모 출처: board_posts 자동화 접수', + '- 자동화 처리 원칙: 아래 자동화 유형 context만 우선 참조하고, Codex Live 문맥은 섞지 않습니다.', + '', + '## 자동화 유형 context', + '선택된 자동화 유형 context 없음', + '', + '## 요청 본문', + '본문', + '', + '## 첨부 파일', + '- spec.png: public/.codex_chat/test/resource/uploads/spec.png', + ].join('\n'), + ); +}); + test('BoardPostAutomationLockedError keeps user-facing message by action', () => { assert.equal(new BoardPostAutomationLockedError('update').message, '자동화 접수된 작업메모는 수정할 수 없습니다.'); assert.equal(new BoardPostAutomationLockedError('delete').message, '자동화 접수된 작업메모는 삭제할 수 없습니다.'); diff --git a/etc/servers/work-server/src/services/board-service.ts b/etc/servers/work-server/src/services/board-service.ts index 69c4193..fa13ecd 100755 --- a/etc/servers/work-server/src/services/board-service.ts +++ b/etc/servers/work-server/src/services/board-service.ts @@ -17,6 +17,16 @@ export const BOARD_POSTS_TABLE = 'board_posts'; export const boardPostPayloadSchema = z.object({ title: z.string().trim().min(1).max(200), content: z.string().min(1).max(200000), + attachments: z.array( + z.object({ + id: z.string().trim().min(1).max(120), + name: z.string().trim().min(1).max(255), + path: z.string().trim().min(1).max(2000), + publicUrl: z.string().trim().min(1).max(2000), + size: z.coerce.number().int().min(0).max(10 * 1024 * 1024), + mimeType: z.string().trim().min(1).max(200), + }), + ).max(20).default([]), automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), }); @@ -25,6 +35,7 @@ export type BoardPostItem = { title: string; content: string; preview: string; + attachments: z.infer['attachments']; automationType: z.infer['automationType']; automationPlanItemId: number | null; automationReceivedAt: string | null; @@ -57,12 +68,22 @@ function createPreview(content: string) { function mapBoardPostRow(row: Record): BoardPostItem { const content = String(row.content ?? ''); + const rawAttachments = row.attachments_json ?? row.attachments ?? '[]'; + let attachments: BoardPostItem['attachments'] = []; + + try { + const parsed = typeof rawAttachments === 'string' ? JSON.parse(rawAttachments) : rawAttachments; + attachments = boardPostPayloadSchema.shape.attachments.parse(parsed); + } catch { + attachments = []; + } return { id: Number(row.id ?? 0), title: String(row.title ?? ''), content, preview: createPreview(content), + attachments, automationType: resolveStoredAutomationTypeId(row), automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined ? null @@ -79,7 +100,33 @@ function isBoardPostAutomationLocked(row: Record) { return Boolean(row.automation_received_at || row.automation_plan_item_id); } -export function buildBoardPostPlanNote(title: string, content: string, automationType?: Pick | null) { +function buildBoardAttachmentSection(attachments: z.infer['attachments']) { + const lines = attachments + .map((attachment) => { + const label = attachment.name.trim() || attachment.path.trim().split('/').pop() || '첨부 파일'; + const path = attachment.path.trim(); + + if (!path) { + return null; + } + + return `- ${label}: ${path}`; + }) + .filter((line): line is string => Boolean(line)); + + if (lines.length === 0) { + return []; + } + + return ['## 첨부 파일', ...lines]; +} + +export function buildBoardPostPlanNote( + title: string, + content: string, + attachments: z.infer['attachments'] = [], + automationType?: Pick | null, +) { const normalizedTitle = title.trim(); const normalizedContent = content.trim(); const normalizedAutomationTypeName = String(automationType?.name ?? '').trim(); @@ -98,6 +145,7 @@ export function buildBoardPostPlanNote(title: string, content: string, automatio '', '## 요청 본문', normalizedContent, + ...(attachments.length ? ['', ...buildBoardAttachmentSection(attachments)] : []), ] .filter((line): line is string => line !== null) .join('\n'); @@ -174,6 +222,7 @@ export async function ensureBoardPostsTable() { ['content', (table) => table.text('content').notNullable().defaultTo('')], ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], ['automation_type_id', (table) => table.string('automation_type_id', 120).nullable()], + ['attachments_json', (table) => table.text('attachments_json').notNullable().defaultTo('[]')], ['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()], ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], @@ -230,6 +279,7 @@ export async function createBoardPost(payload: z.infer { + assert.deepEqual( + extractCodexStreamText({ + type: 'item.completed', + item: { + id: 'item_1', + type: 'command_execution', + command: '/bin/bash -lc pwd', + aggregated_output: '/workspace/main-project\n', + exit_code: 0, + status: 'completed', + }, + }), + { + type: 'item.completed', + completedText: '', + deltaText: '', + }, + ); +}); + +test('extractCodexStreamText keeps completed agent messages', () => { + assert.deepEqual( + extractCodexStreamText({ + type: 'item.completed', + item: { + id: 'item_2', + type: 'agent_message', + text: '최종 응답입니다.', + }, + }), + { + type: 'item.completed', + completedText: '최종 응답입니다.', + deltaText: '', + }, + ); +}); + test('resolveResponseTimestamp moves fast replies behind the request second', () => { assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01'); }); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index e869891..a81dac2 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -1041,11 +1041,25 @@ function collectCodexTextFragments(value: unknown): string[] { return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); } +function extractCompletedAgentMessageText(item: unknown) { + if (!item || typeof item !== 'object') { + return ''; + } + + const record = item as Record; + + if (record.type !== 'agent_message') { + return ''; + } + + return collectCodexTextFragments(record.text ?? record.content ?? record.message).join(''); +} + export function extractCodexStreamText(parsed: Record) { const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'item.completed') { - const completedText = collectCodexTextFragments(parsed.item).join(''); + const completedText = extractCompletedAgentMessageText(parsed.item); return { type, completedText, @@ -3186,9 +3200,19 @@ export class ChatService { }); const progressMessages = buildProgressMessages(request.text); - let progressIndex = 0; + let progressIndex = progressMessages.length > 1 ? 1 : 0; + let lastProgressMessage = progressMessages[0] ?? ''; let progressTimer: ReturnType | null = setInterval(() => { const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)]; + + if (!nextMessage || nextMessage === lastProgressMessage) { + if (progressIndex >= progressMessages.length - 1) { + stopProgressTimer(); + } + return; + } + + lastProgressMessage = nextMessage; chatRuntimeService.appendLog(request.requestId, nextMessage); appendActivityLine(`# 진행: ${nextMessage}`); @@ -3199,6 +3223,8 @@ export class ChatService { if (progressIndex < progressMessages.length - 1) { progressIndex += 1; + } else { + stopProgressTimer(); } }, 2200); diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts index 4d2ca4c..cdc304d 100755 --- a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts +++ b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts @@ -437,6 +437,7 @@ export async function registerErrorLogBoardPosts(args?: { const createdPost = await createBoardPost({ title: buildErrorLogBoardPostTitle(candidate), content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), + attachments: [], automationType: 'none', }); diff --git a/main b/main new file mode 100644 index 0000000..e69de29 diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 6caf811..015cc4e 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -29,6 +29,14 @@ const runnerLogTrimIntervalMs = Math.max( Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'), ); const STREAM_CAPTURE_LIMIT = 256 * 1024; +const CODEX_LIVE_IDLE_TIMEOUT_MS = Math.max( + 30_000, + Number(process.env.SERVER_COMMAND_RUNNER_CODEX_IDLE_TIMEOUT_MS?.trim() || '90000'), +); +const CODEX_LIVE_MAX_EXECUTION_MS = Math.max( + CODEX_LIVE_IDLE_TIMEOUT_MS, + Number(process.env.SERVER_COMMAND_RUNNER_CODEX_MAX_EXECUTION_MS?.trim() || `${10 * 60 * 1000}`), +); const CODEX_HOME_RUNTIME_PATHS = [ 'auth.json', 'config.toml', @@ -342,12 +350,24 @@ function collectCodexTextFragments(value) { return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); } +function extractCompletedAgentMessageText(item) { + if (!item || typeof item !== 'object') { + return ''; + } + + if (item.type !== 'agent_message') { + return ''; + } + + return collectCodexTextFragments(item.text ?? item.content ?? item.message).join(''); +} + function extractCodexStreamText(parsed) { const type = typeof parsed.type === 'string' ? parsed.type : ''; if (type === 'item.completed') { return { - completedText: collectCodexTextFragments(parsed.item).join(''), + completedText: extractCompletedAgentMessageText(parsed.item), deltaText: '', }; } @@ -504,6 +524,9 @@ async function runCodexLiveExecution(payload, response) { let jsonLineBuffer = ''; let completedText = ''; let responseClosed = false; + let idleTimer = null; + let executionTimer = null; + let terminationRequested = false; response.writeHead(200, { 'content-type': 'application/x-ndjson; charset=utf-8', @@ -540,6 +563,68 @@ async function runCodexLiveExecution(payload, response) { await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); }; + const clearExecutionTimers = () => { + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = null; + } + + if (executionTimer) { + clearTimeout(executionTimer); + executionTimer = null; + } + }; + + const requestTermination = (message) => { + if (terminationRequested) { + return; + } + + terminationRequested = true; + clearExecutionTimers(); + + if (!responseClosed) { + sendJsonLine(response, { + type: 'error', + message, + }); + response.end(); + responseClosed = true; + } + + child.kill('SIGTERM'); + setTimeout(() => { + if (!child.killed) { + child.kill('SIGKILL'); + } + }, 3000).unref?.(); + }; + + const refreshIdleTimer = () => { + if (terminationRequested) { + return; + } + + if (idleTimer) { + clearTimeout(idleTimer); + } + + idleTimer = setTimeout(() => { + requestTermination( + `Codex Live 실행이 ${Math.round(CODEX_LIVE_IDLE_TIMEOUT_MS / 1000)}초 동안 출력이 없어 중단되었습니다.`, + ); + }, CODEX_LIVE_IDLE_TIMEOUT_MS); + idleTimer.unref?.(); + }; + + executionTimer = setTimeout(() => { + requestTermination( + `Codex Live 실행이 ${Math.round(CODEX_LIVE_MAX_EXECUTION_MS / 1000)}초를 넘어 중단되었습니다.`, + ); + }, CODEX_LIVE_MAX_EXECUTION_MS); + executionTimer.unref?.(); + refreshIdleTimer(); + const handleCodexJsonLine = (line) => { let parsed; @@ -552,6 +637,7 @@ async function runCodexLiveExecution(payload, response) { const activityLog = extractCodexActivityLog(parsed); if (activityLog) { + refreshIdleTimer(); sendJsonLine(response, { type: 'activity', line: activityLog, @@ -561,6 +647,7 @@ async function runCodexLiveExecution(payload, response) { const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed); if (nextCompletedText) { + refreshIdleTimer(); completedText = nextCompletedText; sendJsonLine(response, { type: 'completed', @@ -570,6 +657,7 @@ async function runCodexLiveExecution(payload, response) { } if (deltaText) { + refreshIdleTimer(); sendJsonLine(response, { type: 'delta', text: deltaText, @@ -582,9 +670,11 @@ async function runCodexLiveExecution(payload, response) { response.on('close', () => { responseClosed = true; + clearExecutionTimers(); }); child.stdout?.on('data', (chunk) => { + refreshIdleTimer(); const text = String(chunk); stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT); jsonLineBuffer += text; @@ -612,6 +702,7 @@ async function runCodexLiveExecution(payload, response) { }); child.stderr?.on('data', (chunk) => { + refreshIdleTimer(); const text = String(chunk); stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT); text @@ -631,6 +722,7 @@ async function runCodexLiveExecution(payload, response) { }); child.on('error', async (error) => { + clearExecutionTimers(); if (!responseClosed) { sendJsonLine(response, { type: 'error', @@ -643,6 +735,7 @@ async function runCodexLiveExecution(payload, response) { }); child.on('close', async (code) => { + clearExecutionTimers(); const trailingLine = jsonLineBuffer.trim(); if (trailingLine) { handleCodexJsonLine(trailingLine); diff --git a/scripts/server-command-runner-supervisor.sh b/scripts/server-command-runner-supervisor.sh new file mode 100755 index 0000000..4190210 --- /dev/null +++ b/scripts/server-command-runner-supervisor.sh @@ -0,0 +1,69 @@ +#!/bin/sh + +set -eu + +PROJECT_ROOT="${PROJECT_ROOT:-$(pwd)}" +RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}" +RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}" +SUPERVISOR_PID_FILE="${SERVER_COMMAND_RUNNER_SUPERVISOR_PID_FILE:-/tmp/server-command-runner-supervisor.pid}" +CHILD_PID="" +STOP_REQUESTED="0" +RELOAD_REQUESTED="0" + +log() { + printf '[server-command-runner-supervisor] %s\n' "$*" +} + +cleanup() { + rm -f "$SUPERVISOR_PID_FILE" +} + +start_child() { + log "starting runner child" + "$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" & + CHILD_PID=$! +} + +request_reload() { + RELOAD_REQUESTED="1" + log "reload requested" + if [ -n "$CHILD_PID" ]; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + fi +} + +request_stop() { + STOP_REQUESTED="1" + log "shutdown requested" + if [ -n "$CHILD_PID" ]; then + kill -TERM "$CHILD_PID" 2>/dev/null || true + fi +} + +trap 'request_reload' HUP +trap 'request_stop' INT TERM +trap 'cleanup' EXIT + +printf '%s\n' "$$" >"$SUPERVISOR_PID_FILE" +cd "$PROJECT_ROOT" + +while :; do + start_child + set +e + wait "$CHILD_PID" + EXIT_CODE=$? + set -e + CHILD_PID="" + + if [ "$STOP_REQUESTED" = "1" ]; then + exit "$EXIT_CODE" + fi + + if [ "$RELOAD_REQUESTED" = "1" ]; then + RELOAD_REQUESTED="0" + continue + fi + + log "runner exited unexpectedly with code $EXIT_CODE; restarting in 2 seconds" + sleep 2 +done diff --git a/src/app/main/AutomationTypeManagementPage.tsx b/src/app/main/AutomationTypeManagementPage.tsx index 1505e06..36dd783 100644 --- a/src/app/main/AutomationTypeManagementPage.tsx +++ b/src/app/main/AutomationTypeManagementPage.tsx @@ -1,12 +1,13 @@ import { ArrowsAltOutlined, - ArrowLeftOutlined, DeleteOutlined, EditOutlined, + SaveOutlined, PlusOutlined, ShrinkOutlined, + UnorderedListOutlined, } from '@ant-design/icons'; -import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd'; +import { Alert, Button, Card, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { @@ -78,8 +79,13 @@ export function AutomationTypeManagementPage() { }, [automationTypes, selectedAutomationTypeId]); useEffect(() => { + if (detailMode !== 'detail') { + return; + } + + form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType)); - }, [form, isCreating, selectedAutomationType]); + }, [detailMode, form, isCreating, selectedAutomationType]); useEffect(() => { if (detailMode !== 'detail') { @@ -163,6 +169,41 @@ export function AutomationTypeManagementPage() { } }; + const detailHeaderActions = ( + + + - - - )} - -
+
+ -
-
- 입력 - {isMobileViewport ? ( + + + + + +
+
+ + 설명 + +
+
+ {isMobileViewport ? ( + { + setMobileView(value as 'edit' | 'preview'); + setMaximizedPane('none'); + }} + /> + ) : ( + - ) : null} -
- - - + + + )}
-
+
- 미리보기 + 입력 {isMobileViewport ? ( ) : null}
-
- prev.description !== next.description}> - {({ getFieldValue }) => { - const description = String(getFieldValue('description') ?? '').trim(); + + + +
+
+
+
+ 미리보기 + {isMobileViewport ? ( + + ) : null} +
+
+ prev.description !== next.description}> + {({ getFieldValue }) => { + const description = String(getFieldValue('description') ?? '').trim(); - return description ? ( - - ) : ( - - ); - }} - + return description ? ( + + ) : ( + + ); + }} + +
-
- - - - - - {!isCreating && selectedAutomationType ? ( - - ) : null} - - -
diff --git a/src/app/main/ChatTypeManagementPage.css b/src/app/main/ChatTypeManagementPage.css index 83ae67d..8dd5184 100755 --- a/src/app/main/ChatTypeManagementPage.css +++ b/src/app/main/ChatTypeManagementPage.css @@ -2,6 +2,8 @@ width: 100%; height: 100%; min-height: 0; + display: flex; + flex-direction: column; overflow: hidden; } @@ -17,21 +19,31 @@ min-height: 0; } +.chat-type-management-page__card { + flex: 1 1 auto; +} + +.chat-type-management-page .ant-card, +.chat-type-management-page__card { + display: flex; + flex-direction: column; +} + .chat-type-management-page .ant-card-head { - min-height: 52px; - padding: 0 14px; + min-height: 44px; + padding: 0 12px; } .chat-type-management-page .ant-card-head-title, .chat-type-management-page .ant-card-extra { - padding: 10px 0; + padding: 6px 0; } .chat-type-management-page .ant-card-body { display: flex; flex-direction: column; overflow: hidden; - padding: 12px 14px; + padding: 4px 14px 12px; } .chat-type-management-page__list, @@ -40,7 +52,7 @@ min-height: 0; display: flex; flex-direction: column; - gap: 8px; + gap: 6px; height: 100%; overflow: hidden; } @@ -57,12 +69,23 @@ min-height: 0; display: flex; flex-direction: column; - gap: 6px; + gap: 2px; overflow: hidden; } +.chat-type-management-page__editor-scroll { + width: 100%; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + padding: 0 0 8px; +} + .chat-type-management-page__editor-form .ant-form-item { - margin-bottom: 8px; + margin-bottom: 6px; } .chat-type-management-page__list-header { @@ -73,7 +96,7 @@ } .chat-type-management-page__list-header .ant-typography { - margin-bottom: 0; + margin: 0; } .chat-type-management-page__item { @@ -137,15 +160,28 @@ .chat-type-management-page__editor-toolbar { display: flex; align-items: center; - justify-content: space-between; - gap: 6px; + justify-content: flex-end; + gap: 8px; +} + +.chat-type-management-page__header-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + width: 100%; +} + +.chat-type-management-page__header-actions .ant-btn { + width: 36px; + min-width: 36px; + height: 36px; } .chat-type-management-page__markdown-grid { width: 100%; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 6px; + grid-template-columns: minmax(0, 1.55fr) minmax(320px, 1fr); + gap: 12px; align-items: stretch; flex: 1; min-height: 0; @@ -173,6 +209,15 @@ margin-bottom: 0; } +.chat-type-management-page__markdown-pane .ant-form-item-control, +.chat-type-management-page__markdown-pane .ant-form-item-control-input, +.chat-type-management-page__markdown-pane .ant-form-item-control-input-content { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + .chat-type-management-page__markdown-pane--desktop-hidden { display: none; } @@ -191,13 +236,13 @@ .chat-type-management-page__markdown-textarea { height: 100% !important; - min-height: 180px; + min-height: 360px; resize: none; } .chat-type-management-page__markdown-textarea textarea { height: 100% !important; - min-height: 180px; + min-height: 360px; overflow: auto !important; resize: none; } @@ -209,10 +254,10 @@ border: 1px solid #f0f0f0; border-radius: 12px; background: #fafafa; - padding: 10px; - display: flex; - flex-direction: column; - gap: 6px; + padding: 10px 12px; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: 8px; overflow: hidden; } @@ -226,23 +271,11 @@ margin-bottom: 0; } -.chat-type-management-page__form-actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding-top: 2px; -} - -.chat-type-management-page__form-actions--compact { - padding-top: 0; -} - .chat-type-management-page__meta-grid { display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) auto; - gap: 8px 12px; - align-items: start; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px 14px; + align-items: end; } .chat-type-management-page__meta-grid--hidden { @@ -251,16 +284,45 @@ .chat-type-management-page__meta-item { min-width: 0; + margin-bottom: 0; } .chat-type-management-page__meta-item .ant-form-item-label { - padding-bottom: 4px; + padding-bottom: 2px; +} + +.chat-type-management-page__meta-item .ant-form-item-control-input { + min-height: 40px; } .chat-type-management-page__meta-item--enabled .ant-form-item-control-input { min-height: 40px; } +.chat-type-management-page__meta-item--permissions .ant-checkbox-group { + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 8px 14px; +} + +.chat-type-management-page__meta-item--permissions .ant-checkbox-wrapper { + margin-inline-start: 0; +} + +.chat-type-management-page__meta-item--name { + grid-column: 1 / -1; +} + +.chat-type-management-page__meta-item--enabled { + justify-self: end; +} + +.chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content { + display: flex; + justify-content: flex-end; +} + .chat-type-management-page--pane-maximized .chat-type-management-page__editor, .chat-type-management-page--pane-maximized .chat-type-management-page__editor-form, .chat-type-management-page--pane-maximized .chat-type-management-page__markdown-field, @@ -293,6 +355,23 @@ min-height: 0; } + .chat-type-management-page { + flex: 1 1 auto; + min-height: 0; + } + + .chat-type-management-page .ant-card-body { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + } + + .chat-type-management-page__editor-scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + } + .chat-type-management-page__list-header { align-items: flex-start; } @@ -305,7 +384,18 @@ .chat-type-management-page .ant-card-head-title, .chat-type-management-page .ant-card-extra, .chat-type-management-page .ant-card-body { - padding: 10px; + padding: 7px 10px; + } + + .chat-type-management-page .ant-card-head-title, + .chat-type-management-page .ant-card-extra { + padding-top: 6px; + padding-bottom: 6px; + } + + .chat-type-management-page__editor-scroll { + gap: 3px; + padding: 0 0 6px; } .chat-type-management-page__mobile-toggle { @@ -314,28 +404,83 @@ .chat-type-management-page__editor-toolbar { flex-wrap: wrap; + justify-content: space-between; + gap: 6px; } .chat-type-management-page__meta-grid { - grid-template-columns: minmax(0, 1fr); - gap: 6px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px 12px; + align-items: start; + } + + .chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content { + justify-content: flex-end; } .chat-type-management-page__markdown-grid { grid-template-columns: minmax(0, 1fr); + gap: 8px; + overflow: hidden; + } + + .chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-textarea), + .chat-type-management-page__markdown-pane:has(.chat-type-management-page__markdown-preview) { + min-height: 0; } .chat-type-management-page__markdown-pane--mobile-hidden { display: none; } + .chat-type-management-page__markdown-pane, + .chat-type-management-page__markdown-field, + .chat-type-management-page__markdown-editor, + .chat-type-management-page__markdown-preview { + overflow: hidden; + } + + .chat-type-management-page__markdown-pane .ant-form-item-control, + .chat-type-management-page__markdown-pane .ant-form-item-control-input, + .chat-type-management-page__markdown-pane .ant-form-item-control-input-content { + flex: 1 1 auto; + min-height: 0; + } + .chat-type-management-page__markdown-textarea, .chat-type-management-page__markdown-textarea textarea, .chat-type-management-page__markdown-preview-body { min-height: 0; } - .chat-type-management-page__form-actions { - flex-wrap: wrap; + .chat-type-management-page__markdown-textarea { + height: 100% !important; + min-height: 0; + } + + .chat-type-management-page__markdown-textarea textarea { + height: 100% !important; + min-height: 0 !important; + max-height: none !important; + overflow: auto !important; + } + + .chat-type-management-page__markdown-preview { + padding: 8px 10px; + } + + .chat-type-management-page__markdown-preview-body { + max-height: none; + overflow: auto; + } + + .chat-type-management-page__header-actions { + gap: 4px; + } + + .chat-type-management-page__header-actions .ant-btn { + width: 34px; + min-width: 34px; + height: 34px; } } diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 8037fdb..618d408 100755 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -1,12 +1,13 @@ import { ArrowsAltOutlined, - ArrowLeftOutlined, DeleteOutlined, EditOutlined, + SaveOutlined, PlusOutlined, ShrinkOutlined, + UnorderedListOutlined, } from '@ant-design/icons'; -import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Typography } from 'antd'; +import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { @@ -82,8 +83,13 @@ export function ChatTypeManagementPage() { }, [chatTypes, selectedChatTypeId]); useEffect(() => { + if (detailMode !== 'detail') { + return; + } + + form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); - }, [form, isCreating, selectedChatType]); + }, [detailMode, form, isCreating, selectedChatType]); useEffect(() => { if (detailMode !== 'detail') { @@ -145,7 +151,7 @@ export function ChatTypeManagementPage() { return; } - if (!window.confirm(`"${selectedChatType.name}" 컨텍스트를 삭제할까요?`)) { + if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 비활성화할까요?`)) { return; } @@ -167,6 +173,52 @@ export function ChatTypeManagementPage() { } }; + const detailHeaderActions = ( + + + - - - )} -
-
+
+ -
-
- 입력 - {isMobileViewport ? ( + + + + + + + + +
+
+ + 기본 문맥 설명 + +
+
+ {isMobileViewport ? ( + { + setMobileView(value as 'edit' | 'preview'); + setMaximizedPane('none'); + }} + /> + ) : ( + - ) : null} -
- - - + + + )}
-
+
- 미리보기 + 입력 {isMobileViewport ? ( ) : null}
-
- prev.description !== next.description}> - {({ getFieldValue }) => { - const description = String(getFieldValue('description') ?? '').trim(); + + + +
+
+
+
+ 미리보기 + {isMobileViewport ? ( + + ) : null} +
+
+ prev.description !== next.description}> + {({ getFieldValue }) => { + const description = String(getFieldValue('description') ?? '').trim(); - return description ? ( - - ) : ( - - ); - }} - + return description ? ( + + ) : ( + + ); + }} + +
-
- - - - - - {!isCreating && selectedChatType ? ( - - ) : null} - - -
diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css index 16915d2..af77df8 100644 --- a/src/app/main/MainChatPanel.hotfix.css +++ b/src/app/main/MainChatPanel.hotfix.css @@ -2131,12 +2131,12 @@ z-index: 4; display: flex; flex-direction: column; - gap: 6px; - width: min(240px, calc(100% - 16px)); - max-height: min(28vh, 180px); - padding: 8px; + gap: 8px; + width: min(420px, calc(100% - 16px)); + max-height: min(58vh, 520px); + padding: 10px; border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 12px; + border-radius: 16px; background: rgba(246, 248, 252, 0.96); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); overflow: hidden; @@ -2145,7 +2145,7 @@ .app-chat-panel__resource-strip-list { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; overflow: auto; } @@ -2171,22 +2171,18 @@ line-height: 1.5; } -.app-chat-panel__resource-chip { - display: inline-flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; - width: 100%; - padding: 6px 8px; - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 10px; - background: rgba(255, 255, 255, 0.9); - color: #0f172a; - cursor: pointer; - font-size: 11px; - text-align: left; +.app-chat-panel__resource-strip .app-chat-preview-card { + margin: 0; +} + +.app-chat-panel__resource-strip .app-chat-preview-card__body { + padding-top: 8px; +} + +.app-chat-panel__resource-strip .app-chat-panel__preview-rich, +.app-chat-panel__resource-strip .previewer-ui__editor, +.app-chat-panel__resource-strip .previewer-ui__editor-body { + min-height: 0; } .app-chat-panel__preview-stage { @@ -2596,10 +2592,6 @@ padding-bottom: 2px; } - .app-chat-panel__resource-chip { - min-width: 160px; - } - .app-chat-panel__preview-image, .app-chat-panel__preview-video, .app-chat-panel__preview-frame, diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx index a63ba02..0849b5b 100644 --- a/src/app/main/MainChatPanel.tsx +++ b/src/app/main/MainChatPanel.tsx @@ -6,6 +6,7 @@ import { CopyOutlined, DownloadOutlined, EditOutlined, + EyeOutlined, ExclamationCircleOutlined, FullscreenExitOutlined, FullscreenOutlined, @@ -34,6 +35,7 @@ import { useConversationViewportController } from './chatV2/hooks/useConversatio import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl'; import { triggerResourceDownload } from './mainChatPanel/downloadUtils'; +import { extractAutoDetectedPreviewUrls } from './mainChatPanel/inlinePreviewUrls'; import { extractHiddenPreviewUrls } from './mainChatPanel/previewMarkers'; import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; @@ -47,9 +49,7 @@ import { createChatMessage, createLocalMessage, ErrorLogViewer, - getStoredChatSessionLastTypeId, isPreparingChatReplyText, - setStoredChatSessionLastTypeId, sortChatConversationSummaries, upsertChatMessage, useErrorLogs, @@ -666,7 +666,7 @@ function isPreviewRouteUrl(url: string) { const parsed = new URL(url, window.location.origin); const pathname = parsed.pathname.toLowerCase(); const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname); - return !hasKnownFileExtension && /^https?:$/.test(parsed.protocol); + return parsed.origin === window.location.origin && !hasKnownFileExtension && /^https?:$/.test(parsed.protocol); } catch { return false; } @@ -725,14 +725,28 @@ function buildPreviewLabel(url: string, source: PreviewItem['source']) { } } +function isHtmlPreviewItem(item: PreviewItem | null | undefined) { + if (!item || item.kind !== 'code') { + return false; + } + + try { + const parsed = new URL(item.url, typeof window !== 'undefined' ? window.location.origin : 'https://local.invalid'); + const pathname = parsed.pathname.toLowerCase(); + return pathname.endsWith('.html') || pathname.endsWith('.htm'); + } catch { + const pathname = item.url.toLowerCase().split('?')[0] ?? ''; + return pathname.endsWith('.html') || pathname.endsWith('.htm'); + } +} + function extractPreviewItems(messages: ChatMessage[]) { - const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g; const seen = new Set(); const items: PreviewItem[] = []; const orderedMessages = [...messages].reverse(); orderedMessages.forEach((message) => { - const matches = [...(message.text.match(urlPattern) ?? []), ...extractHiddenPreviewUrls(message.text)]; + const matches = [...extractAutoDetectedPreviewUrls(message.text), ...extractHiddenPreviewUrls(message.text)]; matches.forEach((matchedUrl) => { const normalizedUrl = normalizePreviewUrl(matchedUrl); @@ -895,7 +909,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = [chatTypes, userRoles], ); const [selectedChatTypeId, setSelectedChatTypeId] = useState(availableChatTypes[0]?.id ?? null); - const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null; + const selectedChatType = chatTypes.find((item) => item.id === selectedChatTypeId) ?? null; + const isSelectedChatTypeAllowed = selectedChatType ? canUseChatType(selectedChatType, userRoles) : false; const requestedSessionId = getSessionIdFromSearch(location.search); const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search); const requestedChatView = getRequestedChatViewFromSearch(location.search); @@ -940,6 +955,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const previewSearchMatchesRef = useRef([]); const previewSearchMatchIndexRef = useRef(-1); const previewSearchKeyRef = useRef(''); + const [isHtmlPreviewMode, setIsHtmlPreviewMode] = useState(false); const titleClusterRef = useRef(null); const copyFeedbackTimerRef = useRef(null); const pendingRequestsRef = useRef([]); @@ -950,7 +966,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const shouldRestoreConversationAfterReconnectRef = useRef(false); const handledRequestedSessionIdRef = useRef(''); const isClosingConversationRef = useRef(false); - const lastChatTypeSessionIdRef = useRef(''); const notifiedTerminalJobKeysRef = useRef([]); const lastMarkedReadResponseIdBySessionRef = useRef>({}); const requestItems = Array.isArray(requestItemsState) ? requestItemsState : []; @@ -966,19 +981,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }); }, []); - const currentContext: ChatViewContext = { - pageId: currentPage.id, - pageTitle: currentPage.title, - topMenu: currentPage.topMenu, - focusedComponentId, - pageUrl: typeof window !== 'undefined' ? window.location.href : '', - isStandaloneMode: isStandaloneDisplayMode(), - pageVisibilityState: - typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', - chatTypeId: selectedChatType?.id ?? null, - chatTypeLabel: selectedChatType?.name ?? '', - chatTypeDescription: selectedChatType?.description ?? '', - }; const { conversationItems, setConversationItems, @@ -1029,14 +1031,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const handleCreateConversation = async () => { const sessionId = createConversationSessionId(); const now = new Date().toISOString(); + const nextConversationChatType = + selectedChatType && isSelectedChatTypeAllowed ? selectedChatType : (availableChatTypes[0] ?? null); const optimisticItem: ChatConversationSummary = { sessionId, clientId: null, title: '새 대화', - chatTypeId: selectedChatType?.id ?? null, - lastChatTypeId: selectedChatType?.id ?? null, - contextLabel: selectedChatType?.name ?? null, - contextDescription: selectedChatType?.description ?? null, + chatTypeId: nextConversationChatType?.id ?? null, + lastChatTypeId: nextConversationChatType?.id ?? null, + contextLabel: nextConversationChatType?.name ?? null, + contextDescription: nextConversationChatType?.description ?? null, notifyOffline: true, hasUnreadResponse: false, currentRequestId: null, @@ -1059,10 +1063,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const item = await chatGateway.createConversation({ sessionId, title: '새 대화', - chatTypeId: selectedChatType?.id ?? null, - lastChatTypeId: selectedChatType?.id ?? null, - contextLabel: selectedChatType?.name, - contextDescription: selectedChatType?.description, + chatTypeId: nextConversationChatType?.id ?? null, + lastChatTypeId: nextConversationChatType?.id ?? null, + contextLabel: nextConversationChatType?.name, + contextDescription: nextConversationChatType?.description, notifyOffline: true, }); @@ -1384,6 +1388,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } }; + const previewItems = useMemo( + () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))), + [messages], + ); + const isTabletAppLayout = isMobileViewport; + const chatMessages = useMemo( + () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)), + [messages], + ); + const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]); + const activeConversation = useMemo( + () => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null, + [activeSessionId, conversationItems], + ); + const persistedActiveChatTypeId = + activeConversation?.chatTypeId?.trim() || activeConversation?.lastChatTypeId?.trim() || null; + const effectiveChatType = useMemo(() => { + if (persistedActiveChatTypeId) { + const persistedChatType = chatTypes.find((item) => item.id === persistedActiveChatTypeId); + if (persistedChatType) { + return persistedChatType; + } + + return { + id: persistedActiveChatTypeId, + name: activeConversation?.contextLabel?.trim() || persistedActiveChatTypeId, + description: activeConversation?.contextDescription?.trim() || '', + }; + } + + return selectedChatType; + }, [ + activeConversation?.contextDescription, + activeConversation?.contextLabel, + chatTypes, + persistedActiveChatTypeId, + selectedChatType, + ]); + const effectiveChatTypeId = effectiveChatType?.id ?? null; + const effectiveRegisteredChatType = + effectiveChatType ? chatTypes.find((item) => item.id === effectiveChatType.id) ?? null : null; + const isEffectiveChatTypeAllowed = effectiveRegisteredChatType + ? canUseChatType(effectiveRegisteredChatType, userRoles) + : false; + const currentContext: ChatViewContext = { + pageId: currentPage.id, + pageTitle: currentPage.title, + topMenu: currentPage.topMenu, + focusedComponentId, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + isStandaloneMode: isStandaloneDisplayMode(), + pageVisibilityState: + typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + chatTypeId: effectiveChatType?.id ?? null, + chatTypeLabel: effectiveChatType?.name ?? '', + chatTypeDescription: effectiveChatType?.description ?? '', + }; const { socketRef, connectionState } = chatConnectionGateway.useConnection({ sessionId: activeSessionId, currentContext, @@ -1410,21 +1471,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = activeView, hasAccess, }); - - const previewItems = useMemo( - () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))), - [messages], - ); - const isTabletAppLayout = isMobileViewport; - const chatMessages = useMemo( - () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)), - [messages], - ); - const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]); - const activeConversation = useMemo( - () => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null, - [activeSessionId, conversationItems], - ); const activeRuntimeStatus = useMemo( () => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId), [runtimeSnapshot, activeSessionId], @@ -1576,7 +1622,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = activeSessionId, activeView, previewItems, - selectedChatTypeId: selectedChatType?.id ?? null, + selectedChatTypeId, composerRef, setActiveSystemStatus, setComposerAttachments, @@ -1634,10 +1680,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } }, [activePreview, messageApi]); + const isActivePreviewHtml = isHtmlPreviewItem(activePreview); + const canSearchActivePreview = Boolean(activePreview) && !isPreviewLoading && !previewError.trim() && + !(isActivePreviewHtml && isHtmlPreviewMode) && (activePreview?.kind === 'markdown' || activePreview?.kind === 'code' || activePreview?.kind === 'document'); const resetActivePreviewSearchState = useCallback(() => { @@ -1673,6 +1722,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = clearActivePreviewSearchSelection(); }, [activePreview?.id, clearActivePreviewSearchSelection, resetActivePreviewSearchState]); + useEffect(() => { + setIsHtmlPreviewMode(false); + }, [activePreview?.id, isPreviewModalOpen]); + useEffect(() => { resetActivePreviewSearchState(); }, [previewFindQuery, resetActivePreviewSearchState]); @@ -2254,25 +2307,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, []); useEffect(() => { - const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId; - lastChatTypeSessionIdRef.current = activeSessionId; - if (activeSessionId) { - if (hasSessionChanged) { - const lastUsedChatTypeId = - activeConversation?.lastChatTypeId?.trim() || getStoredChatSessionLastTypeId(activeSessionId); + if (!activeConversation) { + return; + } - if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) { - if (selectedChatTypeId !== lastUsedChatTypeId) { - setSelectedChatTypeId(lastUsedChatTypeId); - } - return; - } + const persistedChatTypeId = + activeConversation.chatTypeId?.trim() || activeConversation.lastChatTypeId?.trim() || null; - const defaultChatTypeId = availableChatTypes[0]?.id ?? null; - - if (selectedChatTypeId !== defaultChatTypeId) { - setSelectedChatTypeId(defaultChatTypeId); + if (persistedChatTypeId) { + if (selectedChatTypeId !== persistedChatTypeId) { + setSelectedChatTypeId(persistedChatTypeId); } return; } @@ -2283,34 +2328,38 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } setSelectedChatTypeId(availableChatTypes[0]?.id ?? null); - }, [activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]); + }, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]); useEffect(() => { - if (!activeSessionId || !selectedChatTypeId) { + if (!activeSessionId || !selectedChatTypeId || !selectedChatType) { return; } - setStoredChatSessionLastTypeId(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) { + const currentChatTypeId = activeConversation?.chatTypeId?.trim() || null; + if (currentChatTypeId) { return; } void chatGateway.updateConversation(activeSessionId, { + chatTypeId: selectedChatTypeId, lastChatTypeId: selectedChatTypeId, + contextLabel: selectedChatType.name, + contextDescription: selectedChatType.description, + }).then((item) => { + setConversationItems((previous) => + previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)), + ); }).catch(() => { // Ignore background sync failures and keep local in-memory fallback. }); - }, [activeConversation?.lastChatTypeId, activeSessionId, selectedChatTypeId, setConversationItems]); + }, [ + activeConversation?.chatTypeId, + activeConversation?.lastChatTypeId, + activeSessionId, + selectedChatType, + selectedChatTypeId, + setConversationItems, + ]); useEffect(() => { const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat'); @@ -2600,7 +2649,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = draft, composerAttachments, isComposerAttachmentUploading, - selectedChatType, + selectedChatType: effectiveChatType + ? { + id: effectiveChatType.id, + name: effectiveChatType.name, + description: effectiveChatType.description, + } + : null, socketRef, composerRef, messagesRef, @@ -2614,7 +2669,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsSystemStatusPending, setShowScrollToBottom, setPendingContextConfirm, - setStoredChatSessionLastTypeId, upsertRequestItem, syncConversationPreviewForRequest, updatePendingMessageStatus, @@ -2924,7 +2978,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = isPullToLoadArmed={isPullToLoadArmed} pullToLoadDistance={pullToLoadDistance} requestStateMap={activeRequestMap} - selectedChatTypeId={selectedChatType?.id ?? null} + selectedChatTypeId={effectiveChatTypeId} queuedRequests={activeQueuedComposerRequests.map((item, index) => ({ requestId: item.requestId, order: index + 1, @@ -2933,7 +2987,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = chatTypeOptions={chatTypeOptions} previewItems={previewItems.map((item) => ({ id: item.id, label: item.label, url: item.url, kind: item.kind }))} isResourceStripOpen={isResourceStripOpen} - isComposerDisabled={!selectedChatType} + isComposerDisabled={!effectiveChatType || !isEffectiveChatTypeAllowed} + isChatTypeSelectionLocked={Boolean(activeConversation?.chatTypeId?.trim())} isComposerAttachmentUploading={isComposerAttachmentUploading} onViewportScroll={handleViewportScroll} onViewportTouchEnd={handleViewportTouchEnd} @@ -2944,7 +2999,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = onRemoveComposerAttachment={(attachmentId) => { setComposerAttachments((previous) => previous.filter((attachment) => attachment.id !== attachmentId)); }} - onSelectChatType={setSelectedChatTypeId} + onSelectChatType={(nextChatTypeId) => { + if (activeConversation?.chatTypeId?.trim()) { + return; + } + + setSelectedChatTypeId(nextChatTypeId); + }} onSend={handleSend} onSendImmediate={handleSendImmediate} onClearDraft={() => { @@ -3061,6 +3122,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
{`${activePreview.label} preview`} + {isActivePreviewHtml ? ( + + ) : null} {canSearchActivePreview ? (
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 69f08d5..566989a 100755 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -961,16 +961,28 @@ export function MainHeader({ const workServerPendingUpdateCount = workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; const totalPendingUpdateCount = testServerPendingUpdateCount + prodServerPendingUpdateCount + workServerPendingUpdateCount; + const hasBuildRequiredUpdate = + Boolean(testServerStatus?.buildRequired) || + Boolean(prodServerStatus?.buildRequired) || + Boolean(workServerStatus?.buildRequired); const totalAutomationShortcutCount = planShortcutCounts.working + planShortcutCounts.releasePendingMain + planShortcutCounts.automationFailed; const settingsStatusClassName = - totalPendingUpdateCount >= 2 + hasBuildRequiredUpdate + ? 'app-header__status-dot--inactive' + : totalPendingUpdateCount >= 2 ? 'app-header__status-dot--inactive' : totalPendingUpdateCount === 1 ? 'app-header__status-dot--warning' : 'app-header__status-dot--active'; const settingsStatusLabel = - totalPendingUpdateCount >= 2 ? '모든 업데이트 존재' : totalPendingUpdateCount === 1 ? '업데이트 1건 존재' : '최신 상태'; + hasBuildRequiredUpdate + ? '커밋 미반영 업데이트 존재' + : totalPendingUpdateCount >= 2 + ? '모든 업데이트 존재' + : totalPendingUpdateCount === 1 + ? '업데이트 1건 존재' + : '최신 상태'; const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0; const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0; const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0; @@ -2832,7 +2844,7 @@ export function MainHeader({ <> + { + onOpenPreview(item.id, { fullscreen: true }); + }} + onToggle={() => { + setExpandedResourcePreviewKey((current) => (current === item.id ? null : item.id)); + }} + /> ))}
@@ -1248,13 +1274,13 @@ export function ChatConversationView({ const isExpandedMessage = expandedMessageIds.includes(message.id); const shouldTruncateMessage = canCollapseMessage && !isExpandedMessage; const messageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}`; - const { visibleText, diffBlocks } = extractMessageRenderPayload(message.text); + const { previewSourceText, visibleText, diffBlocks } = extractMessageRenderPayload(message.text); if (isActivityLogMessage(message)) { return renderActivityCard(message); } - const inlinePreviewTargets = extractInlinePreviewTargets(visibleText); + const inlinePreviewTargets = extractInlinePreviewTargets(previewSourceText); const hasPreviewCards = diffBlocks.length > 0 || inlinePreviewTargets.length > 0; const shouldRenderStandalonePreview = hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); @@ -1498,7 +1524,7 @@ export function ChatConversationView({ ), }))} getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} - disabled={chatTypeOptions.length === 0} + disabled={chatTypeOptions.length === 0 || isChatTypeSelectionLocked} onChange={onSelectChatType} />
diff --git a/src/app/main/mainChatPanel/ChatPreviewBody.tsx b/src/app/main/mainChatPanel/ChatPreviewBody.tsx index 669afbd..1f34721 100755 --- a/src/app/main/mainChatPanel/ChatPreviewBody.tsx +++ b/src/app/main/mainChatPanel/ChatPreviewBody.tsx @@ -203,6 +203,33 @@ function canRenderFramePreview(url: string) { } } +function buildHtmlFrameDocument(html: string, sourceUrl: string) { + const trimmed = html.trim(); + + if (!trimmed) { + return ''; + } + + const baseHref = (() => { + try { + return new URL('.', sourceUrl).toString(); + } catch { + return sourceUrl; + } + })(); + const baseTag = ``; + + if (/)/i.test(trimmed)) { + return trimmed.replace(/]*)>/i, (match) => `${match}${baseTag}`); + } + + if (/)/i.test(trimmed)) { + return trimmed.replace(/]*)>/i, (match) => `${match}${baseTag}`); + } + + return `${baseTag}${trimmed}`; +} + type ChatPreviewBodyProps = { target: ChatPreviewTarget | null; previewText: string; @@ -210,6 +237,7 @@ type ChatPreviewBodyProps = { previewError: string; previewContentType?: string; maxMarkdownBlocks?: number; + renderHtmlAsFrame?: boolean; }; function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) { @@ -238,6 +266,7 @@ export function ChatPreviewBody({ previewError, previewContentType, maxMarkdownBlocks, + renderHtmlAsFrame = false, }: ChatPreviewBodyProps) { if (!target) { return ; @@ -307,6 +336,16 @@ export function ChatPreviewBody({ if (target.kind === 'code' || target.kind === 'diff' || target.kind === 'document') { const resolvedLanguage = resolveCodeLanguage(target, previewText); + if (renderHtmlAsFrame && resolvedLanguage === 'html' && canRenderFramePreview(target.url)) { + return ( +